Skip to content

feat: add support for Entra ID auth when using Azure DevOps external auth #12201

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Mar 4, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions coderd/externalauth/externalauth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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}
}
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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 {
Expand Down
7 changes: 5 additions & 2 deletions codersdk/externalauth.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ func (e EnhancedExternalAuthProvider) Git() bool {
EnhancedExternalAuthProviderBitBucketCloud,
EnhancedExternalAuthProviderBitBucketServer,
EnhancedExternalAuthProviderAzureDevops,
EnhancedExternalAuthProviderAzureDevopsEntra,
EnhancedExternalAuthProviderGitea:
return true
default:
Expand All @@ -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"
Expand Down
15 changes: 15 additions & 0 deletions docs/admin/external-auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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/<TENANT ID>/oauth2/authorize"
```

> Note: Your app registration in Entra ID requires the `vso.code_write` scope
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we make this a default scope? I made it one in my commit.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure what you mean by this? Are you saying replace vso.code_write with a default scope?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just checked, I did not make it a default scope in my commit. Whoops!

If this scope should always be set, we can include it in the defaults.

	defaults := codersdk.ExternalAuthConfig{
		DisplayName: "Azure DevOps (Entra)",
		DisplayIcon: "/icon/azure-devops.svg",
		Regex:       `^(https?://)?dev\.azure\.com(/.*)?$`,
		Scopes:      []string{"vso.code_write"},
	}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah that's not what I mean by my comment in the docs, I mean when you create the App Registration you have to give it vso.code_write in the Entra ID portal
image

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh! Understood, so there are no default scope recommendations on the Coder end?

Copy link
Contributor Author

@alexwilcox9 alexwilcox9 Feb 29, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's right, as I understand it we just request the 'resource' and receive a token with all scopes that have been set in the app registration


### GitLab self-managed

GitLab self-managed requires the following environment variables:
Expand Down
2 changes: 2 additions & 0 deletions site/src/api/typesGenerated.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.