Skip to content

Commit 320c2ea

Browse files
authored
Entra External Auth for ADO (coder#12201)
1 parent 4439a92 commit 320c2ea

File tree

4 files changed

+83
-2
lines changed

4 files changed

+83
-2
lines changed

coderd/externalauth/externalauth.go

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -499,6 +499,9 @@ func ConvertConfig(instrument *promoauth.Factory, entries []codersdk.ExternalAut
499499
if entry.Type == string(codersdk.EnhancedExternalAuthProviderAzureDevops) {
500500
oauthConfig = &jwtConfig{oc}
501501
}
502+
if entry.Type == string(codersdk.EnhancedExternalAuthProviderAzureDevopsEntra) {
503+
oauthConfig = &entraV1Oauth{oc}
504+
}
502505
if entry.Type == string(codersdk.EnhancedExternalAuthProviderJFrog) {
503506
oauthConfig = &exchangeWithClientSecret{oc}
504507
}
@@ -569,6 +572,9 @@ func applyDefaultsToConfig(config *codersdk.ExternalAuthConfig) {
569572
case codersdk.EnhancedExternalAuthProviderGitea:
570573
copyDefaultSettings(config, giteaDefaults(config))
571574
return
575+
case codersdk.EnhancedExternalAuthProviderAzureDevopsEntra:
576+
copyDefaultSettings(config, azureDevopsEntraDefaults(config))
577+
return
572578
default:
573579
// No defaults for this type. We still want to run this apply with
574580
// an empty set of defaults.
@@ -730,6 +736,41 @@ func giteaDefaults(config *codersdk.ExternalAuthConfig) codersdk.ExternalAuthCon
730736
return defaults
731737
}
732738

739+
func azureDevopsEntraDefaults(config *codersdk.ExternalAuthConfig) codersdk.ExternalAuthConfig {
740+
defaults := codersdk.ExternalAuthConfig{
741+
DisplayName: "Azure DevOps (Entra)",
742+
DisplayIcon: "/icon/azure-devops.svg",
743+
Regex: `^(https?://)?dev\.azure\.com(/.*)?$`,
744+
}
745+
// The tenant ID is required for urls and is in the auth url.
746+
if config.AuthURL == "" {
747+
// No auth url, means we cannot guess the urls.
748+
return defaults
749+
}
750+
751+
auth, err := url.Parse(config.AuthURL)
752+
if err != nil {
753+
// We need a valid URL to continue with.
754+
return defaults
755+
}
756+
757+
// Only extract the tenant ID if the path is what we expect.
758+
// The path should be /{tenantId}/oauth2/authorize.
759+
parts := strings.Split(auth.Path, "/")
760+
if len(parts) < 4 && parts[2] != "oauth2" || parts[3] != "authorize" {
761+
// Not sure what this path is, abort.
762+
return defaults
763+
}
764+
tenantID := parts[1]
765+
766+
tokenURL := auth.ResolveReference(&url.URL{Path: fmt.Sprintf("/%s/oauth2/token", tenantID)})
767+
defaults.TokenURL = tokenURL.String()
768+
769+
// TODO: Discover a validate url for Azure DevOps.
770+
771+
return defaults
772+
}
773+
733774
var staticDefaults = map[codersdk.EnhancedExternalAuthProvider]codersdk.ExternalAuthConfig{
734775
codersdk.EnhancedExternalAuthProviderAzureDevops: {
735776
AuthURL: "https://app.vssps.visualstudio.com/oauth2/authorize",
@@ -811,6 +852,26 @@ func (c *jwtConfig) Exchange(ctx context.Context, code string, opts ...oauth2.Au
811852
)
812853
}
813854

855+
// When authenticating via Entra ID ADO only supports v1 tokens that requires the 'resource' rather than scopes
856+
// When ADO gets support for V2 Entra ID tokens this struct and functions can be removed
857+
type entraV1Oauth struct {
858+
*oauth2.Config
859+
}
860+
861+
const azureDevOpsAppID = "499b84ac-1321-427f-aa17-267ca6975798"
862+
863+
func (c *entraV1Oauth) AuthCodeURL(state string, opts ...oauth2.AuthCodeOption) string {
864+
return c.Config.AuthCodeURL(state, append(opts, oauth2.SetAuthURLParam("resource", azureDevOpsAppID))...)
865+
}
866+
867+
func (c *entraV1Oauth) Exchange(ctx context.Context, code string, opts ...oauth2.AuthCodeOption) (*oauth2.Token, error) {
868+
return c.Config.Exchange(ctx, code,
869+
append(opts,
870+
oauth2.SetAuthURLParam("resource", azureDevOpsAppID),
871+
)...,
872+
)
873+
}
874+
814875
// exchangeWithClientSecret wraps an OAuth config and adds the client secret
815876
// to the Exchange request as a Bearer header. This is used by JFrog Artifactory.
816877
type exchangeWithClientSecret struct {

codersdk/externalauth.go

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ func (e EnhancedExternalAuthProvider) Git() bool {
2525
EnhancedExternalAuthProviderBitBucketCloud,
2626
EnhancedExternalAuthProviderBitBucketServer,
2727
EnhancedExternalAuthProviderAzureDevops,
28+
EnhancedExternalAuthProviderAzureDevopsEntra,
2829
EnhancedExternalAuthProviderGitea:
2930
return true
3031
default:
@@ -34,8 +35,10 @@ func (e EnhancedExternalAuthProvider) Git() bool {
3435

3536
const (
3637
EnhancedExternalAuthProviderAzureDevops EnhancedExternalAuthProvider = "azure-devops"
37-
EnhancedExternalAuthProviderGitHub EnhancedExternalAuthProvider = "github"
38-
EnhancedExternalAuthProviderGitLab EnhancedExternalAuthProvider = "gitlab"
38+
// Authenticate to ADO using an app registration in Entra ID
39+
EnhancedExternalAuthProviderAzureDevopsEntra EnhancedExternalAuthProvider = "azure-devops-entra"
40+
EnhancedExternalAuthProviderGitHub EnhancedExternalAuthProvider = "github"
41+
EnhancedExternalAuthProviderGitLab EnhancedExternalAuthProvider = "gitlab"
3942
// EnhancedExternalAuthProviderBitBucketCloud is the Bitbucket Cloud provider.
4043
// Not to be confused with the self-hosted 'EnhancedExternalAuthProviderBitBucketServer'
4144
EnhancedExternalAuthProviderBitBucketCloud EnhancedExternalAuthProvider = "bitbucket-cloud"

docs/admin/external-auth.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ application. The following providers are supported:
2323
- [GitLab](https://docs.gitlab.com/ee/integration/oauth_provider.html)
2424
- [BitBucket](https://support.atlassian.com/bitbucket-cloud/docs/use-oauth-on-bitbucket-cloud/)
2525
- [Azure DevOps](https://learn.microsoft.com/en-us/azure/devops/integrate/get-started/authentication/oauth?view=azure-devops)
26+
- [Azure DevOps (via Entra ID)](https://learn.microsoft.com/en-us/entra/architecture/auth-oauth2)
2627

2728
Example callback URL:
2829
`https://coder.example.com/external-auth/primary-github/callback`. Use an
@@ -108,6 +109,20 @@ CODER_EXTERNAL_AUTH_0_AUTH_URL="https://app.vssps.visualstudio.com/oauth2/author
108109
CODER_EXTERNAL_AUTH_0_TOKEN_URL="https://app.vssps.visualstudio.com/oauth2/token"
109110
```
110111

112+
### Azure DevOps (via Entra ID)
113+
114+
Azure DevOps (via Entra ID) requires the following environment variables:
115+
116+
```env
117+
CODER_EXTERNAL_AUTH_0_ID="primary-azure-devops"
118+
CODER_EXTERNAL_AUTH_0_TYPE=azure-devops-entra
119+
CODER_EXTERNAL_AUTH_0_CLIENT_ID=xxxxxx
120+
CODER_EXTERNAL_AUTH_0_CLIENT_SECRET=xxxxxxx
121+
CODER_EXTERNAL_AUTH_0_AUTH_URL="https://login.microsoftonline.com/<TENANT ID>/oauth2/authorize"
122+
```
123+
124+
> Note: Your app registration in Entra ID requires the `vso.code_write` scope
125+
111126
### GitLab self-managed
112127

113128
GitLab self-managed requires the following environment variables:

site/src/api/typesGenerated.ts

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)