Skip to content

Commit 658825c

Browse files
authored
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.
1 parent e005e4e commit 658825c

File tree

12 files changed

+281
-99
lines changed

12 files changed

+281
-99
lines changed

cli/server.go

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,17 @@ func createOIDCConfig(ctx context.Context, logger slog.Logger, vals *codersdk.De
172172
groupAllowList[group] = true
173173
}
174174

175+
secondaryClaimsSrc := coderd.MergedClaimsSourceUserInfo
176+
if !vals.OIDC.IgnoreUserInfo && vals.OIDC.UserInfoFromAccessToken {
177+
return nil, xerrors.Errorf("to use 'oidc-access-token-claims', 'oidc-ignore-userinfo' must be set to 'false'")
178+
}
179+
if vals.OIDC.IgnoreUserInfo {
180+
secondaryClaimsSrc = coderd.MergedClaimsSourceNone
181+
}
182+
if vals.OIDC.UserInfoFromAccessToken {
183+
secondaryClaimsSrc = coderd.MergedClaimsSourceAccessToken
184+
}
185+
175186
return &coderd.OIDCConfig{
176187
OAuth2Config: useCfg,
177188
Provider: oidcProvider,
@@ -187,7 +198,7 @@ func createOIDCConfig(ctx context.Context, logger slog.Logger, vals *codersdk.De
187198
NameField: vals.OIDC.NameField.String(),
188199
EmailField: vals.OIDC.EmailField.String(),
189200
AuthURLParams: vals.OIDC.AuthURLParams.Value,
190-
IgnoreUserInfo: vals.OIDC.IgnoreUserInfo.Value(),
201+
SecondaryClaims: secondaryClaimsSrc,
191202
SignInText: vals.OIDC.SignInText.String(),
192203
SignupsDisabledText: vals.OIDC.SignupsDisabledText.String(),
193204
IconURL: vals.OIDC.IconURL.String(),

cli/testdata/server-config.yaml.golden

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -329,6 +329,12 @@ oidc:
329329
# Ignore the userinfo endpoint and only use the ID token for user information.
330330
# (default: false, type: bool)
331331
ignoreUserInfo: false
332+
# Source supplemental user claims from the 'access_token'. This assumes the token
333+
# is a jwt signed by the same issuer as the id_token. Using this requires setting
334+
# 'oidc-ignore-userinfo' to true. This setting is not compliant with the OIDC
335+
# specification and is not recommended. Use at your own risk.
336+
# (default: false, type: bool)
337+
accessTokenClaims: false
332338
# This field must be set if using the organization sync feature. Set to the claim
333339
# to be used for organizations.
334340
# (default: <unset>, type: string)

coderd/apidoc/docs.go

Lines changed: 5 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/apidoc/swagger.json

Lines changed: 5 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/coderdtest/oidctest/idp.go

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ type FakeIDP struct {
105105
// "Authorized Redirect URLs". This can be used to emulate that.
106106
hookValidRedirectURL func(redirectURL string) error
107107
hookUserInfo func(email string) (jwt.MapClaims, error)
108+
hookAccessTokenJWT func(email string, exp time.Time) jwt.MapClaims
108109
// defaultIDClaims is if a new client connects and we didn't preset
109110
// some claims.
110111
defaultIDClaims jwt.MapClaims
@@ -154,6 +155,12 @@ func WithMiddlewares(mws ...func(http.Handler) http.Handler) func(*FakeIDP) {
154155
}
155156
}
156157

158+
func WithAccessTokenJWTHook(hook func(email string, exp time.Time) jwt.MapClaims) func(*FakeIDP) {
159+
return func(f *FakeIDP) {
160+
f.hookAccessTokenJWT = hook
161+
}
162+
}
163+
157164
func WithHookWellKnown(hook func(r *http.Request, j *ProviderJSON) error) func(*FakeIDP) {
158165
return func(f *FakeIDP) {
159166
f.hookWellKnown = hook
@@ -316,8 +323,7 @@ const (
316323
func NewFakeIDP(t testing.TB, opts ...FakeIDPOpt) *FakeIDP {
317324
t.Helper()
318325

319-
block, _ := pem.Decode([]byte(testRSAPrivateKey))
320-
pkey, err := x509.ParsePKCS1PrivateKey(block.Bytes)
326+
pkey, err := FakeIDPKey()
321327
require.NoError(t, err)
322328

323329
idp := &FakeIDP{
@@ -676,8 +682,13 @@ func (f *FakeIDP) newCode(state string) string {
676682

677683
// newToken enforces the access token exchanged is actually a valid access token
678684
// created by the IDP.
679-
func (f *FakeIDP) newToken(email string, expires time.Time) string {
685+
func (f *FakeIDP) newToken(t testing.TB, email string, expires time.Time) string {
680686
accessToken := uuid.NewString()
687+
if f.hookAccessTokenJWT != nil {
688+
claims := f.hookAccessTokenJWT(email, expires)
689+
accessToken = f.encodeClaims(t, claims)
690+
}
691+
681692
f.accessTokens.Store(accessToken, token{
682693
issued: time.Now(),
683694
email: email,
@@ -963,7 +974,7 @@ func (f *FakeIDP) httpHandler(t testing.TB) http.Handler {
963974
email := getEmail(claims)
964975
refreshToken := f.newRefreshTokens(email)
965976
token := map[string]interface{}{
966-
"access_token": f.newToken(email, exp),
977+
"access_token": f.newToken(t, email, exp),
967978
"refresh_token": refreshToken,
968979
"token_type": "Bearer",
969980
"expires_in": int64((f.defaultExpire).Seconds()),
@@ -1465,9 +1476,10 @@ func (f *FakeIDP) internalOIDCConfig(ctx context.Context, t testing.TB, scopes [
14651476
Verifier: oidc.NewVerifier(f.provider.Issuer, &oidc.StaticKeySet{
14661477
PublicKeys: []crypto.PublicKey{f.key.Public()},
14671478
}, verifierConfig),
1468-
UsernameField: "preferred_username",
1469-
EmailField: "email",
1470-
AuthURLParams: map[string]string{"access_type": "offline"},
1479+
UsernameField: "preferred_username",
1480+
EmailField: "email",
1481+
AuthURLParams: map[string]string{"access_type": "offline"},
1482+
SecondaryClaims: coderd.MergedClaimsSourceUserInfo,
14711483
}
14721484

14731485
for _, opt := range opts {
@@ -1552,3 +1564,8 @@ d8h4Ht09E+f3nhTEc87mODkl7WJZpHL6V2sORfeq/eIkds+H6CJ4hy5w/bSw8tjf
15521564
sz9Di8sGIaUbLZI2rd0CQQCzlVwEtRtoNCyMJTTrkgUuNufLP19RZ5FpyXxBO5/u
15531565
QastnN77KfUwdj3SJt44U/uh1jAIv4oSLBr8HYUkbnI8
15541566
-----END RSA PRIVATE KEY-----`
1567+
1568+
func FakeIDPKey() (*rsa.PrivateKey, error) {
1569+
block, _ := pem.Decode([]byte(testRSAPrivateKey))
1570+
return x509.ParsePKCS1PrivateKey(block.Bytes)
1571+
}

coderd/userauth.go

Lines changed: 106 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,14 @@ import (
4646
"github.com/coder/coder/v2/cryptorand"
4747
)
4848

49+
type MergedClaimsSource string
50+
51+
var (
52+
MergedClaimsSourceNone MergedClaimsSource = "none"
53+
MergedClaimsSourceUserInfo MergedClaimsSource = "user_info"
54+
MergedClaimsSourceAccessToken MergedClaimsSource = "access_token"
55+
)
56+
4957
const (
5058
userAuthLoggerName = "userauth"
5159
OAuthConvertCookieValue = "coder_oauth_convert_jwt"
@@ -1116,11 +1124,13 @@ type OIDCConfig struct {
11161124
// AuthURLParams are additional parameters to be passed to the OIDC provider
11171125
// when requesting an access token.
11181126
AuthURLParams map[string]string
1119-
// IgnoreUserInfo causes Coder to only use claims from the ID token to
1120-
// process OIDC logins. This is useful if the OIDC provider does not
1121-
// support the userinfo endpoint, or if the userinfo endpoint causes
1122-
// undesirable behavior.
1123-
IgnoreUserInfo bool
1127+
// SecondaryClaims indicates where to source additional claim information from.
1128+
// The standard is either 'MergedClaimsSourceNone' or 'MergedClaimsSourceUserInfo'.
1129+
//
1130+
// The OIDC compliant way is to use the userinfo endpoint. This option
1131+
// is useful when the userinfo endpoint does not exist or causes undesirable
1132+
// behavior.
1133+
SecondaryClaims MergedClaimsSource
11241134
// SignInText is the text to display on the OIDC login button
11251135
SignInText string
11261136
// 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) {
12161226
// Some providers (e.g. ADFS) do not support custom OIDC claims in the
12171227
// UserInfo endpoint, so we allow users to disable it and only rely on the
12181228
// ID token.
1219-
userInfoClaims := make(map[string]interface{})
1229+
//
12201230
// If user info is skipped, the idtokenClaims are the claims.
12211231
mergedClaims := idtokenClaims
1222-
if !api.OIDCConfig.IgnoreUserInfo {
1223-
userInfo, err := api.OIDCConfig.Provider.UserInfo(ctx, oauth2.StaticTokenSource(state.Token))
1224-
if err == nil {
1225-
err = userInfo.Claims(&userInfoClaims)
1226-
if err != nil {
1227-
logger.Error(ctx, "oauth2: unable to unmarshal user info claims", slog.Error(err))
1228-
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
1229-
Message: "Failed to unmarshal user info claims.",
1230-
Detail: err.Error(),
1231-
})
1232-
return
1233-
}
1234-
logger.Debug(ctx, "got oidc claims",
1235-
slog.F("source", "userinfo"),
1236-
slog.F("claim_fields", claimFields(userInfoClaims)),
1237-
slog.F("blank", blankFields(userInfoClaims)),
1238-
)
1239-
1240-
// Merge the claims from the ID token and the UserInfo endpoint.
1241-
// Information from UserInfo takes precedence.
1242-
mergedClaims = mergeClaims(idtokenClaims, userInfoClaims)
1232+
supplementaryClaims := make(map[string]interface{})
1233+
switch api.OIDCConfig.SecondaryClaims {
1234+
case MergedClaimsSourceUserInfo:
1235+
supplementaryClaims, ok = api.userInfoClaims(ctx, rw, state, logger)
1236+
if !ok {
1237+
return
1238+
}
12431239

1244-
// Log all of the field names after merging.
1245-
logger.Debug(ctx, "got oidc claims",
1246-
slog.F("source", "merged"),
1247-
slog.F("claim_fields", claimFields(mergedClaims)),
1248-
slog.F("blank", blankFields(mergedClaims)),
1249-
)
1250-
} else if !strings.Contains(err.Error(), "user info endpoint is not supported by this provider") {
1251-
logger.Error(ctx, "oauth2: unable to obtain user information claims", slog.Error(err))
1252-
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
1253-
Message: "Failed to obtain user information claims.",
1254-
Detail: "The attempt to fetch claims via the UserInfo endpoint failed: " + err.Error(),
1255-
})
1240+
// The precedence ordering is userInfoClaims > idTokenClaims.
1241+
// Note: Unsure why exactly this is the case. idTokenClaims feels more
1242+
// important?
1243+
mergedClaims = mergeClaims(idtokenClaims, supplementaryClaims)
1244+
case MergedClaimsSourceAccessToken:
1245+
supplementaryClaims, ok = api.accessTokenClaims(ctx, rw, state, logger)
1246+
if !ok {
12561247
return
1257-
} else {
1258-
// The OIDC provider does not support the UserInfo endpoint.
1259-
// This is not an error, but we should log it as it may mean
1260-
// that some claims are missing.
1261-
logger.Warn(ctx, "OIDC provider does not support the user info endpoint, ensure that all required claims are present in the id_token")
12621248
}
1249+
// idTokenClaims take priority over accessTokenClaims. The order should
1250+
// not matter. It is just safer to assume idTokenClaims is the truth,
1251+
// and accessTokenClaims are supplemental.
1252+
mergedClaims = mergeClaims(supplementaryClaims, idtokenClaims)
1253+
case MergedClaimsSourceNone:
1254+
// noop, keep the userInfoClaims empty
1255+
default:
1256+
// This should never happen and is a developer error
1257+
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
1258+
Message: "Invalid source for secondary user claims.",
1259+
Detail: fmt.Sprintf("invalid source: %q", api.OIDCConfig.SecondaryClaims),
1260+
})
1261+
return // Invalid MergedClaimsSource
12631262
}
12641263

12651264
usernameRaw, ok := mergedClaims[api.OIDCConfig.UsernameField]
@@ -1413,7 +1412,7 @@ func (api *API) userOIDC(rw http.ResponseWriter, r *http.Request) {
14131412
RoleSync: roleSync,
14141413
UserClaims: database.UserLinkClaims{
14151414
IDTokenClaims: idtokenClaims,
1416-
UserInfoClaims: userInfoClaims,
1415+
UserInfoClaims: supplementaryClaims,
14171416
MergedClaims: mergedClaims,
14181417
},
14191418
}).SetInitAuditRequest(func(params *audit.RequestParams) (*audit.Request[database.User], func()) {
@@ -1447,6 +1446,68 @@ func (api *API) userOIDC(rw http.ResponseWriter, r *http.Request) {
14471446
http.Redirect(rw, r, redirect, http.StatusTemporaryRedirect)
14481447
}
14491448

1449+
func (api *API) accessTokenClaims(ctx context.Context, rw http.ResponseWriter, state httpmw.OAuth2State, logger slog.Logger) (accessTokenClaims map[string]interface{}, ok bool) {
1450+
// Assume the access token is a jwt, and signed by the provider.
1451+
accessToken, err := api.OIDCConfig.Verifier.Verify(ctx, state.Token.AccessToken)
1452+
if err != nil {
1453+
logger.Error(ctx, "oauth2: unable to verify access token as secondary claims source", slog.Error(err))
1454+
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
1455+
Message: "Failed to verify access token.",
1456+
Detail: fmt.Sprintf("sourcing secondary claims from access token: %s", err.Error()),
1457+
})
1458+
return nil, false
1459+
}
1460+
1461+
rawClaims := make(map[string]any)
1462+
err = accessToken.Claims(&rawClaims)
1463+
if err != nil {
1464+
logger.Error(ctx, "oauth2: unable to unmarshal access token claims", slog.Error(err))
1465+
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
1466+
Message: "Failed to unmarshal access token claims.",
1467+
Detail: err.Error(),
1468+
})
1469+
return nil, false
1470+
}
1471+
1472+
return rawClaims, true
1473+
}
1474+
1475+
func (api *API) userInfoClaims(ctx context.Context, rw http.ResponseWriter, state httpmw.OAuth2State, logger slog.Logger) (userInfoClaims map[string]interface{}, ok bool) {
1476+
userInfoClaims = make(map[string]interface{})
1477+
userInfo, err := api.OIDCConfig.Provider.UserInfo(ctx, oauth2.StaticTokenSource(state.Token))
1478+
if err == nil {
1479+
err = userInfo.Claims(&userInfoClaims)
1480+
if err != nil {
1481+
logger.Error(ctx, "oauth2: unable to unmarshal user info claims", slog.Error(err))
1482+
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
1483+
Message: "Failed to unmarshal user info claims.",
1484+
Detail: err.Error(),
1485+
})
1486+
return nil, false
1487+
}
1488+
logger.Debug(ctx, "got oidc claims",
1489+
slog.F("source", "userinfo"),
1490+
slog.F("claim_fields", claimFields(userInfoClaims)),
1491+
slog.F("blank", blankFields(userInfoClaims)),
1492+
)
1493+
} else if !strings.Contains(err.Error(), "user info endpoint is not supported by this provider") {
1494+
logger.Error(ctx, "oauth2: unable to obtain user information claims", slog.Error(err))
1495+
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
1496+
Message: "Failed to obtain user information claims.",
1497+
Detail: "The attempt to fetch claims via the UserInfo endpoint failed: " + err.Error(),
1498+
})
1499+
return nil, false
1500+
} else {
1501+
// The OIDC provider does not support the UserInfo endpoint.
1502+
// This is not an error, but we should log it as it may mean
1503+
// that some claims are missing.
1504+
logger.Warn(ctx, "OIDC provider does not support the user info endpoint, ensure that all required claims are present in the id_token",
1505+
slog.Error(err),
1506+
)
1507+
}
1508+
return userInfoClaims, true
1509+
}
1510+
14501511
// claimFields returns the sorted list of fields in the claims map.
14511512
func claimFields(claims map[string]interface{}) []string {
14521513
fields := []string{}

0 commit comments

Comments
 (0)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy