diff --git a/coderd/externalauth/externalauth.go b/coderd/externalauth/externalauth.go index b6c14fa180..e430a798e6 100644 --- a/coderd/externalauth/externalauth.go +++ b/coderd/externalauth/externalauth.go @@ -499,6 +499,9 @@ func ConvertConfig(instrument *promoauth.Factory, entries []codersdk.ExternalAut if entry.Type == string(codersdk.EnhancedExternalAuthProviderAzureDevops) { oauthConfig = &jwtConfig{oc} } + if entry.Type == string(codersdk.EnhancedExternalAuthProviderAzureDevopsEntra) { + oauthConfig = &entraV1Oauth{oc} + } if entry.Type == string(codersdk.EnhancedExternalAuthProviderJFrog) { oauthConfig = &exchangeWithClientSecret{oc} } @@ -569,6 +572,9 @@ func applyDefaultsToConfig(config *codersdk.ExternalAuthConfig) { case codersdk.EnhancedExternalAuthProviderGitea: copyDefaultSettings(config, giteaDefaults(config)) return + case codersdk.EnhancedExternalAuthProviderAzureDevopsEntra: + copyDefaultSettings(config, azureDevopsEntraDefaults(config)) + return default: // No defaults for this type. We still want to run this apply with // an empty set of defaults. @@ -730,6 +736,41 @@ func giteaDefaults(config *codersdk.ExternalAuthConfig) codersdk.ExternalAuthCon return defaults } +func azureDevopsEntraDefaults(config *codersdk.ExternalAuthConfig) codersdk.ExternalAuthConfig { + defaults := codersdk.ExternalAuthConfig{ + DisplayName: "Azure DevOps (Entra)", + DisplayIcon: "/icon/azure-devops.svg", + Regex: `^(https?://)?dev\.azure\.com(/.*)?$`, + } + // The tenant ID is required for urls and is in the auth url. + if config.AuthURL == "" { + // No auth url, means we cannot guess the urls. + return defaults + } + + auth, err := url.Parse(config.AuthURL) + if err != nil { + // We need a valid URL to continue with. + return defaults + } + + // Only extract the tenant ID if the path is what we expect. + // The path should be /{tenantId}/oauth2/authorize. + parts := strings.Split(auth.Path, "/") + if len(parts) < 4 && parts[2] != "oauth2" || parts[3] != "authorize" { + // Not sure what this path is, abort. + return defaults + } + tenantID := parts[1] + + tokenURL := auth.ResolveReference(&url.URL{Path: fmt.Sprintf("/%s/oauth2/token", tenantID)}) + defaults.TokenURL = tokenURL.String() + + // TODO: Discover a validate url for Azure DevOps. + + return defaults +} + var staticDefaults = map[codersdk.EnhancedExternalAuthProvider]codersdk.ExternalAuthConfig{ codersdk.EnhancedExternalAuthProviderAzureDevops: { AuthURL: "https://app.vssps.visualstudio.com/oauth2/authorize", @@ -811,6 +852,26 @@ func (c *jwtConfig) Exchange(ctx context.Context, code string, opts ...oauth2.Au ) } +// When authenticating via Entra ID ADO only supports v1 tokens that requires the 'resource' rather than scopes +// When ADO gets support for V2 Entra ID tokens this struct and functions can be removed +type entraV1Oauth struct { + *oauth2.Config +} + +const azureDevOpsAppID = "499b84ac-1321-427f-aa17-267ca6975798" + +func (c *entraV1Oauth) AuthCodeURL(state string, opts ...oauth2.AuthCodeOption) string { + return c.Config.AuthCodeURL(state, append(opts, oauth2.SetAuthURLParam("resource", azureDevOpsAppID))...) +} + +func (c *entraV1Oauth) Exchange(ctx context.Context, code string, opts ...oauth2.AuthCodeOption) (*oauth2.Token, error) { + return c.Config.Exchange(ctx, code, + append(opts, + oauth2.SetAuthURLParam("resource", azureDevOpsAppID), + )..., + ) +} + // exchangeWithClientSecret wraps an OAuth config and adds the client secret // to the Exchange request as a Bearer header. This is used by JFrog Artifactory. type exchangeWithClientSecret struct { diff --git a/codersdk/externalauth.go b/codersdk/externalauth.go index b4013e70ad..49e1a8f262 100644 --- a/codersdk/externalauth.go +++ b/codersdk/externalauth.go @@ -25,6 +25,7 @@ func (e EnhancedExternalAuthProvider) Git() bool { EnhancedExternalAuthProviderBitBucketCloud, EnhancedExternalAuthProviderBitBucketServer, EnhancedExternalAuthProviderAzureDevops, + EnhancedExternalAuthProviderAzureDevopsEntra, EnhancedExternalAuthProviderGitea: return true default: @@ -34,8 +35,10 @@ func (e EnhancedExternalAuthProvider) Git() bool { const ( EnhancedExternalAuthProviderAzureDevops EnhancedExternalAuthProvider = "azure-devops" - EnhancedExternalAuthProviderGitHub EnhancedExternalAuthProvider = "github" - EnhancedExternalAuthProviderGitLab EnhancedExternalAuthProvider = "gitlab" + // Authenticate to ADO using an app registration in Entra ID + EnhancedExternalAuthProviderAzureDevopsEntra EnhancedExternalAuthProvider = "azure-devops-entra" + EnhancedExternalAuthProviderGitHub EnhancedExternalAuthProvider = "github" + EnhancedExternalAuthProviderGitLab EnhancedExternalAuthProvider = "gitlab" // EnhancedExternalAuthProviderBitBucketCloud is the Bitbucket Cloud provider. // Not to be confused with the self-hosted 'EnhancedExternalAuthProviderBitBucketServer' EnhancedExternalAuthProviderBitBucketCloud EnhancedExternalAuthProvider = "bitbucket-cloud" diff --git a/docs/admin/external-auth.md b/docs/admin/external-auth.md index ce14f97281..0ee5025393 100644 --- a/docs/admin/external-auth.md +++ b/docs/admin/external-auth.md @@ -23,6 +23,7 @@ application. The following providers are supported: - [GitLab](https://docs.gitlab.com/ee/integration/oauth_provider.html) - [BitBucket](https://support.atlassian.com/bitbucket-cloud/docs/use-oauth-on-bitbucket-cloud/) - [Azure DevOps](https://learn.microsoft.com/en-us/azure/devops/integrate/get-started/authentication/oauth?view=azure-devops) +- [Azure DevOps (via Entra ID)](https://learn.microsoft.com/en-us/entra/architecture/auth-oauth2) Example callback URL: `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 CODER_EXTERNAL_AUTH_0_TOKEN_URL="https://app.vssps.visualstudio.com/oauth2/token" ``` +### Azure DevOps (via Entra ID) + +Azure DevOps (via Entra ID) requires the following environment variables: + +```env +CODER_EXTERNAL_AUTH_0_ID="primary-azure-devops" +CODER_EXTERNAL_AUTH_0_TYPE=azure-devops-entra +CODER_EXTERNAL_AUTH_0_CLIENT_ID=xxxxxx +CODER_EXTERNAL_AUTH_0_CLIENT_SECRET=xxxxxxx +CODER_EXTERNAL_AUTH_0_AUTH_URL="https://login.microsoftonline.com//oauth2/authorize" +``` + +> Note: Your app registration in Entra ID requires the `vso.code_write` scope + ### GitLab self-managed GitLab self-managed requires the following environment variables: diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 7b0f21a3fa..6ac25d9b7c 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -2003,6 +2003,7 @@ export const DisplayApps: DisplayApp[] = [ // From codersdk/externalauth.go export type EnhancedExternalAuthProvider = | "azure-devops" + | "azure-devops-entra" | "bitbucket-cloud" | "bitbucket-server" | "gitea" @@ -2012,6 +2013,7 @@ export type EnhancedExternalAuthProvider = | "slack"; export const EnhancedExternalAuthProviders: EnhancedExternalAuthProvider[] = [ "azure-devops", + "azure-devops-entra", "bitbucket-cloud", "bitbucket-server", "gitea",