feat: allow arbitrary claims from the IDToken and IdentityProvider UserInfo endpoint to be added to the session state (#2685)

* feat: support additional claims

Signed-off-by: afsu <suaf2020@163.com>
Signed-off-by: af su <saf@zjuici.com>

* docs: clarify that AdditionalClaims may come from id_token or userinfo endpoint

Signed-off-by: afsu <suaf2020@163.com>
Signed-off-by: af su <saf@zjuici.com>

* feat: include AdditionalClaims in /oauth2/userinfo response (#834)

Signed-off-by: afsu <suaf2020@163.com>
Signed-off-by: af su <saf@zjuici.com>

* refactor: extract coerceClaim logic into util

Signed-off-by: afsu <suaf2020@163.com>
Signed-off-by: af su <saf@zjuici.com>

* doc: add changelog entry for #2685

Signed-off-by: Jan Larwig <jan@larwig.com>

* refactor: added more verbose comments to some struct fields and minor code cleanup

Signed-off-by: Jan Larwig <jan@larwig.com>

---------

Signed-off-by: afsu <suaf2020@163.com>
Signed-off-by: af su <saf@zjuici.com>
Signed-off-by: Jan Larwig <jan@larwig.com>
Co-authored-by: af su <saf@zjuici.com>
Co-authored-by: Jan Larwig <jan@larwig.com>
This commit is contained in:
af su
2026-03-14 12:04:33 +08:00
committed by GitHub
co-authored by af su Jan Larwig
parent c6355ee402
commit e59f7c1549
14 changed files with 314 additions and 137 deletions
+3
View File
@@ -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 upstream IDP, either from the id_token or from the userinfo endpoint if configured.
AdditionalClaims []string `yaml:"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"`
}
+15 -1
View File
@@ -8,6 +8,7 @@ import (
"time"
"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/encryption"
"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/util"
"github.com/pierrec/lz4/v4"
"github.com/vmihailenco/msgpack/v5"
)
@@ -28,6 +29,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 +160,20 @@ 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 {
var result []string
if err := util.CoerceClaim(value, &result); err == nil {
return result
}
}
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)
+64
View File
@@ -222,6 +222,23 @@ 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": []interface{}{"item1", "item2"},
},
},
}
for _, secretSize := range []int{16, 24, 32} {
@@ -289,3 +306,50 @@ 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": []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{"[\"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))
})
}
}
+10 -74
View File
@@ -3,7 +3,6 @@ package util
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"mime"
"net/http"
@@ -12,17 +11,17 @@ import (
"github.com/bitly/go-simplejson"
"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/requests"
"github.com/spf13/cast"
"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/util"
)
// ClaimExtractor is used to extract claim values from an ID Token, or, if not
// present, from the profile URL.
type ClaimExtractor interface {
// GetClaim fetches a named claim and returns the value.
GetClaim(claim string) (interface{}, bool, error)
GetClaim(claim string) (any, bool, error)
// GetClaimInto fetches a named claim and puts the value into the destination.
GetClaimInto(claim string, dst interface{}) (bool, error)
GetClaimInto(claim string, dst any) (bool, error)
}
// NewClaimExtractor constructs a new ClaimExtractor from the raw ID Token.
@@ -31,12 +30,12 @@ type ClaimExtractor interface {
func NewClaimExtractor(ctx context.Context, idToken string, profileURL *url.URL, profileRequestHeaders http.Header) (ClaimExtractor, error) {
payload, err := parseJWT(idToken)
if err != nil {
return nil, fmt.Errorf("failed to parse ID Token: %v", err)
return nil, fmt.Errorf("failed to parse ID Token: %w", err)
}
tokenClaims, err := simplejson.NewJson(payload)
if err != nil {
return nil, fmt.Errorf("failed to parse ID Token payload: %v", err)
return nil, fmt.Errorf("failed to parse ID Token payload: %w", err)
}
return &claimExtractor{
@@ -59,7 +58,7 @@ type claimExtractor struct {
// GetClaim will return the value claim if it exists.
// It will only return an error if the profile URL needs to be fetched due to
// the claim not being present in the ID Token.
func (c *claimExtractor) GetClaim(claim string) (interface{}, bool, error) {
func (c *claimExtractor) GetClaim(claim string) (any, bool, error) {
if claim == "" {
return nil, false, nil
}
@@ -124,7 +123,7 @@ func (c *claimExtractor) loadProfileClaims() (*simplejson.Json, error) {
// GetClaimInto loads a claim and places it into the destination interface.
// This will attempt to coerce the claim into the specified type.
// If it cannot be coerced, an error may be returned.
func (c *claimExtractor) GetClaimInto(claim string, dst interface{}) (bool, error) {
func (c *claimExtractor) GetClaimInto(claim string, dst any) (bool, error) {
value, exists, err := c.GetClaim(claim)
if err != nil {
return false, fmt.Errorf("could not get claim %q: %v", claim, err)
@@ -132,8 +131,8 @@ func (c *claimExtractor) GetClaimInto(claim string, dst interface{}) (bool, erro
if !exists {
return false, nil
}
if err := coerceClaim(value, dst); err != nil {
return false, fmt.Errorf("could no coerce claim: %v", err)
if err := util.CoerceClaim(value, dst); err != nil {
return false, fmt.Errorf("could not coerce claim: %v", err)
}
return true, nil
@@ -156,73 +155,10 @@ func parseJWT(p string) ([]byte, error) {
// getClaimFrom gets a claim from a Json object.
// It can accept either a single claim name or a json path. The claim is always evaluated first as a single claim name.
// Paths with indexes are not supported.
func getClaimFrom(claim string, src *simplejson.Json) interface{} {
func getClaimFrom(claim string, src *simplejson.Json) any {
if value, ok := src.CheckGet(claim); ok {
return value.Interface()
}
claimParts := strings.Split(claim, ".")
return src.GetPath(claimParts...).Interface()
}
// coerceClaim tries to convert the value into the destination interface type.
// If it can convert the value, it will then store the value in the destination
// interface.
func coerceClaim(value, dst interface{}) error {
switch d := dst.(type) {
case *string:
str, err := toString(value)
if err != nil {
return fmt.Errorf("could not convert value to string: %v", err)
}
*d = str
case *[]string:
strSlice, err := toStringSlice(value)
if err != nil {
return fmt.Errorf("could not convert value to string slice: %v", err)
}
*d = strSlice
case *bool:
*d = cast.ToBool(value)
default:
return fmt.Errorf("unknown type for destination: %T", dst)
}
return nil
}
// toStringSlice converts an interface (either a slice or single value) into
// a slice of strings.
func toStringSlice(value interface{}) ([]string, error) {
var sliceValues []interface{}
switch v := value.(type) {
case []interface{}:
sliceValues = v
case interface{}:
sliceValues = []interface{}{v}
default:
sliceValues = cast.ToSlice(value)
}
out := []string{}
for _, v := range sliceValues {
str, err := toString(v)
if err != nil {
return nil, fmt.Errorf("could not convert slice entry to string %v: %v", v, err)
}
out = append(out, str)
}
return out, nil
}
// toString coerces a value into a string.
// If it is non-string, marshal it into JSON.
func toString(value interface{}) (string, error) {
if str, err := cast.ToStringE(value); err == nil {
return str, nil
}
jsonStr, err := json.Marshal(value)
if err != nil {
return "", err
}
return string(jsonStr), nil
}
+2 -53
View File
@@ -76,7 +76,7 @@ var _ = Describe("Claim Extractor Suite", func() {
func(in newClaimExtractorTableInput) {
_, err := NewClaimExtractor(context.Background(), in.idToken, nil, nil)
if in.expectedError != nil {
Expect(err).To(MatchError(in.expectedError))
Expect(err).To(MatchError(in.expectedError.Error()))
} else {
Expect(err).ToNot(HaveOccurred())
}
@@ -405,7 +405,7 @@ var _ = Describe("Claim Extractor Suite", func() {
into: "",
expectExists: false,
expectedValue: "",
expectedError: errors.New("could no coerce claim: unknown type for destination: string"),
expectedError: errors.New("could not coerce claim: unknown type for destination: string"),
}),
Entry("flattens a complex claim value into a JSON string", getClaimIntoTableInput{
testClaimExtractorOpts: testClaimExtractorOpts{
@@ -451,53 +451,6 @@ var _ = Describe("Claim Extractor Suite", func() {
}),
)
type coerceClaimTableInput struct {
value interface{}
dst interface{}
expectedDst interface{}
expectedError error
}
DescribeTable("coerceClaim",
func(in coerceClaimTableInput) {
err := coerceClaim(in.value, in.dst)
if in.expectedError != nil {
Expect(err).To(MatchError(in.expectedError))
return
}
Expect(err).ToNot(HaveOccurred())
Expect(in.dst).To(Equal(in.expectedDst))
},
Entry("coerces a string to a string", coerceClaimTableInput{
value: "some_string",
dst: stringPointer(""),
expectedDst: stringPointer("some_string"),
}),
Entry("coerces a slice to a string slice", coerceClaimTableInput{
value: []interface{}{"a", "b"},
dst: stringSlicePointer([]string{}),
expectedDst: stringSlicePointer([]string{"a", "b"}),
}),
Entry("coerces a bool to a bool", coerceClaimTableInput{
value: true,
dst: boolPointer(false),
expectedDst: boolPointer(true),
}),
Entry("coerces a string to a bool", coerceClaimTableInput{
value: "true",
dst: boolPointer(false),
expectedDst: boolPointer(true),
}),
Entry("coerces a map to a string", coerceClaimTableInput{
value: map[string]interface{}{
"foo": []interface{}{"bar", "baz"},
},
dst: stringPointer(""),
expectedDst: stringPointer("{\"foo\":[\"bar\",\"baz\"]}"),
}),
)
It("should extract claims from a JWT response", func() {
jwtResponsePayload := `{
"user": "jwtUser",
@@ -605,10 +558,6 @@ func stringSlicePointer(in []string) *[]string {
return &in
}
func boolPointer(in bool) *bool {
return &in
}
// ******************************
// Different profile URL handlers
// ******************************
+64
View File
@@ -5,6 +5,7 @@ import (
"crypto/rsa"
"crypto/x509"
"crypto/x509/pkix"
"encoding/json"
"fmt"
"math/big"
"net"
@@ -12,6 +13,8 @@ import (
"os"
"strings"
"time"
"github.com/spf13/cast"
)
func GetCertPool(paths []string, useSystemPool bool) (*x509.CertPool, error) {
@@ -191,3 +194,64 @@ func RemoveDuplicateStr(strSlice []string) []string {
}
return list
}
// CoerceClaim tries to convert the value into the destination interface type.
// If it can convert the value, it will then store the value in the destination
// interface.
func CoerceClaim(value, dst any) error {
switch d := dst.(type) {
case *string:
str, err := toString(value)
if err != nil {
return fmt.Errorf("could not convert value to string: %v", err)
}
*d = str
case *[]string:
strSlice, err := toStringSlice(value)
if err != nil {
return fmt.Errorf("could not convert value to string slice: %v", err)
}
*d = strSlice
case *bool:
*d = cast.ToBool(value)
default:
return fmt.Errorf("unknown type for destination: %T", dst)
}
return nil
}
// toStringSlice converts an interface (either a slice or single value) into
// a slice of strings.
func toStringSlice(value any) ([]string, error) {
var sliceValues []any
switch v := value.(type) {
case []any:
sliceValues = v
default:
sliceValues = []any{v}
}
out := []string{}
for _, v := range sliceValues {
str, err := toString(v)
if err != nil {
return nil, fmt.Errorf("could not convert slice entry to string %v: %v", v, err)
}
out = append(out, str)
}
return out, nil
}
// toString coerces a value into a string.
// If it is non-string, marshal it into JSON.
func toString(value any) (string, error) {
if str, err := cast.ToStringE(value); err == nil {
return str, nil
}
jsonStr, err := json.Marshal(value)
if err != nil {
return "", err
}
return string(jsonStr), nil
}
+70
View File
@@ -2,10 +2,13 @@ package util
import (
"crypto/x509"
"encoding/json"
"encoding/pem"
"os"
"reflect"
"testing"
"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/util/ptr"
"github.com/stretchr/testify/assert"
)
@@ -253,3 +256,70 @@ func TestGetCertPool(t *testing.T) {
assert.Error(t, err3)
}
}
type coerceClaimTableInput struct {
name string
value any
dst any
expectedDst any
expectedError error
}
func TestCoerceClaim(t *testing.T) {
tests := []coerceClaimTableInput{
{
name: "coerces a string to a string",
value: "some_string",
dst: ptr.To(""),
expectedDst: ptr.To("some_string"),
},
{
name: "coerces a slice to a string slice",
value: []any{"a", "b"},
dst: ptr.To([]string{}),
expectedDst: ptr.To([]string{"a", "b"}),
},
{
name: "coerces a bool to a bool",
value: true,
dst: ptr.To(false),
expectedDst: ptr.To(true),
},
{
name: "coerces a string to a bool",
value: "true",
dst: ptr.To(false),
expectedDst: ptr.To(true),
},
{
name: "coerces a map to a string",
value: map[string]any{
"foo": []any{"bar", "baz"},
},
dst: ptr.To(""),
expectedDst: ptr.To("{\"foo\":[\"bar\",\"baz\"]}"),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := CoerceClaim(tt.value, tt.dst)
if tt.expectedError != nil {
if err == nil || err.Error() != tt.expectedError.Error() {
t.Errorf("expected error %v, got %v", tt.expectedError, err)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !reflect.DeepEqual(tt.dst, tt.expectedDst) {
gotJSON, _ := json.Marshal(tt.dst)
wantJSON, _ := json.Marshal(tt.expectedDst)
t.Errorf("expected dst to be %+v, got %+v", string(wantJSON), string(gotJSON))
}
})
}
}