zitadel/internal/api/oidc/client_integration_test.go
Tim Möhlmann 8054e6753a
fix(oidc): roles in userinfo for client credentials token (#7763)
* fix(oidc): roles in userinfo for client credentials token

When tokens were obtained using the client credentials grant,
with audience and role scopes, userinfo would not return the role claims. This had multiple causes:

1. There is no auth request flow, so for legacy userinfo project data was never attached to the token
2. For optimized userinfo, there is no client ID that maps to an application. The client ID for client credentials is the machine user's name. There we can't obtain a project ID. When the project ID remained empty, we always ignored the roleAudience.

This PR fixes situation 2, by always taking the roleAudience into account, even when the projectID is empty. The code responsible for the bug is also refactored to be more readable and understandable, including additional godoc.

The fix only applies to the optimized userinfo code introduced in #7706 and released in v2.50 (currently in RC). Therefore it can't be back-ported to earlier versions.

Fixes #6662

* chore(deps): update all go deps (#7764)

This change updates all go modules, including oidc, a major version of go-jose and the go 1.22 release.

* Revert "chore(deps): update all go deps" (#7772)

Revert "chore(deps): update all go deps (#7764)"

This reverts commit 6893e7d060a953d595a18ff8daa979834c4324d5.

---------

Co-authored-by: Livio Spring <livio.a@gmail.com>
(cherry picked from commit 9ccbbe05bc602d57e1eb897d4d28e876ff7dea86)
2024-04-16 15:46:13 +02:00

419 lines
15 KiB
Go

//go:build integration
package oidc_test
import (
"context"
"testing"
"time"
"github.com/brianvoe/gofakeit/v6"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/zitadel/oidc/v3/pkg/client"
"github.com/zitadel/oidc/v3/pkg/client/rp"
"github.com/zitadel/oidc/v3/pkg/client/rs"
"github.com/zitadel/oidc/v3/pkg/oidc"
"golang.org/x/text/language"
oidc_api "github.com/zitadel/zitadel/internal/api/oidc"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/integration"
"github.com/zitadel/zitadel/pkg/grpc/authn"
"github.com/zitadel/zitadel/pkg/grpc/management"
oidc_pb "github.com/zitadel/zitadel/pkg/grpc/oidc/v2beta"
"github.com/zitadel/zitadel/pkg/grpc/user"
)
func TestServer_Introspect(t *testing.T) {
project, err := Tester.CreateProject(CTX)
require.NoError(t, err)
app, err := Tester.CreateOIDCNativeClient(CTX, redirectURI, logoutRedirectURI, project.GetId(), false)
require.NoError(t, err)
wantAudience := []string{app.GetClientId(), project.GetId()}
tests := []struct {
name string
api func(*testing.T) (apiID string, resourceServer rs.ResourceServer)
wantErr bool
}{
{
name: "client assertion",
api: func(t *testing.T) (string, rs.ResourceServer) {
api, err := Tester.CreateAPIClientJWT(CTX, project.GetId())
require.NoError(t, err)
keyResp, err := Tester.Client.Mgmt.AddAppKey(CTX, &management.AddAppKeyRequest{
ProjectId: project.GetId(),
AppId: api.GetAppId(),
Type: authn.KeyType_KEY_TYPE_JSON,
ExpirationDate: nil,
})
require.NoError(t, err)
resourceServer, err := Tester.CreateResourceServerJWTProfile(CTX, keyResp.GetKeyDetails())
require.NoError(t, err)
return api.GetClientId(), resourceServer
},
},
{
name: "client credentials",
api: func(t *testing.T) (string, rs.ResourceServer) {
api, err := Tester.CreateAPIClientBasic(CTX, project.GetId())
require.NoError(t, err)
resourceServer, err := Tester.CreateResourceServerClientCredentials(CTX, api.GetClientId(), api.GetClientSecret())
require.NoError(t, err)
return api.GetClientId(), resourceServer
},
},
{
name: "client invalid id, error",
api: func(t *testing.T) (string, rs.ResourceServer) {
api, err := Tester.CreateAPIClientBasic(CTX, project.GetId())
require.NoError(t, err)
resourceServer, err := Tester.CreateResourceServerClientCredentials(CTX, "xxxxx", api.GetClientSecret())
require.NoError(t, err)
return api.GetClientId(), resourceServer
},
wantErr: true,
},
{
name: "client invalid secret, error",
api: func(t *testing.T) (string, rs.ResourceServer) {
api, err := Tester.CreateAPIClientBasic(CTX, project.GetId())
require.NoError(t, err)
resourceServer, err := Tester.CreateResourceServerClientCredentials(CTX, api.GetClientId(), "xxxxx")
require.NoError(t, err)
return api.GetClientId(), resourceServer
},
wantErr: true,
},
{
name: "client credentials on jwt client, error",
api: func(t *testing.T) (string, rs.ResourceServer) {
api, err := Tester.CreateAPIClientJWT(CTX, project.GetId())
require.NoError(t, err)
resourceServer, err := Tester.CreateResourceServerClientCredentials(CTX, api.GetClientId(), "xxxxx")
require.NoError(t, err)
return api.GetClientId(), resourceServer
},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
apiID, resourceServer := tt.api(t)
// wantAudience grows for every API we add to the project.
wantAudience = append(wantAudience, apiID)
scope := []string{oidc.ScopeOpenID, oidc.ScopeProfile, oidc.ScopeEmail, oidc.ScopeOfflineAccess, oidc_api.ScopeResourceOwner}
authRequestID := createAuthRequest(t, app.GetClientId(), redirectURI, scope...)
sessionID, sessionToken, startTime, changeTime := Tester.CreateVerifiedWebAuthNSession(t, CTXLOGIN, User.GetUserId())
linkResp, err := Tester.Client.OIDCv2.CreateCallback(CTXLOGIN, &oidc_pb.CreateCallbackRequest{
AuthRequestId: authRequestID,
CallbackKind: &oidc_pb.CreateCallbackRequest_Session{
Session: &oidc_pb.Session{
SessionId: sessionID,
SessionToken: sessionToken,
},
},
})
require.NoError(t, err)
// code exchange
code := assertCodeResponse(t, linkResp.GetCallbackUrl())
tokens, err := exchangeTokens(t, app.GetClientId(), code, redirectURI)
require.NoError(t, err)
assertTokens(t, tokens, true)
assertIDTokenClaims(t, tokens.IDTokenClaims, User.GetUserId(), armPasskey, startTime, changeTime)
// test actual introspection
introspection, err := rs.Introspect[*oidc.IntrospectionResponse](context.Background(), resourceServer, tokens.AccessToken)
if tt.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
assertIntrospection(t, introspection,
Tester.OIDCIssuer(), app.GetClientId(),
scope, wantAudience,
tokens.Expiry, tokens.Expiry.Add(-12*time.Hour))
})
}
}
func assertIntrospection(
t *testing.T,
introspection *oidc.IntrospectionResponse,
issuer, clientID string,
scope, audience []string,
expiration, creation time.Time,
) {
assert.True(t, introspection.Active)
assert.Equal(t, scope, []string(introspection.Scope))
assert.Equal(t, clientID, introspection.ClientID)
assert.Equal(t, oidc.BearerToken, introspection.TokenType)
assertOIDCTime(t, introspection.Expiration, expiration)
assertOIDCTime(t, introspection.IssuedAt, creation)
assertOIDCTime(t, introspection.NotBefore, creation)
assert.Equal(t, User.GetUserId(), introspection.Subject)
assert.ElementsMatch(t, audience, introspection.Audience)
assert.Equal(t, issuer, introspection.Issuer)
assert.NotEmpty(t, introspection.JWTID)
assert.NotEmpty(t, introspection.Username)
assert.Equal(t, introspection.Username, introspection.PreferredUsername)
assert.Equal(t, "Mickey", introspection.GivenName)
assert.Equal(t, "Mouse", introspection.FamilyName)
assert.Equal(t, "Mickey Mouse", introspection.Name)
assert.Equal(t, oidc.Gender("male"), introspection.Gender)
assert.Equal(t, oidc.NewLocale(language.Dutch), introspection.Locale)
assert.Equal(t, introspection.Username, introspection.Email)
assert.False(t, bool(introspection.EmailVerified))
assertOIDCTime(t, introspection.UpdatedAt, User.GetDetails().GetChangeDate().AsTime())
require.NotNil(t, introspection.Claims)
assert.Equal(t, User.Details.ResourceOwner, introspection.Claims[oidc_api.ClaimResourceOwnerID])
assert.NotEmpty(t, introspection.Claims[oidc_api.ClaimResourceOwnerName])
assert.NotEmpty(t, introspection.Claims[oidc_api.ClaimResourceOwnerPrimaryDomain])
}
// TestServer_VerifyClient tests verification by running code flow tests
// with clients that have different authentication methods.
func TestServer_VerifyClient(t *testing.T) {
sessionID, sessionToken, startTime, changeTime := Tester.CreateVerifiedWebAuthNSession(t, CTXLOGIN, User.GetUserId())
project, err := Tester.CreateProject(CTX)
require.NoError(t, err)
inactiveClient, err := Tester.CreateOIDCInactivateClient(CTX, redirectURI, logoutRedirectURI, project.GetId())
require.NoError(t, err)
nativeClient, err := Tester.CreateOIDCNativeClient(CTX, redirectURI, logoutRedirectURI, project.GetId(), false)
require.NoError(t, err)
basicWebClient, err := Tester.CreateOIDCWebClientBasic(CTX, redirectURI, logoutRedirectURI, project.GetId())
require.NoError(t, err)
jwtWebClient, keyData, err := Tester.CreateOIDCWebClientJWT(CTX, redirectURI, logoutRedirectURI, project.GetId())
require.NoError(t, err)
type clientDetails struct {
authReqClientID string
clientID string
clientSecret string
keyData []byte
}
tests := []struct {
name string
client clientDetails
wantErr bool
}{
{
name: "empty client ID error",
client: clientDetails{
authReqClientID: nativeClient.GetClientId(),
},
wantErr: true,
},
{
name: "client not found error",
client: clientDetails{
authReqClientID: nativeClient.GetClientId(),
clientID: "foo",
},
wantErr: true,
},
{
name: "client inactive error",
client: clientDetails{
authReqClientID: nativeClient.GetClientId(),
clientID: inactiveClient.GetClientId(),
},
wantErr: true,
},
{
name: "native client success",
client: clientDetails{
authReqClientID: nativeClient.GetClientId(),
clientID: nativeClient.GetClientId(),
},
},
{
name: "web client basic secret empty error",
client: clientDetails{
authReqClientID: basicWebClient.GetClientId(),
clientID: basicWebClient.GetClientId(),
clientSecret: "",
},
wantErr: true,
},
{
name: "web client basic secret invalid error",
client: clientDetails{
authReqClientID: basicWebClient.GetClientId(),
clientID: basicWebClient.GetClientId(),
clientSecret: "wrong",
},
wantErr: true,
},
{
name: "web client basic secret success",
client: clientDetails{
authReqClientID: basicWebClient.GetClientId(),
clientID: basicWebClient.GetClientId(),
clientSecret: basicWebClient.GetClientSecret(),
},
},
{
name: "web client JWT profile empty assertion error",
client: clientDetails{
authReqClientID: jwtWebClient.GetClientId(),
clientID: jwtWebClient.GetClientId(),
},
wantErr: true,
},
{
name: "web client JWT profile invalid assertion error",
client: clientDetails{
authReqClientID: jwtWebClient.GetClientId(),
clientID: jwtWebClient.GetClientId(),
keyData: createInvalidKeyData(t, jwtWebClient),
},
wantErr: true,
},
{
name: "web client JWT profile success",
client: clientDetails{
authReqClientID: jwtWebClient.GetClientId(),
clientID: jwtWebClient.GetClientId(),
keyData: keyData,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
authRequestID, err := Tester.CreateOIDCAuthRequest(CTX, tt.client.authReqClientID, Tester.Users[integration.FirstInstanceUsersKey][integration.Login].ID, redirectURI, oidc.ScopeOpenID)
require.NoError(t, err)
linkResp, err := Tester.Client.OIDCv2.CreateCallback(CTXLOGIN, &oidc_pb.CreateCallbackRequest{
AuthRequestId: authRequestID,
CallbackKind: &oidc_pb.CreateCallbackRequest_Session{
Session: &oidc_pb.Session{
SessionId: sessionID,
SessionToken: sessionToken,
},
},
})
require.NoError(t, err)
// use a new RP so we can inject different credentials
var options []rp.Option
if tt.client.keyData != nil {
options = append(options, rp.WithJWTProfile(rp.SignerFromKeyFile(tt.client.keyData)))
}
provider, err := rp.NewRelyingPartyOIDC(CTX, Tester.OIDCIssuer(), tt.client.clientID, tt.client.clientSecret, redirectURI, []string{oidc.ScopeOpenID}, options...)
require.NoError(t, err)
// test code exchange
code := assertCodeResponse(t, linkResp.GetCallbackUrl())
codeOpts := codeExchangeOptions(t, provider)
tokens, err := rp.CodeExchange[*oidc.IDTokenClaims](context.Background(), code, provider, codeOpts...)
if tt.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
assertTokens(t, tokens, false)
assertIDTokenClaims(t, tokens.IDTokenClaims, User.GetUserId(), armPasskey, startTime, changeTime)
})
}
}
func codeExchangeOptions(t testing.TB, provider rp.RelyingParty) []rp.CodeExchangeOpt {
codeOpts := []rp.CodeExchangeOpt{rp.WithCodeVerifier(integration.CodeVerifier)}
if signer := provider.Signer(); signer != nil {
assertion, err := client.SignedJWTProfileAssertion(provider.OAuthConfig().ClientID, []string{provider.Issuer()}, time.Hour, provider.Signer())
require.NoError(t, err)
codeOpts = append(codeOpts, rp.WithClientAssertionJWT(assertion))
}
return codeOpts
}
func createInvalidKeyData(t testing.TB, client *management.AddOIDCAppResponse) []byte {
key := domain.ApplicationKey{
Type: domain.AuthNKeyTypeJSON,
KeyID: "1",
PrivateKey: []byte("-----BEGIN RSA PRIVATE KEY-----\nMIIEogIBAAKCAQEAxHd087RoEm9ywVWZ/H+tDWxQsmVvhfRz4jAq/RfU+OWXNH4J\njMMSHdFs0Q+WP98nNXRyc7fgbMb8NdmlB2yD4qLYapN5SDaBc5dh/3EnyFt53oSs\njTlKnQUPAeJr2qh/NY046CfyUyQMM4JR5OiQFo4TssfWnqdcgamGt0AEnk2lvbMZ\nKQdAqNS9lDzYbjMGavEQPTZE35mFXFQXjaooZXq+TIa7hbaq7/idH7cHNbLcPLgj\nfPQA8q+DYvnvhXlmq0LPQZH3Oiixf+SF2vRwrBzT2mqGD2OiOkUmhuPwyqEiiBHt\nfxklRtRU6WfLa1Gcb1PsV0uoBGpV3KybIl/GlwIDAQABAoIBAEQjDduLgOCL6Gem\n0X3hpdnW6/HC/jed/Sa//9jBECq2LYeWAqff64ON40hqOHi0YvvGA/+gEOSI6mWe\nsv5tIxxRz+6+cLybsq+tG96kluCE4TJMHy/nY7orS/YiWbd+4odnEApr+D3fbZ/b\nnZ1fDsHTyn8hkYx6jLmnWsJpIHDp7zxD76y7k2Bbg6DZrCGiVxngiLJk23dvz79W\np03lHLM7XE92aFwXQmhfxHGxrbuoB/9eY4ai5IHp36H4fw0vL6NXdNQAo/bhe0p9\nAYB7y0ZumF8Hg0Z/BmMeEzLy6HrYB+VE8cO93pNjhSyH+p2yDB/BlUyTiRLQAoM0\nVTmOZXECgYEA7NGlzpKNhyQEJihVqt0MW0LhKIO/xbBn+XgYfX6GpqPa/ucnMx5/\nVezpl3gK8IU4wPUhAyXXAHJiqNBcEeyxrw0MXLujDVMJgYaLysCLJdvMVgoY08mS\nK5IQivpbozpf4+0y3mOnA+Sy1kbfxv2X8xiWLODRQW3f3q/xoklwOR8CgYEA1GEe\nfaibOFTQAYcIVj77KXtBfYZsX3EGAyfAN9O7cKHq5oaxVstwnF47WxpuVtoKZxCZ\nbNm9D5WvQ9b+Ztpioe42tzwE7Bff/Osj868GcDdRPK7nFlh9N2yVn/D514dOYVwR\n4MBr1KrJzgRWt4QqS4H+to1GzudDTSNlG7gnK4kCgYBUi6AbOHzoYzZL/RhgcJwp\ntJ23nhmH1Su5h2OO4e3mbhcP66w19sxU+8iFN+kH5zfUw26utgKk+TE5vXExQQRK\nT2k7bg2PAzcgk80ybD0BHhA8I0yrx4m0nmfjhe/TPVLgh10iwgbtP+eM0i6v1vc5\nZWyvxu9N4ZEL6lpkqr0y1wKBgG/NAIQd8jhhTW7Aav8cAJQBsqQl038avJOEpYe+\nCnpsgoAAf/K0/f8TDCQVceh+t+MxtdK7fO9rWOxZjWsPo8Si5mLnUaAHoX4/OpnZ\nlYYVWMqdOEFnK+O1Yb7k2GFBdV2DXlX2dc1qavntBsls5ecB89id3pyk2aUN8Pf6\npYQhAoGAMGtrHFely9wyaxI0RTCyfmJbWZHGVGkv6ELK8wneJjdjl82XOBUGCg5q\naRCrTZ3dPitKwrUa6ibJCIFCIziiriBmjDvTHzkMvoJEap2TVxYNDR6IfINVsQ57\nlOsiC4A2uGq4Lbfld+gjoplJ5GX6qXtTgZ6m7eo0y7U6zm2tkN0=\n-----END RSA PRIVATE KEY-----\n"),
ApplicationID: client.GetAppId(),
ClientID: client.GetClientId(),
}
data, err := key.Detail()
require.NoError(t, err)
return data
}
func TestServer_CreateAccessToken_ClientCredentials(t *testing.T) {
_, clientID, clientSecret, err := Tester.CreateOIDCCredentialsClient(CTX)
require.NoError(t, err)
type clientDetails struct {
clientID string
clientSecret string
keyData []byte
}
tests := []struct {
name string
clientID string
clientSecret string
wantErr bool
}{
{
name: "missing client ID error",
clientID: "",
clientSecret: clientSecret,
wantErr: true,
},
{
name: "client not found error",
clientID: "foo",
clientSecret: clientSecret,
wantErr: true,
},
{
name: "machine user without secret error",
clientID: func() string {
name := gofakeit.Username()
_, err := Tester.Client.Mgmt.AddMachineUser(CTX, &management.AddMachineUserRequest{
Name: name,
UserName: name,
AccessTokenType: user.AccessTokenType_ACCESS_TOKEN_TYPE_JWT,
})
require.NoError(t, err)
return name
}(),
clientSecret: clientSecret,
wantErr: true,
},
{
name: "wrong secret error",
clientID: clientID,
clientSecret: "bar",
wantErr: true,
},
{
name: "success",
clientID: clientID,
clientSecret: clientSecret,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
provider, err := rp.NewRelyingPartyOIDC(CTX, Tester.OIDCIssuer(), tt.clientID, tt.clientSecret, redirectURI, []string{oidc.ScopeOpenID})
require.NoError(t, err)
tokens, err := rp.ClientCredentials(CTX, provider, nil)
if tt.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
assert.NotNil(t, tokens)
assert.NotEmpty(t, tokens.AccessToken)
})
}
}