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
+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{{