From 252d078d8e85a822d1615deb2544834b418ac4b0 Mon Sep 17 00:00:00 2001 From: af su Date: Sat, 28 Feb 2026 10:47:32 +0800 Subject: [PATCH] feat: support additional claims Signed-off-by: afsu Signed-off-by: af su --- docs/docs/configuration/alpha_config.md | 1 + pkg/apis/options/providers.go | 3 ++ pkg/apis/sessions/session_state.go | 29 ++++++++++- pkg/apis/sessions/session_state_test.go | 67 +++++++++++++++++++++++++ providers/provider_data.go | 17 +++++++ providers/provider_data_test.go | 23 +++++++++ providers/providers.go | 2 + 7 files changed, 141 insertions(+), 1 deletion(-) diff --git a/docs/docs/configuration/alpha_config.md b/docs/docs/configuration/alpha_config.md index b4a75582..9fecc41d 100644 --- a/docs/docs/configuration/alpha_config.md +++ b/docs/docs/configuration/alpha_config.md @@ -526,6 +526,7 @@ Provider holds all configuration for a single provider | `scope` | _string_ | Scope is the OAuth scope specification | | `allowedGroups` | _[]string_ | AllowedGroups is a list of restrict logins to members of this group | | `code_challenge_method` | _string_ | The code challenge method | +| `additionalClaims` | _[]string_ | Additional claims to be obtained from the `id_token`. | | `backendLogoutURL` | _string_ | URL to call to perform backend logout, `{id_token}` would be replaced by the actual `id_token` if available in the session | ### ProviderType diff --git a/pkg/apis/options/providers.go b/pkg/apis/options/providers.go index 94bdb592..cc6daa82 100644 --- a/pkg/apis/options/providers.go +++ b/pkg/apis/options/providers.go @@ -134,6 +134,9 @@ type Provider struct { // The code challenge method CodeChallengeMethod string `yaml:"code_challenge_method,omitempty"` + // Additional claims to be obtained from the `id_token`. + AdditionalClaims []string `json:"additionalClaims,omitempty"` + // URL to call to perform backend logout, `{id_token}` would be replaced by the actual `id_token` if available in the session BackendLogoutURL string `yaml:"backendLogoutURL"` } diff --git a/pkg/apis/sessions/session_state.go b/pkg/apis/sessions/session_state.go index a1f807ab..dbeb7edb 100644 --- a/pkg/apis/sessions/session_state.go +++ b/pkg/apis/sessions/session_state.go @@ -28,6 +28,9 @@ type SessionState struct { Groups []string `msgpack:"g,omitempty"` PreferredUsername string `msgpack:"pu,omitempty"` + // Additional claims + AdditionalClaims map[string]interface{} `msgpack:"ac,omitempty"` + // Internal helpers, not serialized Clock func() time.Time `msgpack:"-"` // override for time.Now, for testing Lock Lock `msgpack:"-"` @@ -156,10 +159,34 @@ func (s *SessionState) GetClaim(claim string) []string { case "preferred_username": return []string{s.PreferredUsername} default: - return []string{} + return s.getAdditionalClaim(claim) } } +func (s *SessionState) getAdditionalClaim(claim string) []string { + if value, ok := s.AdditionalClaims[claim]; ok { + switch v := value.(type) { + case string: + return []string{v} + case []string: + return v + case []interface{}: + result := make([]string, len(v)) + for i, item := range v { + if str, ok := item.(string); ok { + result[i] = str + } else { + result[i] = fmt.Sprintf("%v", item) + } + } + return result + default: + return []string{fmt.Sprintf("%v", value)} + } + } + return []string{} +} + // CheckNonce compares the Nonce against a potential hash of it func (s *SessionState) CheckNonce(hashed string) bool { return encryption.CheckNonce(s.Nonce, hashed) diff --git a/pkg/apis/sessions/session_state_test.go b/pkg/apis/sessions/session_state_test.go index 87b97614..3837a696 100644 --- a/pkg/apis/sessions/session_state_test.go +++ b/pkg/apis/sessions/session_state_test.go @@ -222,6 +222,24 @@ func TestEncodeAndDecodeSessionState(t *testing.T) { Nonce: []byte("abcdef1234567890abcdef1234567890"), Groups: []string{"group-a", "group-b"}, }, + "With additional claims": { + Email: "username@example.com", + User: "username", + PreferredUsername: "preferred.username", + AccessToken: "AccessToken.12349871293847fdsaihf9238h4f91h8fr.1349f831y98fd7", + IDToken: "IDToken.12349871293847fdsaihf9238h4f91h8fr.1349f831y98fd7", + CreatedAt: &created, + ExpiresOn: &expires, + RefreshToken: "RefreshToken.12349871293847fdsaihf9238h4f91h8fr.1349f831y98fd7", + Nonce: []byte("abcdef1234567890abcdef1234567890"), + Groups: []string{"group-a", "group-b"}, + AdditionalClaims: map[string]interface{}{ + "custom_claim_1": "value1", + "custom_claim_2": true, + "custom_claim_3": int8(1), + "custom_claim_4": []interface{}{"item1", "item2"}, + }, + }, } for _, secretSize := range []int{16, 24, 32} { @@ -289,3 +307,52 @@ func compareSessionStates(t *testing.T, expected *SessionState, actual *SessionS act.ExpiresOn = nil assert.Equal(t, exp, act) } + +func TestGetClaim(t *testing.T) { + createdAt := time.Now() + expiresOn := createdAt.Add(1 * time.Hour) + + ss := &SessionState{ + CreatedAt: &createdAt, + ExpiresOn: &expiresOn, + AccessToken: "AccessToken.12349871293847fdsaihf9238h4f91h8fr.1349f831y98fd7", + IDToken: "IDToken.12349871293847fdsaihf9238h4f91h8fr.1349f831y98fd7", + RefreshToken: "RefreshToken.12349871293847fdsaihf9238h4f91h8fr.1349f831y98fd7", + Email: "user@example.com", + User: "user123", + Groups: []string{"group1", "group2"}, + PreferredUsername: "preferred_user", + AdditionalClaims: map[string]interface{}{ + "custom_claim_1": "value1", + "custom_claim_2": true, + "custom_claim_3": 1, + "custom_claim_4": []string{"item1", "item2"}, + }, + } + + tests := []struct { + claim string + want []string + }{ + {"access_token", []string{"AccessToken.12349871293847fdsaihf9238h4f91h8fr.1349f831y98fd7"}}, + {"id_token", []string{"IDToken.12349871293847fdsaihf9238h4f91h8fr.1349f831y98fd7"}}, + {"refresh_token", []string{"RefreshToken.12349871293847fdsaihf9238h4f91h8fr.1349f831y98fd7"}}, + {"created_at", []string{createdAt.String()}}, + {"expires_on", []string{expiresOn.String()}}, + {"email", []string{"user@example.com"}}, + {"user", []string{"user123"}}, + {"groups", []string{"group1", "group2"}}, + {"preferred_username", []string{"preferred_user"}}, + {"custom_claim_1", []string{"value1"}}, + {"custom_claim_2", []string{"true"}}, + {"custom_claim_3", []string{"1"}}, + {"custom_claim_4", []string{"item1", "item2"}}, + } + + for _, tt := range tests { + t.Run(tt.claim, func(t *testing.T) { + gs := NewWithT(t) + gs.Expect(ss.GetClaim(tt.claim)).To(Equal(tt.want)) + }) + } +} diff --git a/providers/provider_data.go b/providers/provider_data.go index 95de5c50..35f95487 100644 --- a/providers/provider_data.go +++ b/providers/provider_data.go @@ -50,6 +50,7 @@ type ProviderData struct { EmailClaim string GroupsClaim string Verifier internaloidc.IDTokenVerifier + AdditionalClaims []string `json:"additionalClaims,omitempty"` SkipClaimsFromProfileURL bool // Universal Group authorization data structure @@ -268,6 +269,11 @@ func (p *ProviderData) buildSessionFromClaims(rawIDToken, accessToken string) (* } } + // Extract additional claims + if p.AdditionalClaims != nil { + p.extractAdditionalClaims(extractor, ss) + } + // `email_verified` must be present and explicitly set to `false` to be // considered unverified. verifyEmail := (p.EmailClaim == options.OIDCEmailClaim) && !p.AllowUnverifiedEmail @@ -301,6 +307,17 @@ func (p *ProviderData) getClaimExtractor(rawIDToken, accessToken string) (util.C return extractor, nil } +func (p *ProviderData) extractAdditionalClaims(extractor util.ClaimExtractor, ss *sessions.SessionState) { + if ss.AdditionalClaims == nil { + ss.AdditionalClaims = make(map[string]interface{}) + } + for _, claim := range p.AdditionalClaims { + if value, exists, err := extractor.GetClaim(claim); err == nil && exists { + ss.AdditionalClaims[claim] = value + } + } +} + // checkNonce compares the session's nonce with the IDToken's nonce claim func (p *ProviderData) checkNonce(s *sessions.SessionState) error { extractor, err := p.getClaimExtractor(s.IDToken, "") diff --git a/providers/provider_data_test.go b/providers/provider_data_test.go index 044a77b1..9801d20c 100644 --- a/providers/provider_data_test.go +++ b/providers/provider_data_test.go @@ -237,6 +237,7 @@ func TestProviderData_buildSessionFromClaims(t *testing.T) { ExpectedError error ExpectedSession *sessions.SessionState ExpectProfileURLCalled bool + AdditionalClaims []string }{ "Standard": { IDToken: defaultIDToken, @@ -417,6 +418,27 @@ func TestProviderData_buildSessionFromClaims(t *testing.T) { SkipClaimsFromProfileURL: true, ExpectedSession: &sessions.SessionState{}, }, + "Additional claims": { + IDToken: defaultIDToken, + AdditionalClaims: []string{"phone_number", "picture"}, + ExpectedSession: &sessions.SessionState{ + PreferredUsername: "Jane Dobbs", + AdditionalClaims: map[string]interface{}{ + "phone_number": "+4798765432", + "picture": "http://mugbook.com/janed/me.jpg", + }, + }, + }, + "Additional claims with missing claim": { + IDToken: defaultIDToken, + AdditionalClaims: []string{"phone_number", "picture1"}, + ExpectedSession: &sessions.SessionState{ + PreferredUsername: "Jane Dobbs", + AdditionalClaims: map[string]interface{}{ + "phone_number": "+4798765432", + }, + }, + }, } for testName, tc := range testCases { t.Run(testName, func(t *testing.T) { @@ -453,6 +475,7 @@ func TestProviderData_buildSessionFromClaims(t *testing.T) { provider.EmailClaim = tc.EmailClaim provider.GroupsClaim = tc.GroupsClaim provider.SkipClaimsFromProfileURL = tc.SkipClaimsFromProfileURL + provider.AdditionalClaims = tc.AdditionalClaims rawIDToken, err := newSignedTestIDToken(tc.IDToken) g.Expect(err).ToNot(HaveOccurred()) diff --git a/providers/providers.go b/providers/providers.go index 6af51ecf..ee925314 100644 --- a/providers/providers.go +++ b/providers/providers.go @@ -84,6 +84,8 @@ func newProviderDataFromConfig(providerConfig options.Provider) (*ProviderData, ClientSecret: providerConfig.ClientSecret, ClientSecretFile: providerConfig.ClientSecretFile, AuthRequestResponseMode: providerConfig.AuthRequestResponseMode, + // additional claims to be extracted from the ID Token + AdditionalClaims: providerConfig.AdditionalClaims, } needsVerifier, err := providerRequiresOIDCProviderVerifier(providerConfig.Type)