@@ -170,6 +170,148 @@ func TestCustomOAuthProvider_GetUserDataWithAttributeMapping(t *testing.T) {
170170 assert .True (t , userData .Emails [0 ].Verified ) // Should be true from literal mapping
171171}
172172
173+ func TestCustomOAuthProvider_GetUserDataPreservesCustomClaims (t * testing.T ) {
174+ server := httptest .NewServer (http .HandlerFunc (func (w http.ResponseWriter , r * http.Request ) {
175+ w .Header ().Set ("Content-Type" , "application/json" )
176+ json .NewEncoder (w ).Encode (map [string ]interface {}{
177+ "sub" : "user-123" ,
178+ "email" : "test@example.com" ,
179+ "email_verified" : true ,
180+ "name" : "Test User" ,
181+ // Non-standard claims that previously got silently dropped.
182+ "groups" : []string {"admins" , "billing" },
183+ "org_id" : "org_42" ,
184+ "tenant_id" : "tenant-abc" ,
185+ })
186+ }))
187+ defer server .Close ()
188+
189+ provider := NewCustomOAuthProvider (
190+ "client-id" ,
191+ "client-secret" ,
192+ "https://example.com/authorize" ,
193+ "https://example.com/token" ,
194+ server .URL ,
195+ "https://myapp.com/callback" ,
196+ []string {"openid" , "profile" , "email" },
197+ false ,
198+ nil ,
199+ nil ,
200+ nil ,
201+ )
202+
203+ token := & oauth2.Token {AccessToken : "test-access-token" , TokenType : "Bearer" }
204+ userData , err := provider .GetUserData (context .Background (), token )
205+ require .NoError (t , err )
206+ require .NotNil (t , userData )
207+ require .NotNil (t , userData .Metadata )
208+
209+ // Standard fields still populated.
210+ assert .Equal (t , "user-123" , userData .Metadata .Subject )
211+ assert .Equal (t , "test@example.com" , userData .Metadata .Email )
212+ assert .Equal (t , "Test User" , userData .Metadata .Name )
213+
214+ // Non-standard claims preserved under CustomClaims.
215+ require .NotNil (t , userData .Metadata .CustomClaims )
216+ assert .Equal (t , "org_42" , userData .Metadata .CustomClaims ["org_id" ])
217+ assert .Equal (t , "tenant-abc" , userData .Metadata .CustomClaims ["tenant_id" ])
218+
219+ groups , ok := userData .Metadata .CustomClaims ["groups" ].([]interface {})
220+ require .True (t , ok , "groups should round-trip as []interface{}" )
221+ require .Len (t , groups , 2 )
222+ assert .Equal (t , "admins" , groups [0 ])
223+ assert .Equal (t , "billing" , groups [1 ])
224+
225+ // Known fields must NOT also leak into CustomClaims.
226+ _ , hasEmail := userData .Metadata .CustomClaims ["email" ]
227+ assert .False (t , hasEmail )
228+ _ , hasSub := userData .Metadata .CustomClaims ["sub" ]
229+ assert .False (t , hasSub )
230+ }
231+
232+ func TestCustomClaimsUnmarshalCapturesCustomClaims (t * testing.T ) {
233+ t .Run ("standard claims fill typed fields and non-standard claims land in CustomClaims" , func (t * testing.T ) {
234+ body := []byte (`{
235+ "sub": "u-1",
236+ "email": "a@b.com",
237+ "email_verified": true,
238+ "groups": ["x"],
239+ "org_id": "o1"
240+ }` )
241+ var c customClaims
242+ require .NoError (t , json .Unmarshal (body , & c ))
243+
244+ assert .Equal (t , "u-1" , c .Subject )
245+ assert .Equal (t , "a@b.com" , c .Email )
246+ assert .True (t , c .EmailVerified )
247+ require .NotNil (t , c .CustomClaims )
248+ assert .Equal (t , "o1" , c .CustomClaims ["org_id" ])
249+ assert .Equal (t , []interface {}{"x" }, c .CustomClaims ["groups" ])
250+
251+ _ , hasEmail := c .CustomClaims ["email" ]
252+ assert .False (t , hasEmail , "standard claims must not also leak into CustomClaims" )
253+ })
254+
255+ t .Run ("only standard claims means CustomClaims stays nil" , func (t * testing.T ) {
256+ body := []byte (`{"sub":"u","email":"a@b.com"}` )
257+ var c customClaims
258+ require .NoError (t , json .Unmarshal (body , & c ))
259+ assert .Nil (t , c .CustomClaims )
260+ })
261+
262+ t .Run ("provider that literally returns custom_claims is preserved flat (not re-nested)" , func (t * testing.T ) {
263+ body := []byte (`{"sub":"u-2","custom_claims":{"foo":"bar"}}` )
264+ var c customClaims
265+ require .NoError (t , json .Unmarshal (body , & c ))
266+ assert .Equal (t , "u-2" , c .Subject )
267+ require .NotNil (t , c .CustomClaims )
268+ assert .Equal (t , "bar" , c .CustomClaims ["foo" ])
269+ _ , nested := c .CustomClaims ["custom_claims" ]
270+ assert .False (t , nested , "custom_claims must not be re-nested under itself" )
271+ })
272+
273+ t .Run ("custom_claims object and other non-standard keys are merged at top level" , func (t * testing.T ) {
274+ body := []byte (`{
275+ "sub": "u-3",
276+ "custom_claims": {"foo": "bar"},
277+ "groups": ["admins"],
278+ "org_id": "o1"
279+ }` )
280+ var c customClaims
281+ require .NoError (t , json .Unmarshal (body , & c ))
282+ assert .Equal (t , "u-3" , c .Subject )
283+ require .NotNil (t , c .CustomClaims )
284+ assert .Equal (t , "bar" , c .CustomClaims ["foo" ])
285+ assert .Equal (t , "o1" , c .CustomClaims ["org_id" ])
286+ assert .Equal (t , []interface {}{"admins" }, c .CustomClaims ["groups" ])
287+ _ , nested := c .CustomClaims ["custom_claims" ]
288+ assert .False (t , nested , "custom_claims must not be re-nested under itself" )
289+ })
290+
291+ t .Run ("entries inside custom_claims win over same-named top-level keys" , func (t * testing.T ) {
292+ // IdP returns "groups" both inside custom_claims and at the top
293+ // level. The typed decode places the inner value into CustomClaims
294+ // first; the outer one must not silently overwrite it.
295+ body := []byte (`{
296+ "sub": "u-4",
297+ "custom_claims": {"groups": ["from-inner"]},
298+ "groups": ["from-outer"]
299+ }` )
300+ var c customClaims
301+ require .NoError (t , json .Unmarshal (body , & c ))
302+ require .NotNil (t , c .CustomClaims )
303+ assert .Equal (t , []interface {}{"from-inner" }, c .CustomClaims ["groups" ])
304+ })
305+
306+ t .Run ("plain Claims still drops non-standard claims (proves scoping)" , func (t * testing.T ) {
307+ body := []byte (`{"sub":"u","groups":["x"]}` )
308+ var c Claims
309+ require .NoError (t , json .Unmarshal (body , & c ))
310+ assert .Equal (t , "u" , c .Subject )
311+ assert .Nil (t , c .CustomClaims , "non-custom providers must keep existing drop behaviour" )
312+ })
313+ }
314+
173315func TestApplyAttributeMapping (t * testing.T ) {
174316 tests := []struct {
175317 name string
0 commit comments