feat: add redirect URL override for external auth (#28082)

This commit is contained in:
Asher
2026-08-17 14:09:23 -08:00
committed by GitHub
parent 94f487b890
commit b5d18bb9c9
14 changed files with 110 additions and 39 deletions
+3
View File
@@ -974,6 +974,7 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd.
}
options.ExternalAuthConfigs, err = externalauth.ConvertConfig(
ctx,
logger,
oauthInstrument,
mergedExternalAuthProviders,
@@ -3123,6 +3124,8 @@ func parseExternalAuthProvidersFromEnv(prefix string, environ []string) ([]coder
provider.RevokeURL = v.Value
case "VALIDATE_URL":
provider.ValidateURL = v.Value
case "REDIRECT_URL":
provider.RedirectURL = v.Value
case "REGEX":
provider.Regex = v.Value
case "DEVICE_FLOW":
+4
View File
@@ -81,6 +81,7 @@ func TestReadExternalAuthProvidersFromEnv(t *testing.T) {
"CODER_EXTERNAL_AUTH_1_CLIENT_SECRET=hunter12",
"CODER_EXTERNAL_AUTH_1_TOKEN_URL=google.com",
"CODER_EXTERNAL_AUTH_1_VALIDATE_URL=bing.com",
"CODER_EXTERNAL_AUTH_1_REDIRECT_URL=coder.com",
"CODER_EXTERNAL_AUTH_1_REVOKE_URL=revoke.url",
"CODER_EXTERNAL_AUTH_1_SCOPES=repo:read repo:write",
"CODER_EXTERNAL_AUTH_1_NO_REFRESH=true",
@@ -101,6 +102,7 @@ func TestReadExternalAuthProvidersFromEnv(t *testing.T) {
assert.Equal(t, "hunter12", providers[1].ClientSecret)
assert.Equal(t, "google.com", providers[1].TokenURL)
assert.Equal(t, "bing.com", providers[1].ValidateURL)
assert.Equal(t, "coder.com", providers[1].RedirectURL)
assert.Equal(t, "revoke.url", providers[1].RevokeURL)
assert.Equal(t, []string{"repo:read", "repo:write"}, providers[1].Scopes)
assert.Equal(t, true, providers[1].NoRefresh)
@@ -193,6 +195,7 @@ func TestReadGitAuthProvidersFromEnv(t *testing.T) {
"CODER_GITAUTH_1_CLIENT_SECRET=hunter12",
"CODER_GITAUTH_1_TOKEN_URL=google.com",
"CODER_GITAUTH_1_VALIDATE_URL=bing.com",
"CODER_GITAUTH_1_REDIRECT_URL=coder.com",
"CODER_GITAUTH_1_SCOPES=repo:read repo:write",
"CODER_GITAUTH_1_NO_REFRESH=true",
})
@@ -209,6 +212,7 @@ func TestReadGitAuthProvidersFromEnv(t *testing.T) {
assert.Equal(t, "hunter12", providers[1].ClientSecret)
assert.Equal(t, "google.com", providers[1].TokenURL)
assert.Equal(t, "bing.com", providers[1].ValidateURL)
assert.Equal(t, "coder.com", providers[1].RedirectURL)
assert.Equal(t, []string{"repo:read", "repo:write"}, providers[1].Scopes)
assert.Equal(t, true, providers[1].NoRefresh)
})
+5 -1
View File
@@ -20739,6 +20739,10 @@ const docTemplate = `{
"no_refresh": {
"type": "boolean"
},
"redirect_url": {
"description": "RedirectURL is optional, defaulting to 'ACCESS_URL'. Only useful in niche\nsituations where the OAuth callback domain is different from the ACCESS_URL\ndomain. The path component is ignored.",
"type": "string"
},
"regex": {
"description": "Regex allows API requesters to match an auth config by\na string (e.g. coder.com) instead of by it's type.\n\nGit clone makes use of this by parsing the URL from:\n'Username for \"https://github.com\":'\nAnd sending it to the Coder server to match against the Regex.",
"type": "string"
@@ -22434,7 +22438,7 @@ const docTemplate = `{
}
},
"redirect_url": {
"description": "RedirectURL is optional, defaulting to 'ACCESS_URL'. Only useful in niche\nsituations where the OIDC callback domain is different from the ACCESS_URL\ndomain.",
"description": "RedirectURL is optional, defaulting to 'ACCESS_URL'. Only useful in niche\nsituations where the OIDC callback domain is different from the ACCESS_URL\ndomain. The path component is ignored.",
"allOf": [
{
"$ref": "#/definitions/serpent.URL"
+5 -1
View File
@@ -18861,6 +18861,10 @@
"no_refresh": {
"type": "boolean"
},
"redirect_url": {
"description": "RedirectURL is optional, defaulting to 'ACCESS_URL'. Only useful in niche\nsituations where the OAuth callback domain is different from the ACCESS_URL\ndomain. The path component is ignored.",
"type": "string"
},
"regex": {
"description": "Regex allows API requesters to match an auth config by\na string (e.g. coder.com) instead of by it's type.\n\nGit clone makes use of this by parsing the URL from:\n'Username for \"https://github.com\":'\nAnd sending it to the Coder server to match against the Regex.",
"type": "string"
@@ -20488,7 +20492,7 @@
}
},
"redirect_url": {
"description": "RedirectURL is optional, defaulting to 'ACCESS_URL'. Only useful in niche\nsituations where the OIDC callback domain is different from the ACCESS_URL\ndomain.",
"description": "RedirectURL is optional, defaulting to 'ACCESS_URL'. Only useful in niche\nsituations where the OIDC callback domain is different from the ACCESS_URL\ndomain. The path component is ignored.",
"allOf": [
{
"$ref": "#/definitions/serpent.URL"
+18 -4
View File
@@ -912,7 +912,7 @@ func (c *DeviceAuth) formatDeviceCodeURL() (string, error) {
// ConvertConfig converts the SDK configuration entry format
// to the parsed and ready-to-consume in coderd provider type.
func ConvertConfig(logger slog.Logger, instrument *promoauth.Factory, entries []codersdk.ExternalAuthConfig, accessURL *url.URL) ([]*Config, error) {
func ConvertConfig(ctx context.Context, logger slog.Logger, instrument *promoauth.Factory, entries []codersdk.ExternalAuthConfig, accessURL *url.URL) ([]*Config, error) {
ids := map[string]struct{}{}
configs := []*Config{}
for _, entry := range entries {
@@ -921,6 +921,8 @@ func ConvertConfig(logger slog.Logger, instrument *promoauth.Factory, entries []
// apply their client secret and ID, and have the UI appear nicely.
applyDefaultsToConfig(&entry)
logger := logger.Named("externalauth").With(slog.F("provider_id", entry.ID), slog.F("provider_type", entry.Type))
valid := codersdk.NameValid(entry.ID)
if valid != nil {
return nil, xerrors.Errorf("external auth provider %q doesn't have a valid id: %w", entry.ID, valid)
@@ -938,9 +940,18 @@ func ConvertConfig(logger slog.Logger, instrument *promoauth.Factory, entries []
}
ids[entry.ID] = struct{}{}
authRedirect, err := accessURL.Parse(fmt.Sprintf("/external-auth/%s/callback", entry.ID))
baseRedirectURL := accessURL
if entry.RedirectURL != "" {
var err error
baseRedirectURL, err = url.Parse(entry.RedirectURL)
if err != nil {
return nil, xerrors.Errorf("parse redirect url override for external auth provider %q: %w", entry.ID, err)
}
logger.Warn(ctx, "custom redirect URL used instead of 'access_url', ensure this matches the value configured in your provider")
}
authRedirect, err := baseRedirectURL.Parse(fmt.Sprintf("/external-auth/%s/callback", entry.ID))
if err != nil {
return nil, xerrors.Errorf("parse external auth callback url: %w", err)
return nil, xerrors.Errorf("parse callback url for external auth provider %q: %w", entry.ID, err)
}
var regex *regexp.Regexp
@@ -996,7 +1007,7 @@ func ConvertConfig(logger slog.Logger, instrument *promoauth.Factory, entries []
cfg := &Config{
InstrumentedOAuth2Config: instrumented,
Logger: logger.Named("externalauth").With(slog.F("provider_id", entry.ID), slog.F("provider_type", entry.Type)),
Logger: logger,
ID: entry.ID,
ClientID: entry.ClientID,
ClientSecret: entry.ClientSecret,
@@ -1092,6 +1103,9 @@ func copyDefaultSettings(config *codersdk.ExternalAuthConfig, defaults codersdk.
if config.ValidateURL == "" {
config.ValidateURL = defaults.ValidateURL
}
if config.RedirectURL == "" {
config.RedirectURL = defaults.RedirectURL
}
if config.RevokeURL == "" {
config.RevokeURL = defaults.RevokeURL
}
+11 -9
View File
@@ -1076,7 +1076,7 @@ func TestRefreshTokenWithScopes(t *testing.T) {
newConfig := func(t *testing.T, scopes []string) *externalauth.Config {
t.Helper()
instrument := promoauth.NewFactory(prometheus.NewRegistry())
configs, err := externalauth.ConvertConfig(testutil.Logger(t), instrument, []codersdk.ExternalAuthConfig{{
configs, err := externalauth.ConvertConfig(context.Background(), testutil.Logger(t), instrument, []codersdk.ExternalAuthConfig{{
ID: "test",
Type: codersdk.EnhancedExternalAuthProviderAzureDevopsEntra.String(),
ClientID: "id",
@@ -1203,7 +1203,7 @@ func TestValidateToken(t *testing.T) {
logs := &bytes.Buffer{}
logger := slog.Make(slogjson.Sink(logs)).Leveled(slog.LevelDebug)
// ConvertConfig wires the named logger as production does.
configs, err := externalauth.ConvertConfig(logger, f, []codersdk.ExternalAuthConfig{{
configs, err := externalauth.ConvertConfig(context.Background(), logger, f, []codersdk.ExternalAuthConfig{{
ID: providerName,
Type: codersdk.EnhancedExternalAuthProviderGitHub.String(),
ClientID: "id",
@@ -1608,7 +1608,7 @@ func TestExchangeWithClientSecret(t *testing.T) {
instrument := promoauth.NewFactory(prometheus.NewRegistry())
// This ensures a provider that requires the custom
// client secret exchange works.
configs, err := externalauth.ConvertConfig(testutil.Logger(t), instrument, []codersdk.ExternalAuthConfig{{
configs, err := externalauth.ConvertConfig(context.Background(), testutil.Logger(t), instrument, []codersdk.ExternalAuthConfig{{
// JFrog just happens to require this custom type.
Type: codersdk.EnhancedExternalAuthProviderJFrog.String(),
@@ -1740,7 +1740,7 @@ func TestConvertYAML(t *testing.T) {
}} {
t.Run(tc.Name, func(t *testing.T) {
t.Parallel()
output, err := externalauth.ConvertConfig(testutil.Logger(t), instrument, tc.Input, &url.URL{})
output, err := externalauth.ConvertConfig(context.Background(), testutil.Logger(t), instrument, tc.Input, &url.URL{})
if tc.Error != "" {
require.Error(t, err)
require.Contains(t, err.Error(), tc.Error)
@@ -1752,21 +1752,22 @@ func TestConvertYAML(t *testing.T) {
t.Run("CustomScopesAndEndpoint", func(t *testing.T) {
t.Parallel()
config, err := externalauth.ConvertConfig(testutil.Logger(t), instrument, []codersdk.ExternalAuthConfig{{
config, err := externalauth.ConvertConfig(context.Background(), testutil.Logger(t), instrument, []codersdk.ExternalAuthConfig{{
Type: string(codersdk.EnhancedExternalAuthProviderGitLab),
ClientID: "id",
ClientSecret: "secret",
AuthURL: "https://auth.com",
TokenURL: "https://token.com",
RedirectURL: "https://redirect.com",
Scopes: []string{"read"},
}}, &url.URL{})
}}, &url.URL{Scheme: "https", Host: "default.com"})
require.NoError(t, err)
require.Equal(t, "https://auth.com?client_id=id&redirect_uri=%2Fexternal-auth%2Fgitlab%2Fcallback&response_type=code&scope=read", config[0].AuthCodeURL(""))
require.Equal(t, "https://auth.com?client_id=id&redirect_uri=https%3A%2F%2Fredirect.com%2Fexternal-auth%2Fgitlab%2Fcallback&response_type=code&scope=read", config[0].AuthCodeURL(""))
})
t.Run("RevokeTimeoutSet", func(t *testing.T) {
t.Parallel()
configs, err := externalauth.ConvertConfig(testutil.Logger(t), instrument, []codersdk.ExternalAuthConfig{{
configs, err := externalauth.ConvertConfig(context.Background(), testutil.Logger(t), instrument, []codersdk.ExternalAuthConfig{{
Type: string(codersdk.EnhancedExternalAuthProviderGitLab),
ClientID: "id",
ClientSecret: "secret",
@@ -1777,7 +1778,7 @@ func TestConvertYAML(t *testing.T) {
t.Run("SelfHostedGitLabAPIBaseURL", func(t *testing.T) {
t.Parallel()
configs, err := externalauth.ConvertConfig(testutil.Logger(t), instrument, []codersdk.ExternalAuthConfig{{
configs, err := externalauth.ConvertConfig(context.Background(), testutil.Logger(t), instrument, []codersdk.ExternalAuthConfig{{
Type: string(codersdk.EnhancedExternalAuthProviderGitLab),
ClientID: "id",
ClientSecret: "secret",
@@ -1956,6 +1957,7 @@ func TestApplyDefaultsToConfig_CaseInsensitive(t *testing.T) {
t.Run(tc.Name, func(t *testing.T) {
t.Parallel()
configs, err := externalauth.ConvertConfig(
context.Background(),
testutil.Logger(t),
instrument,
[]codersdk.ExternalAuthConfig{{
+9 -5
View File
@@ -1016,7 +1016,7 @@ type OIDCConfig struct {
// RedirectURL is optional, defaulting to 'ACCESS_URL'. Only useful in niche
// situations where the OIDC callback domain is different from the ACCESS_URL
// domain.
// domain. The path component is ignored.
RedirectURL serpent.URL `json:"redirect_url" typescript:",notnull"`
AutoRepairLinks serpent.Bool `json:"auto_repair_links" typescript:",notnull"`
@@ -1170,10 +1170,14 @@ type ExternalAuthConfig struct {
ClientSecret string `json:"-" yaml:"client_secret"`
// ID is a unique identifier for the auth config.
// It defaults to `type` when not provided.
ID string `json:"id" yaml:"id"`
AuthURL string `json:"auth_url" yaml:"auth_url"`
TokenURL string `json:"token_url" yaml:"token_url"`
ValidateURL string `json:"validate_url" yaml:"validate_url"`
ID string `json:"id" yaml:"id"`
AuthURL string `json:"auth_url" yaml:"auth_url"`
TokenURL string `json:"token_url" yaml:"token_url"`
ValidateURL string `json:"validate_url" yaml:"validate_url"`
// RedirectURL is optional, defaulting to 'ACCESS_URL'. Only useful in niche
// situations where the OAuth callback domain is different from the ACCESS_URL
// domain. The path component is ignored.
RedirectURL string `json:"redirect_url" yaml:"redirect_url"`
RevokeURL string `json:"revoke_url" yaml:"revoke_url"`
AppInstallURL string `json:"app_install_url" yaml:"app_install_url"`
AppInstallationsURL string `json:"app_installations_url" yaml:"app_installations_url"`
+1
View File
@@ -1006,6 +1006,7 @@ func TestExternalAuthYAMLConfig(t *testing.T) {
ID: "id",
AuthURL: "https://example.com/auth",
TokenURL: "https://example.com/token",
RedirectURL: "https://example.com/redirect",
ValidateURL: "https://example.com/validate",
RevokeURL: "https://example.com/revoke",
AppInstallURL: "https://example.com/install",
+1
View File
@@ -6,6 +6,7 @@ externalAuthProviders:
auth_url: https://example.com/auth
token_url: https://example.com/token
validate_url: https://example.com/validate
redirect_url: https://example.com/redirect
revoke_url: https://example.com/revoke
app_install_url: https://example.com/install
app_installations_url: https://example.com/installations
+21
View File
@@ -43,6 +43,27 @@ Set it with a value that helps you identify the provider.
For example, if you use `CODER_EXTERNAL_AUTH_0_ID="primary-github"` for your GitHub provider,
configure your callback URL as `https://example.com/external-auth/primary-github/callback`.
By default, the redirect URL is built from the access URL Coder is configured
with. You can override the base URL with:
```dotenv
CODER_EXTERNAL_AUTH_0_REDIRECT_URL=https://my.tld
```
This would change the callback in the above example to
`https://my.tld/external-auth/primary-github/callback` (any path component on
the redirect URL is ignored).
Using this setting can break OAuth, so use with caution. The override is
intended to be used when the access URL is internal and either:
- Users access Coder via some other URL that proxies to the internal one.
- The redirect URL redirects to the internal access URL (this can be used to
work around providers that require public domains for the callback).
Ultimately, the user must end up on the same domain they were on when the
authentication flow was initiated.
### Add an authentication button to the workspace template
Add the following code to any template to add a button to the workspace setup page which will allow you to authenticate with your provider:
+1
View File
@@ -351,6 +351,7 @@ curl -X GET http://coder-server:8080/api/v2/deployment/config \
"mcp_tool_deny_regex": "string",
"mcp_url": "string",
"no_refresh": true,
"redirect_url": "string",
"regex": "string",
"revoke_url": "string",
"scopes": [
+23 -18
View File
@@ -6096,6 +6096,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o
"mcp_tool_deny_regex": "string",
"mcp_url": "string",
"no_refresh": true,
"redirect_url": "string",
"regex": "string",
"revoke_url": "string",
"scopes": [
@@ -6723,6 +6724,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o
"mcp_tool_deny_regex": "string",
"mcp_url": "string",
"no_refresh": true,
"redirect_url": "string",
"regex": "string",
"revoke_url": "string",
"scopes": [
@@ -7724,6 +7726,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o
"mcp_tool_deny_regex": "string",
"mcp_url": "string",
"no_refresh": true,
"redirect_url": "string",
"regex": "string",
"revoke_url": "string",
"scopes": [
@@ -7737,23 +7740,24 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o
### Properties
| Name | Type | Required | Restrictions | Description |
|------------------------------------|-----------------|----------|--------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `api_base_url` | string | false | | Api base URL is the base URL for provider REST API calls (e.g., "https://api.github.com" for GitHub). Derived from defaults when not explicitly configured. |
| `app_install_url` | string | false | | |
| `app_installations_url` | string | false | | |
| `auth_url` | string | false | | |
| `client_id` | string | false | | |
| `code_challenge_methods_supported` | array of string | false | | Code challenge methods supported lists the PKCE code challenge methods The only one supported by Coder is "S256". |
| `device_code_url` | string | false | | |
| `device_flow` | boolean | false | | |
| `display_icon` | string | false | | Display icon is a URL to an icon to display in the UI. |
| `display_name` | string | false | | Display name is shown in the UI to identify the auth config. |
| `id` | string | false | | ID is a unique identifier for the auth config. It defaults to `type` when not provided. |
| `mcp_tool_allow_regex` | string | false | | Deprecated: Injected MCP in AI Bridge is deprecated and will be removed in a future release. |
| `mcp_tool_deny_regex` | string | false | | Deprecated: Injected MCP in AI Bridge is deprecated and will be removed in a future release. |
| `mcp_url` | string | false | | Deprecated: Injected MCP in AI Bridge is deprecated and will be removed in a future release. |
| `no_refresh` | boolean | false | | |
| Name | Type | Required | Restrictions | Description |
|------------------------------------|-----------------|----------|--------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `api_base_url` | string | false | | Api base URL is the base URL for provider REST API calls (e.g., "https://api.github.com" for GitHub). Derived from defaults when not explicitly configured. |
| `app_install_url` | string | false | | |
| `app_installations_url` | string | false | | |
| `auth_url` | string | false | | |
| `client_id` | string | false | | |
| `code_challenge_methods_supported` | array of string | false | | Code challenge methods supported lists the PKCE code challenge methods The only one supported by Coder is "S256". |
| `device_code_url` | string | false | | |
| `device_flow` | boolean | false | | |
| `display_icon` | string | false | | Display icon is a URL to an icon to display in the UI. |
| `display_name` | string | false | | Display name is shown in the UI to identify the auth config. |
| `id` | string | false | | ID is a unique identifier for the auth config. It defaults to `type` when not provided. |
| `mcp_tool_allow_regex` | string | false | | Deprecated: Injected MCP in AI Bridge is deprecated and will be removed in a future release. |
| `mcp_tool_deny_regex` | string | false | | Deprecated: Injected MCP in AI Bridge is deprecated and will be removed in a future release. |
| `mcp_url` | string | false | | Deprecated: Injected MCP in AI Bridge is deprecated and will be removed in a future release. |
| `no_refresh` | boolean | false | | |
| `redirect_url` | string | false | | Redirect URL is optional, defaulting to 'ACCESS_URL'. Only useful in niche situations where the OAuth callback domain is different from the ACCESS_URL domain. The path component is ignored. |
|`regex`|string|false||Regex allows API requesters to match an auth config by a string (e.g. coder.com) instead of by it's type.
Git clone makes use of this by parsing the URL from: 'Username for "https://github.com":' And sending it to the Coder server to match against the Regex.|
|`revoke_url`|string|false|||
@@ -9615,7 +9619,7 @@ Only certain features set these fields: - FeatureManagedAgentLimit - FeatureAgen
| `organization_field` | string | false | | |
| `organization_mapping` | object | false | | |
| `redirect_allowed_hosts` | array of string | false | | Redirect allowed hosts is an allowlist of hostnames that may be used as the host of the OIDC redirect_uri. When non-empty, the redirect_uri is constructed from the incoming request's Host header (validated against this list) instead of from AccessURL. Every listed host must also be registered as a valid redirect URI in the OIDC provider. This setting is mutually exclusive with RedirectURL: if RedirectURL is set, this allowlist is ignored. |
| `redirect_url` | [serpent.URL](#serpenturl) | false | | Redirect URL is optional, defaulting to 'ACCESS_URL'. Only useful in niche situations where the OIDC callback domain is different from the ACCESS_URL domain. |
| `redirect_url` | [serpent.URL](#serpenturl) | false | | Redirect URL is optional, defaulting to 'ACCESS_URL'. Only useful in niche situations where the OIDC callback domain is different from the ACCESS_URL domain. The path component is ignored. |
| `scopes` | array of string | false | | |
| `sign_in_text` | string | false | | |
| `signups_disabled_text` | string | false | | |
@@ -19348,6 +19352,7 @@ None
"mcp_tool_deny_regex": "string",
"mcp_url": "string",
"no_refresh": true,
"redirect_url": "string",
"regex": "string",
"revoke_url": "string",
"scopes": [
+7 -1
View File
@@ -5069,6 +5069,12 @@ export interface ExternalAuthConfig {
readonly auth_url: string;
readonly token_url: string;
readonly validate_url: string;
/**
* RedirectURL is optional, defaulting to 'ACCESS_URL'. Only useful in niche
* situations where the OAuth callback domain is different from the ACCESS_URL
* domain. The path component is ignored.
*/
readonly redirect_url: string;
readonly revoke_url: string;
readonly app_install_url: string;
readonly app_installations_url: string;
@@ -6799,7 +6805,7 @@ export interface OIDCConfig {
/**
* RedirectURL is optional, defaulting to 'ACCESS_URL'. Only useful in niche
* situations where the OIDC callback domain is different from the ACCESS_URL
* domain.
* domain. The path component is ignored.
*/
readonly redirect_url: string;
readonly auto_repair_links: boolean;
@@ -17,6 +17,7 @@ const meta: Meta<typeof ExternalAuthSettingsPageView> = {
auth_url: "",
token_url: "",
validate_url: "",
redirect_url: "",
revoke_url: "",
app_install_url: "https://github.com/apps/coder/installations/new",
app_installations_url: "",