mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: add sourcing secondary claims from access_token (#16517)
Niche edge case, assumes access_token is jwt. Some `access_token`s are JWT's with potential useful claims. These claims would be nearly equivalent to `user_info` claims. This is not apart of the oauth spec, so this feature should not be loudly advertised. If using this feature, alternate solutions are preferred.
This commit is contained in:
+12
-1
@@ -172,6 +172,17 @@ func createOIDCConfig(ctx context.Context, logger slog.Logger, vals *codersdk.De
|
||||
groupAllowList[group] = true
|
||||
}
|
||||
|
||||
secondaryClaimsSrc := coderd.MergedClaimsSourceUserInfo
|
||||
if !vals.OIDC.IgnoreUserInfo && vals.OIDC.UserInfoFromAccessToken {
|
||||
return nil, xerrors.Errorf("to use 'oidc-access-token-claims', 'oidc-ignore-userinfo' must be set to 'false'")
|
||||
}
|
||||
if vals.OIDC.IgnoreUserInfo {
|
||||
secondaryClaimsSrc = coderd.MergedClaimsSourceNone
|
||||
}
|
||||
if vals.OIDC.UserInfoFromAccessToken {
|
||||
secondaryClaimsSrc = coderd.MergedClaimsSourceAccessToken
|
||||
}
|
||||
|
||||
return &coderd.OIDCConfig{
|
||||
OAuth2Config: useCfg,
|
||||
Provider: oidcProvider,
|
||||
@@ -187,7 +198,7 @@ func createOIDCConfig(ctx context.Context, logger slog.Logger, vals *codersdk.De
|
||||
NameField: vals.OIDC.NameField.String(),
|
||||
EmailField: vals.OIDC.EmailField.String(),
|
||||
AuthURLParams: vals.OIDC.AuthURLParams.Value,
|
||||
IgnoreUserInfo: vals.OIDC.IgnoreUserInfo.Value(),
|
||||
SecondaryClaims: secondaryClaimsSrc,
|
||||
SignInText: vals.OIDC.SignInText.String(),
|
||||
SignupsDisabledText: vals.OIDC.SignupsDisabledText.String(),
|
||||
IconURL: vals.OIDC.IconURL.String(),
|
||||
|
||||
+6
@@ -329,6 +329,12 @@ oidc:
|
||||
# Ignore the userinfo endpoint and only use the ID token for user information.
|
||||
# (default: false, type: bool)
|
||||
ignoreUserInfo: false
|
||||
# Source supplemental user claims from the 'access_token'. This assumes the token
|
||||
# is a jwt signed by the same issuer as the id_token. Using this requires setting
|
||||
# 'oidc-ignore-userinfo' to true. This setting is not compliant with the OIDC
|
||||
# specification and is not recommended. Use at your own risk.
|
||||
# (default: false, type: bool)
|
||||
accessTokenClaims: false
|
||||
# This field must be set if using the organization sync feature. Set to the claim
|
||||
# to be used for organizations.
|
||||
# (default: <unset>, type: string)
|
||||
|
||||
Generated
+5
@@ -12669,6 +12669,7 @@ const docTemplate = `{
|
||||
"type": "boolean"
|
||||
},
|
||||
"ignore_user_info": {
|
||||
"description": "IgnoreUserInfo \u0026 UserInfoFromAccessToken are mutually exclusive. Only 1\ncan be set to true. Ideally this would be an enum with 3 states, ['none',\n'userinfo', 'access_token']. However, for backward compatibility,\n` + "`" + `ignore_user_info` + "`" + ` must remain. And ` + "`" + `access_token` + "`" + ` is a niche, non-spec\ncompliant edge case. So it's use is rare, and should not be advised.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"issuer_url": {
|
||||
@@ -12701,6 +12702,10 @@ const docTemplate = `{
|
||||
"skip_issuer_checks": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"source_user_info_from_access_token": {
|
||||
"description": "UserInfoFromAccessToken as mentioned above is an edge case. This allows\nsourcing the user_info from the access token itself instead of a user_info\nendpoint. This assumes the access token is a valid JWT with a set of claims to\nbe merged with the id_token.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"user_role_field": {
|
||||
"type": "string"
|
||||
},
|
||||
|
||||
Generated
+5
@@ -11405,6 +11405,7 @@
|
||||
"type": "boolean"
|
||||
},
|
||||
"ignore_user_info": {
|
||||
"description": "IgnoreUserInfo \u0026 UserInfoFromAccessToken are mutually exclusive. Only 1\ncan be set to true. Ideally this would be an enum with 3 states, ['none',\n'userinfo', 'access_token']. However, for backward compatibility,\n`ignore_user_info` must remain. And `access_token` is a niche, non-spec\ncompliant edge case. So it's use is rare, and should not be advised.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"issuer_url": {
|
||||
@@ -11437,6 +11438,10 @@
|
||||
"skip_issuer_checks": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"source_user_info_from_access_token": {
|
||||
"description": "UserInfoFromAccessToken as mentioned above is an edge case. This allows\nsourcing the user_info from the access token itself instead of a user_info\nendpoint. This assumes the access token is a valid JWT with a set of claims to\nbe merged with the id_token.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"user_role_field": {
|
||||
"type": "string"
|
||||
},
|
||||
|
||||
@@ -105,6 +105,7 @@ type FakeIDP struct {
|
||||
// "Authorized Redirect URLs". This can be used to emulate that.
|
||||
hookValidRedirectURL func(redirectURL string) error
|
||||
hookUserInfo func(email string) (jwt.MapClaims, error)
|
||||
hookAccessTokenJWT func(email string, exp time.Time) jwt.MapClaims
|
||||
// defaultIDClaims is if a new client connects and we didn't preset
|
||||
// some claims.
|
||||
defaultIDClaims jwt.MapClaims
|
||||
@@ -154,6 +155,12 @@ func WithMiddlewares(mws ...func(http.Handler) http.Handler) func(*FakeIDP) {
|
||||
}
|
||||
}
|
||||
|
||||
func WithAccessTokenJWTHook(hook func(email string, exp time.Time) jwt.MapClaims) func(*FakeIDP) {
|
||||
return func(f *FakeIDP) {
|
||||
f.hookAccessTokenJWT = hook
|
||||
}
|
||||
}
|
||||
|
||||
func WithHookWellKnown(hook func(r *http.Request, j *ProviderJSON) error) func(*FakeIDP) {
|
||||
return func(f *FakeIDP) {
|
||||
f.hookWellKnown = hook
|
||||
@@ -316,8 +323,7 @@ const (
|
||||
func NewFakeIDP(t testing.TB, opts ...FakeIDPOpt) *FakeIDP {
|
||||
t.Helper()
|
||||
|
||||
block, _ := pem.Decode([]byte(testRSAPrivateKey))
|
||||
pkey, err := x509.ParsePKCS1PrivateKey(block.Bytes)
|
||||
pkey, err := FakeIDPKey()
|
||||
require.NoError(t, err)
|
||||
|
||||
idp := &FakeIDP{
|
||||
@@ -676,8 +682,13 @@ func (f *FakeIDP) newCode(state string) string {
|
||||
|
||||
// newToken enforces the access token exchanged is actually a valid access token
|
||||
// created by the IDP.
|
||||
func (f *FakeIDP) newToken(email string, expires time.Time) string {
|
||||
func (f *FakeIDP) newToken(t testing.TB, email string, expires time.Time) string {
|
||||
accessToken := uuid.NewString()
|
||||
if f.hookAccessTokenJWT != nil {
|
||||
claims := f.hookAccessTokenJWT(email, expires)
|
||||
accessToken = f.encodeClaims(t, claims)
|
||||
}
|
||||
|
||||
f.accessTokens.Store(accessToken, token{
|
||||
issued: time.Now(),
|
||||
email: email,
|
||||
@@ -963,7 +974,7 @@ func (f *FakeIDP) httpHandler(t testing.TB) http.Handler {
|
||||
email := getEmail(claims)
|
||||
refreshToken := f.newRefreshTokens(email)
|
||||
token := map[string]interface{}{
|
||||
"access_token": f.newToken(email, exp),
|
||||
"access_token": f.newToken(t, email, exp),
|
||||
"refresh_token": refreshToken,
|
||||
"token_type": "Bearer",
|
||||
"expires_in": int64((f.defaultExpire).Seconds()),
|
||||
@@ -1465,9 +1476,10 @@ func (f *FakeIDP) internalOIDCConfig(ctx context.Context, t testing.TB, scopes [
|
||||
Verifier: oidc.NewVerifier(f.provider.Issuer, &oidc.StaticKeySet{
|
||||
PublicKeys: []crypto.PublicKey{f.key.Public()},
|
||||
}, verifierConfig),
|
||||
UsernameField: "preferred_username",
|
||||
EmailField: "email",
|
||||
AuthURLParams: map[string]string{"access_type": "offline"},
|
||||
UsernameField: "preferred_username",
|
||||
EmailField: "email",
|
||||
AuthURLParams: map[string]string{"access_type": "offline"},
|
||||
SecondaryClaims: coderd.MergedClaimsSourceUserInfo,
|
||||
}
|
||||
|
||||
for _, opt := range opts {
|
||||
@@ -1552,3 +1564,8 @@ d8h4Ht09E+f3nhTEc87mODkl7WJZpHL6V2sORfeq/eIkds+H6CJ4hy5w/bSw8tjf
|
||||
sz9Di8sGIaUbLZI2rd0CQQCzlVwEtRtoNCyMJTTrkgUuNufLP19RZ5FpyXxBO5/u
|
||||
QastnN77KfUwdj3SJt44U/uh1jAIv4oSLBr8HYUkbnI8
|
||||
-----END RSA PRIVATE KEY-----`
|
||||
|
||||
func FakeIDPKey() (*rsa.PrivateKey, error) {
|
||||
block, _ := pem.Decode([]byte(testRSAPrivateKey))
|
||||
return x509.ParsePKCS1PrivateKey(block.Bytes)
|
||||
}
|
||||
|
||||
+107
-46
@@ -46,6 +46,14 @@ import (
|
||||
"github.com/coder/coder/v2/cryptorand"
|
||||
)
|
||||
|
||||
type MergedClaimsSource string
|
||||
|
||||
var (
|
||||
MergedClaimsSourceNone MergedClaimsSource = "none"
|
||||
MergedClaimsSourceUserInfo MergedClaimsSource = "user_info"
|
||||
MergedClaimsSourceAccessToken MergedClaimsSource = "access_token"
|
||||
)
|
||||
|
||||
const (
|
||||
userAuthLoggerName = "userauth"
|
||||
OAuthConvertCookieValue = "coder_oauth_convert_jwt"
|
||||
@@ -1116,11 +1124,13 @@ type OIDCConfig struct {
|
||||
// AuthURLParams are additional parameters to be passed to the OIDC provider
|
||||
// when requesting an access token.
|
||||
AuthURLParams map[string]string
|
||||
// IgnoreUserInfo causes Coder to only use claims from the ID token to
|
||||
// process OIDC logins. This is useful if the OIDC provider does not
|
||||
// support the userinfo endpoint, or if the userinfo endpoint causes
|
||||
// undesirable behavior.
|
||||
IgnoreUserInfo bool
|
||||
// SecondaryClaims indicates where to source additional claim information from.
|
||||
// The standard is either 'MergedClaimsSourceNone' or 'MergedClaimsSourceUserInfo'.
|
||||
//
|
||||
// The OIDC compliant way is to use the userinfo endpoint. This option
|
||||
// is useful when the userinfo endpoint does not exist or causes undesirable
|
||||
// behavior.
|
||||
SecondaryClaims MergedClaimsSource
|
||||
// SignInText is the text to display on the OIDC login button
|
||||
SignInText string
|
||||
// IconURL points to the URL of an icon to display on the OIDC login button
|
||||
@@ -1216,50 +1226,39 @@ func (api *API) userOIDC(rw http.ResponseWriter, r *http.Request) {
|
||||
// Some providers (e.g. ADFS) do not support custom OIDC claims in the
|
||||
// UserInfo endpoint, so we allow users to disable it and only rely on the
|
||||
// ID token.
|
||||
userInfoClaims := make(map[string]interface{})
|
||||
//
|
||||
// If user info is skipped, the idtokenClaims are the claims.
|
||||
mergedClaims := idtokenClaims
|
||||
if !api.OIDCConfig.IgnoreUserInfo {
|
||||
userInfo, err := api.OIDCConfig.Provider.UserInfo(ctx, oauth2.StaticTokenSource(state.Token))
|
||||
if err == nil {
|
||||
err = userInfo.Claims(&userInfoClaims)
|
||||
if err != nil {
|
||||
logger.Error(ctx, "oauth2: unable to unmarshal user info claims", slog.Error(err))
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Failed to unmarshal user info claims.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
logger.Debug(ctx, "got oidc claims",
|
||||
slog.F("source", "userinfo"),
|
||||
slog.F("claim_fields", claimFields(userInfoClaims)),
|
||||
slog.F("blank", blankFields(userInfoClaims)),
|
||||
)
|
||||
|
||||
// Merge the claims from the ID token and the UserInfo endpoint.
|
||||
// Information from UserInfo takes precedence.
|
||||
mergedClaims = mergeClaims(idtokenClaims, userInfoClaims)
|
||||
|
||||
// Log all of the field names after merging.
|
||||
logger.Debug(ctx, "got oidc claims",
|
||||
slog.F("source", "merged"),
|
||||
slog.F("claim_fields", claimFields(mergedClaims)),
|
||||
slog.F("blank", blankFields(mergedClaims)),
|
||||
)
|
||||
} else if !strings.Contains(err.Error(), "user info endpoint is not supported by this provider") {
|
||||
logger.Error(ctx, "oauth2: unable to obtain user information claims", slog.Error(err))
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Failed to obtain user information claims.",
|
||||
Detail: "The attempt to fetch claims via the UserInfo endpoint failed: " + err.Error(),
|
||||
})
|
||||
supplementaryClaims := make(map[string]interface{})
|
||||
switch api.OIDCConfig.SecondaryClaims {
|
||||
case MergedClaimsSourceUserInfo:
|
||||
supplementaryClaims, ok = api.userInfoClaims(ctx, rw, state, logger)
|
||||
if !ok {
|
||||
return
|
||||
} else {
|
||||
// The OIDC provider does not support the UserInfo endpoint.
|
||||
// This is not an error, but we should log it as it may mean
|
||||
// that some claims are missing.
|
||||
logger.Warn(ctx, "OIDC provider does not support the user info endpoint, ensure that all required claims are present in the id_token")
|
||||
}
|
||||
|
||||
// The precedence ordering is userInfoClaims > idTokenClaims.
|
||||
// Note: Unsure why exactly this is the case. idTokenClaims feels more
|
||||
// important?
|
||||
mergedClaims = mergeClaims(idtokenClaims, supplementaryClaims)
|
||||
case MergedClaimsSourceAccessToken:
|
||||
supplementaryClaims, ok = api.accessTokenClaims(ctx, rw, state, logger)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
// idTokenClaims take priority over accessTokenClaims. The order should
|
||||
// not matter. It is just safer to assume idTokenClaims is the truth,
|
||||
// and accessTokenClaims are supplemental.
|
||||
mergedClaims = mergeClaims(supplementaryClaims, idtokenClaims)
|
||||
case MergedClaimsSourceNone:
|
||||
// noop, keep the userInfoClaims empty
|
||||
default:
|
||||
// This should never happen and is a developer error
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Invalid source for secondary user claims.",
|
||||
Detail: fmt.Sprintf("invalid source: %q", api.OIDCConfig.SecondaryClaims),
|
||||
})
|
||||
return // Invalid MergedClaimsSource
|
||||
}
|
||||
|
||||
usernameRaw, ok := mergedClaims[api.OIDCConfig.UsernameField]
|
||||
@@ -1413,7 +1412,7 @@ func (api *API) userOIDC(rw http.ResponseWriter, r *http.Request) {
|
||||
RoleSync: roleSync,
|
||||
UserClaims: database.UserLinkClaims{
|
||||
IDTokenClaims: idtokenClaims,
|
||||
UserInfoClaims: userInfoClaims,
|
||||
UserInfoClaims: supplementaryClaims,
|
||||
MergedClaims: mergedClaims,
|
||||
},
|
||||
}).SetInitAuditRequest(func(params *audit.RequestParams) (*audit.Request[database.User], func()) {
|
||||
@@ -1447,6 +1446,68 @@ func (api *API) userOIDC(rw http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(rw, r, redirect, http.StatusTemporaryRedirect)
|
||||
}
|
||||
|
||||
func (api *API) accessTokenClaims(ctx context.Context, rw http.ResponseWriter, state httpmw.OAuth2State, logger slog.Logger) (accessTokenClaims map[string]interface{}, ok bool) {
|
||||
// Assume the access token is a jwt, and signed by the provider.
|
||||
accessToken, err := api.OIDCConfig.Verifier.Verify(ctx, state.Token.AccessToken)
|
||||
if err != nil {
|
||||
logger.Error(ctx, "oauth2: unable to verify access token as secondary claims source", slog.Error(err))
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "Failed to verify access token.",
|
||||
Detail: fmt.Sprintf("sourcing secondary claims from access token: %s", err.Error()),
|
||||
})
|
||||
return nil, false
|
||||
}
|
||||
|
||||
rawClaims := make(map[string]any)
|
||||
err = accessToken.Claims(&rawClaims)
|
||||
if err != nil {
|
||||
logger.Error(ctx, "oauth2: unable to unmarshal access token claims", slog.Error(err))
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Failed to unmarshal access token claims.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return nil, false
|
||||
}
|
||||
|
||||
return rawClaims, true
|
||||
}
|
||||
|
||||
func (api *API) userInfoClaims(ctx context.Context, rw http.ResponseWriter, state httpmw.OAuth2State, logger slog.Logger) (userInfoClaims map[string]interface{}, ok bool) {
|
||||
userInfoClaims = make(map[string]interface{})
|
||||
userInfo, err := api.OIDCConfig.Provider.UserInfo(ctx, oauth2.StaticTokenSource(state.Token))
|
||||
if err == nil {
|
||||
err = userInfo.Claims(&userInfoClaims)
|
||||
if err != nil {
|
||||
logger.Error(ctx, "oauth2: unable to unmarshal user info claims", slog.Error(err))
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Failed to unmarshal user info claims.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return nil, false
|
||||
}
|
||||
logger.Debug(ctx, "got oidc claims",
|
||||
slog.F("source", "userinfo"),
|
||||
slog.F("claim_fields", claimFields(userInfoClaims)),
|
||||
slog.F("blank", blankFields(userInfoClaims)),
|
||||
)
|
||||
} else if !strings.Contains(err.Error(), "user info endpoint is not supported by this provider") {
|
||||
logger.Error(ctx, "oauth2: unable to obtain user information claims", slog.Error(err))
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Failed to obtain user information claims.",
|
||||
Detail: "The attempt to fetch claims via the UserInfo endpoint failed: " + err.Error(),
|
||||
})
|
||||
return nil, false
|
||||
} else {
|
||||
// The OIDC provider does not support the UserInfo endpoint.
|
||||
// This is not an error, but we should log it as it may mean
|
||||
// that some claims are missing.
|
||||
logger.Warn(ctx, "OIDC provider does not support the user info endpoint, ensure that all required claims are present in the id_token",
|
||||
slog.Error(err),
|
||||
)
|
||||
}
|
||||
return userInfoClaims, true
|
||||
}
|
||||
|
||||
// claimFields returns the sorted list of fields in the claims map.
|
||||
func claimFields(claims map[string]interface{}) []string {
|
||||
fields := []string{}
|
||||
|
||||
+46
-4
@@ -61,7 +61,7 @@ func TestOIDCOauthLoginWithExisting(t *testing.T) {
|
||||
|
||||
cfg := fake.OIDCConfig(t, nil, func(cfg *coderd.OIDCConfig) {
|
||||
cfg.AllowSignups = true
|
||||
cfg.IgnoreUserInfo = true
|
||||
cfg.SecondaryClaims = coderd.MergedClaimsSourceNone
|
||||
})
|
||||
|
||||
client, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{
|
||||
@@ -979,6 +979,7 @@ func TestUserOIDC(t *testing.T) {
|
||||
Name string
|
||||
IDTokenClaims jwt.MapClaims
|
||||
UserInfoClaims jwt.MapClaims
|
||||
AccessTokenClaims jwt.MapClaims
|
||||
AllowSignups bool
|
||||
EmailDomain []string
|
||||
AssertUser func(t testing.TB, u codersdk.User)
|
||||
@@ -986,6 +987,7 @@ func TestUserOIDC(t *testing.T) {
|
||||
AssertResponse func(t testing.TB, resp *http.Response)
|
||||
IgnoreEmailVerified bool
|
||||
IgnoreUserInfo bool
|
||||
UseAccessToken bool
|
||||
}{
|
||||
{
|
||||
Name: "NoSub",
|
||||
@@ -995,6 +997,32 @@ func TestUserOIDC(t *testing.T) {
|
||||
AllowSignups: true,
|
||||
StatusCode: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
Name: "AccessTokenMerge",
|
||||
IDTokenClaims: jwt.MapClaims{
|
||||
"sub": uuid.NewString(),
|
||||
},
|
||||
AccessTokenClaims: jwt.MapClaims{
|
||||
"email": "kyle@kwc.io",
|
||||
},
|
||||
IgnoreUserInfo: true,
|
||||
AllowSignups: true,
|
||||
UseAccessToken: true,
|
||||
StatusCode: http.StatusOK,
|
||||
AssertUser: func(t testing.TB, u codersdk.User) {
|
||||
assert.Equal(t, "kyle@kwc.io", u.Email)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "AccessTokenMergeNotJWT",
|
||||
IDTokenClaims: jwt.MapClaims{
|
||||
"sub": uuid.NewString(),
|
||||
},
|
||||
IgnoreUserInfo: true,
|
||||
AllowSignups: true,
|
||||
UseAccessToken: true,
|
||||
StatusCode: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
Name: "EmailOnly",
|
||||
IDTokenClaims: jwt.MapClaims{
|
||||
@@ -1377,18 +1405,32 @@ func TestUserOIDC(t *testing.T) {
|
||||
tc := tc
|
||||
t.Run(tc.Name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
fake := oidctest.NewFakeIDP(t,
|
||||
opts := []oidctest.FakeIDPOpt{
|
||||
oidctest.WithRefresh(func(_ string) error {
|
||||
return xerrors.New("refreshing token should never occur")
|
||||
}),
|
||||
oidctest.WithServing(),
|
||||
oidctest.WithStaticUserInfo(tc.UserInfoClaims),
|
||||
)
|
||||
}
|
||||
|
||||
if tc.AccessTokenClaims != nil && len(tc.AccessTokenClaims) > 0 {
|
||||
opts = append(opts, oidctest.WithAccessTokenJWTHook(func(email string, exp time.Time) jwt.MapClaims {
|
||||
return tc.AccessTokenClaims
|
||||
}))
|
||||
}
|
||||
|
||||
fake := oidctest.NewFakeIDP(t, opts...)
|
||||
cfg := fake.OIDCConfig(t, nil, func(cfg *coderd.OIDCConfig) {
|
||||
cfg.AllowSignups = tc.AllowSignups
|
||||
cfg.EmailDomain = tc.EmailDomain
|
||||
cfg.IgnoreEmailVerified = tc.IgnoreEmailVerified
|
||||
cfg.IgnoreUserInfo = tc.IgnoreUserInfo
|
||||
cfg.SecondaryClaims = coderd.MergedClaimsSourceUserInfo
|
||||
if tc.IgnoreUserInfo {
|
||||
cfg.SecondaryClaims = coderd.MergedClaimsSourceNone
|
||||
}
|
||||
if tc.UseAccessToken {
|
||||
cfg.SecondaryClaims = coderd.MergedClaimsSourceAccessToken
|
||||
}
|
||||
cfg.NameField = "name"
|
||||
})
|
||||
|
||||
|
||||
+38
-11
@@ -518,17 +518,27 @@ type OIDCConfig struct {
|
||||
ClientID serpent.String `json:"client_id" typescript:",notnull"`
|
||||
ClientSecret serpent.String `json:"client_secret" typescript:",notnull"`
|
||||
// ClientKeyFile & ClientCertFile are used in place of ClientSecret for PKI auth.
|
||||
ClientKeyFile serpent.String `json:"client_key_file" typescript:",notnull"`
|
||||
ClientCertFile serpent.String `json:"client_cert_file" typescript:",notnull"`
|
||||
EmailDomain serpent.StringArray `json:"email_domain" typescript:",notnull"`
|
||||
IssuerURL serpent.String `json:"issuer_url" typescript:",notnull"`
|
||||
Scopes serpent.StringArray `json:"scopes" typescript:",notnull"`
|
||||
IgnoreEmailVerified serpent.Bool `json:"ignore_email_verified" typescript:",notnull"`
|
||||
UsernameField serpent.String `json:"username_field" typescript:",notnull"`
|
||||
NameField serpent.String `json:"name_field" typescript:",notnull"`
|
||||
EmailField serpent.String `json:"email_field" typescript:",notnull"`
|
||||
AuthURLParams serpent.Struct[map[string]string] `json:"auth_url_params" typescript:",notnull"`
|
||||
IgnoreUserInfo serpent.Bool `json:"ignore_user_info" typescript:",notnull"`
|
||||
ClientKeyFile serpent.String `json:"client_key_file" typescript:",notnull"`
|
||||
ClientCertFile serpent.String `json:"client_cert_file" typescript:",notnull"`
|
||||
EmailDomain serpent.StringArray `json:"email_domain" typescript:",notnull"`
|
||||
IssuerURL serpent.String `json:"issuer_url" typescript:",notnull"`
|
||||
Scopes serpent.StringArray `json:"scopes" typescript:",notnull"`
|
||||
IgnoreEmailVerified serpent.Bool `json:"ignore_email_verified" typescript:",notnull"`
|
||||
UsernameField serpent.String `json:"username_field" typescript:",notnull"`
|
||||
NameField serpent.String `json:"name_field" typescript:",notnull"`
|
||||
EmailField serpent.String `json:"email_field" typescript:",notnull"`
|
||||
AuthURLParams serpent.Struct[map[string]string] `json:"auth_url_params" typescript:",notnull"`
|
||||
// IgnoreUserInfo & UserInfoFromAccessToken are mutually exclusive. Only 1
|
||||
// can be set to true. Ideally this would be an enum with 3 states, ['none',
|
||||
// 'userinfo', 'access_token']. However, for backward compatibility,
|
||||
// `ignore_user_info` must remain. And `access_token` is a niche, non-spec
|
||||
// compliant edge case. So it's use is rare, and should not be advised.
|
||||
IgnoreUserInfo serpent.Bool `json:"ignore_user_info" typescript:",notnull"`
|
||||
// UserInfoFromAccessToken as mentioned above is an edge case. This allows
|
||||
// sourcing the user_info from the access token itself instead of a user_info
|
||||
// endpoint. This assumes the access token is a valid JWT with a set of claims to
|
||||
// be merged with the id_token.
|
||||
UserInfoFromAccessToken serpent.Bool `json:"source_user_info_from_access_token" typescript:",notnull"`
|
||||
OrganizationField serpent.String `json:"organization_field" typescript:",notnull"`
|
||||
OrganizationMapping serpent.Struct[map[string][]uuid.UUID] `json:"organization_mapping" typescript:",notnull"`
|
||||
OrganizationAssignDefault serpent.Bool `json:"organization_assign_default" typescript:",notnull"`
|
||||
@@ -1764,6 +1774,23 @@ func (c *DeploymentValues) Options() serpent.OptionSet {
|
||||
Group: &deploymentGroupOIDC,
|
||||
YAML: "ignoreUserInfo",
|
||||
},
|
||||
{
|
||||
Name: "OIDC Access Token Claims",
|
||||
// This is a niche edge case that should not be advertised. Alternatives should
|
||||
// be investigated before turning this on. A properly configured IdP should
|
||||
// always have a userinfo endpoint which is preferred.
|
||||
Hidden: true,
|
||||
Description: "Source supplemental user claims from the 'access_token'. This assumes the " +
|
||||
"token is a jwt signed by the same issuer as the id_token. Using this requires setting " +
|
||||
"'oidc-ignore-userinfo' to true. This setting is not compliant with the OIDC specification " +
|
||||
"and is not recommended. Use at your own risk.",
|
||||
Flag: "oidc-access-token-claims",
|
||||
Env: "CODER_OIDC_ACCESS_TOKEN_CLAIMS",
|
||||
Default: "false",
|
||||
Value: &c.OIDC.UserInfoFromAccessToken,
|
||||
Group: &deploymentGroupOIDC,
|
||||
YAML: "accessTokenClaims",
|
||||
},
|
||||
{
|
||||
Name: "OIDC Organization Field",
|
||||
Description: "This field must be set if using the organization sync feature." +
|
||||
|
||||
Generated
+1
@@ -376,6 +376,7 @@ curl -X GET http://coder-server:8080/api/v2/deployment/config \
|
||||
"sign_in_text": "string",
|
||||
"signups_disabled_text": "string",
|
||||
"skip_issuer_checks": true,
|
||||
"source_user_info_from_access_token": true,
|
||||
"user_role_field": "string",
|
||||
"user_role_mapping": {},
|
||||
"user_roles_default": [
|
||||
|
||||
Generated
+35
-31
@@ -2025,6 +2025,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o
|
||||
"sign_in_text": "string",
|
||||
"signups_disabled_text": "string",
|
||||
"skip_issuer_checks": true,
|
||||
"source_user_info_from_access_token": true,
|
||||
"user_role_field": "string",
|
||||
"user_role_mapping": {},
|
||||
"user_roles_default": [
|
||||
@@ -2496,6 +2497,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o
|
||||
"sign_in_text": "string",
|
||||
"signups_disabled_text": "string",
|
||||
"skip_issuer_checks": true,
|
||||
"source_user_info_from_access_token": true,
|
||||
"user_role_field": "string",
|
||||
"user_role_mapping": {},
|
||||
"user_roles_default": [
|
||||
@@ -3994,6 +3996,7 @@ Git clone makes use of this by parsing the URL from: 'Username for "https://gith
|
||||
"sign_in_text": "string",
|
||||
"signups_disabled_text": "string",
|
||||
"skip_issuer_checks": true,
|
||||
"source_user_info_from_access_token": true,
|
||||
"user_role_field": "string",
|
||||
"user_role_mapping": {},
|
||||
"user_roles_default": [
|
||||
@@ -4005,37 +4008,38 @@ Git clone makes use of this by parsing the URL from: 'Username for "https://gith
|
||||
|
||||
### Properties
|
||||
|
||||
| Name | Type | Required | Restrictions | Description |
|
||||
|-------------------------------|----------------------------------|----------|--------------|----------------------------------------------------------------------------------|
|
||||
| `allow_signups` | boolean | false | | |
|
||||
| `auth_url_params` | object | false | | |
|
||||
| `client_cert_file` | string | false | | |
|
||||
| `client_id` | string | false | | |
|
||||
| `client_key_file` | string | false | | Client key file & ClientCertFile are used in place of ClientSecret for PKI auth. |
|
||||
| `client_secret` | string | false | | |
|
||||
| `email_domain` | array of string | false | | |
|
||||
| `email_field` | string | false | | |
|
||||
| `group_allow_list` | array of string | false | | |
|
||||
| `group_auto_create` | boolean | false | | |
|
||||
| `group_mapping` | object | false | | |
|
||||
| `group_regex_filter` | [serpent.Regexp](#serpentregexp) | false | | |
|
||||
| `groups_field` | string | false | | |
|
||||
| `icon_url` | [serpent.URL](#serpenturl) | false | | |
|
||||
| `ignore_email_verified` | boolean | false | | |
|
||||
| `ignore_user_info` | boolean | false | | |
|
||||
| `issuer_url` | string | false | | |
|
||||
| `name_field` | string | false | | |
|
||||
| `organization_assign_default` | boolean | false | | |
|
||||
| `organization_field` | string | false | | |
|
||||
| `organization_mapping` | object | false | | |
|
||||
| `scopes` | array of string | false | | |
|
||||
| `sign_in_text` | string | false | | |
|
||||
| `signups_disabled_text` | string | false | | |
|
||||
| `skip_issuer_checks` | boolean | false | | |
|
||||
| `user_role_field` | string | false | | |
|
||||
| `user_role_mapping` | object | false | | |
|
||||
| `user_roles_default` | array of string | false | | |
|
||||
| `username_field` | string | false | | |
|
||||
| Name | Type | Required | Restrictions | Description |
|
||||
|--------------------------------------|----------------------------------|----------|--------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `allow_signups` | boolean | false | | |
|
||||
| `auth_url_params` | object | false | | |
|
||||
| `client_cert_file` | string | false | | |
|
||||
| `client_id` | string | false | | |
|
||||
| `client_key_file` | string | false | | Client key file & ClientCertFile are used in place of ClientSecret for PKI auth. |
|
||||
| `client_secret` | string | false | | |
|
||||
| `email_domain` | array of string | false | | |
|
||||
| `email_field` | string | false | | |
|
||||
| `group_allow_list` | array of string | false | | |
|
||||
| `group_auto_create` | boolean | false | | |
|
||||
| `group_mapping` | object | false | | |
|
||||
| `group_regex_filter` | [serpent.Regexp](#serpentregexp) | false | | |
|
||||
| `groups_field` | string | false | | |
|
||||
| `icon_url` | [serpent.URL](#serpenturl) | false | | |
|
||||
| `ignore_email_verified` | boolean | false | | |
|
||||
| `ignore_user_info` | boolean | false | | Ignore user info & UserInfoFromAccessToken are mutually exclusive. Only 1 can be set to true. Ideally this would be an enum with 3 states, ['none', 'userinfo', 'access_token']. However, for backward compatibility, `ignore_user_info` must remain. And `access_token` is a niche, non-spec compliant edge case. So it's use is rare, and should not be advised. |
|
||||
| `issuer_url` | string | false | | |
|
||||
| `name_field` | string | false | | |
|
||||
| `organization_assign_default` | boolean | false | | |
|
||||
| `organization_field` | string | false | | |
|
||||
| `organization_mapping` | object | false | | |
|
||||
| `scopes` | array of string | false | | |
|
||||
| `sign_in_text` | string | false | | |
|
||||
| `signups_disabled_text` | string | false | | |
|
||||
| `skip_issuer_checks` | boolean | false | | |
|
||||
| `source_user_info_from_access_token` | boolean | false | | Source user info from access token as mentioned above is an edge case. This allows sourcing the user_info from the access token itself instead of a user_info endpoint. This assumes the access token is a valid JWT with a set of claims to be merged with the id_token. |
|
||||
| `user_role_field` | string | false | | |
|
||||
| `user_role_mapping` | object | false | | |
|
||||
| `user_roles_default` | array of string | false | | |
|
||||
| `username_field` | string | false | | |
|
||||
|
||||
## codersdk.Organization
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v4"
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"cdr.dev/slog"
|
||||
@@ -88,6 +89,7 @@ func RunIDP() func(t *testing.T) {
|
||||
// This is a static set of auth fields. Might be beneficial to make flags
|
||||
// to allow different values here. This is only required for using the
|
||||
// testIDP as primary auth. External auth does not ever fetch these fields.
|
||||
"sub": uuid.MustParse("26c6a19c-b9b8-493b-a991-88a4c3310314"),
|
||||
"email": "oidc_member@coder.com",
|
||||
"preferred_username": "oidc_member",
|
||||
"email_verified": true,
|
||||
|
||||
Generated
+1
@@ -1411,6 +1411,7 @@ export interface OIDCConfig {
|
||||
readonly email_field: string;
|
||||
readonly auth_url_params: SerpentStruct<Record<string, string>>;
|
||||
readonly ignore_user_info: boolean;
|
||||
readonly source_user_info_from_access_token: boolean;
|
||||
readonly organization_field: string;
|
||||
readonly organization_mapping: SerpentStruct<Record<string, string[]>>;
|
||||
readonly organization_assign_default: boolean;
|
||||
|
||||
Reference in New Issue
Block a user