perf(oidc): optimize client verification (#6999)

* fix some spelling errors

* client credential auth

* implementation of client auth

* improve error handling

* unit test command package

* unit test database package

* unit test query package

* cleanup unused tracing func

* fix integration tests

* errz to zerrors

* fix linting and import issues

* fix another linting error

* integration test with client secret

* Revert "integration test with client secret"

This reverts commit 0814ba522f.

* add integration tests

* client credentials integration test

* resolve comments

* pin oidc v3.5.0
This commit is contained in:
Tim Möhlmann
2023-12-05 19:01:03 +02:00
committed by GitHub
parent 51cfb9564a
commit ec03340b67
46 changed files with 1666 additions and 781 deletions

View File

@@ -10,7 +10,7 @@ import (
"github.com/zitadel/oidc/v3/pkg/op"
"github.com/zitadel/zitadel/internal/command"
errz "github.com/zitadel/zitadel/internal/errors"
zerrors "github.com/zitadel/zitadel/internal/errors"
"github.com/zitadel/zitadel/internal/query"
"github.com/zitadel/zitadel/internal/user/model"
)
@@ -55,7 +55,7 @@ func (s *Server) verifyAccessToken(ctx context.Context, tkn string) (*accessToke
token, err := s.repo.TokenByIDs(ctx, subject, tokenID)
if err != nil {
return nil, errz.ThrowPermissionDenied(err, "OIDC-Dsfb2", "token is not valid or has expired")
return nil, zerrors.ThrowPermissionDenied(err, "OIDC-Dsfb2", "token is not valid or has expired")
}
return accessTokenV1(tokenID, subject, token), nil
}
@@ -91,7 +91,7 @@ func (s *Server) assertClientScopesForPAT(ctx context.Context, token *accessToke
token.audience = append(token.audience, clientID)
projectIDQuery, err := query.NewProjectRoleProjectIDSearchQuery(projectID)
if err != nil {
return errz.ThrowInternal(err, "OIDC-Cyc78", "Errors.Internal")
return zerrors.ThrowInternal(err, "OIDC-Cyc78", "Errors.Internal")
}
roles, err := s.query.SearchProjectRoles(ctx, s.features.TriggerIntrospectionProjections, &query.ProjectRoleSearchQueries{Queries: []query.SearchQuery{projectIDQuery}})
if err != nil {

View File

@@ -17,6 +17,7 @@ import (
http_utils "github.com/zitadel/zitadel/internal/api/http"
oidc_api "github.com/zitadel/zitadel/internal/api/oidc"
"github.com/zitadel/zitadel/internal/command"
"github.com/zitadel/zitadel/internal/integration"
oidc_pb "github.com/zitadel/zitadel/pkg/grpc/oidc/v2beta"
session "github.com/zitadel/zitadel/pkg/grpc/session/v2beta"
)
@@ -500,8 +501,7 @@ func exchangeTokens(t testing.TB, clientID, code string) (*oidc.Tokens[*oidc.IDT
provider, err := Tester.CreateRelyingParty(CTX, clientID, redirectURI)
require.NoError(t, err)
codeVerifier := "codeVerifier"
return rp.CodeExchange[*oidc.IDTokenClaims](context.Background(), code, provider, rp.WithCodeVerifier(codeVerifier))
return rp.CodeExchange[*oidc.IDTokenClaims](context.Background(), code, provider, rp.WithCodeVerifier(integration.CodeVerifier))
}
func refreshTokens(t testing.TB, clientID, refreshToken string) (*oidc.Tokens[*oidc.IDTokenClaims], error) {

View File

@@ -43,32 +43,14 @@ const (
func (o *OPStorage) GetClientByClientID(ctx context.Context, id string) (_ op.Client, err error) {
ctx, span := tracing.NewSpan(ctx)
defer func() { span.EndWithError(err) }()
client, err := o.query.AppByOIDCClientID(ctx, id)
client, err := o.query.GetOIDCClientByID(ctx, id, false)
if err != nil {
return nil, err
}
if client.State != domain.AppStateActive {
return nil, errors.ThrowPreconditionFailed(nil, "OIDC-sdaGg", "client is not active")
}
projectIDQuery, err := query.NewProjectRoleProjectIDSearchQuery(client.ProjectID)
if err != nil {
return nil, errors.ThrowInternal(err, "OIDC-mPxqP", "Errors.Internal")
}
projectRoles, err := o.query.SearchProjectRoles(ctx, true, &query.ProjectRoleSearchQueries{Queries: []query.SearchQuery{projectIDQuery}})
if err != nil {
return nil, err
}
allowedScopes := make([]string, len(projectRoles.ProjectRoles))
for i, role := range projectRoles.ProjectRoles {
allowedScopes[i] = ScopeProjectRolePrefix + role.Key
}
accessTokenLifetime, idTokenLifetime, _, _, err := o.getOIDCSettings(ctx)
if err != nil {
return nil, err
}
return ClientFromBusiness(client, o.defaultLoginURL, o.defaultLoginURLV2, accessTokenLifetime, idTokenLifetime, allowedScopes)
return ClientFromBusiness(client, o.defaultLoginURL, o.defaultLoginURLV2), nil
}
func (o *OPStorage) GetKeyByIDAndClientID(ctx context.Context, keyID, userID string) (_ *jose.JSONWebKey, err error) {
@@ -235,22 +217,10 @@ func (o *OPStorage) ClientCredentialsTokenRequest(ctx context.Context, clientID
}, nil
}
func (o *OPStorage) ClientCredentials(ctx context.Context, clientID, clientSecret string) (op.Client, error) {
loginname, err := query.NewUserLoginNamesSearchQuery(clientID)
if err != nil {
return nil, err
}
user, err := o.query.GetUser(ctx, false, loginname)
if err != nil {
return nil, err
}
if _, err := o.command.VerifyMachineSecret(ctx, user.ID, user.ResourceOwner, clientSecret); err != nil {
return nil, err
}
return &clientCredentialsClient{
id: clientID,
tokenType: accessTokenTypeToOIDC(user.Machine.AccessTokenType),
}, nil
// ClientCredentials method is kept to keep the storage interface implemented.
// However, it should never be called as the VerifyClient method on the Server is overridden.
func (o *OPStorage) ClientCredentials(context.Context, string, string) (op.Client, error) {
return nil, errors.ThrowInternal(nil, "OIDC-Su8So", "Errors.Internal")
}
// isOriginAllowed checks whether a call by the client to the endpoint is allowed from the provided origin
@@ -934,3 +904,67 @@ func userinfoClaims(userInfo *oidc.UserInfo) func(c *actions.FieldConfig) interf
return c.Runtime.ToValue(claims)
}
}
func (s *Server) VerifyClient(ctx context.Context, r *op.Request[op.ClientCredentials]) (_ op.Client, err error) {
ctx, span := tracing.NewSpan(ctx)
defer func() { span.EndWithError(err) }()
if oidc.GrantType(r.Form.Get("grant_type")) == oidc.GrantTypeClientCredentials {
return s.clientCredentialsAuth(ctx, r.Data.ClientID, r.Data.ClientSecret)
}
clientID, assertion, err := clientIDFromCredentials(r.Data)
if err != nil {
return nil, err
}
client, err := s.query.GetOIDCClientByID(ctx, clientID, assertion)
if errors.IsNotFound(err) {
return nil, oidc.ErrInvalidClient().WithParent(err).WithDescription("client not found")
}
if err != nil {
return nil, err // defaults to server error
}
if client.State != domain.AppStateActive {
return nil, oidc.ErrInvalidClient().WithDescription("client is not active")
}
switch client.AuthMethodType {
case domain.OIDCAuthMethodTypeBasic, domain.OIDCAuthMethodTypePost:
err = s.verifyClientSecret(ctx, client, r.Data.ClientSecret)
case domain.OIDCAuthMethodTypePrivateKeyJWT:
err = s.verifyClientAssertion(ctx, client, r.Data.ClientAssertion)
case domain.OIDCAuthMethodTypeNone:
}
if err != nil {
return nil, err
}
return ClientFromBusiness(client, s.defaultLoginURL, s.defaultLoginURLV2), nil
}
func (s *Server) verifyClientAssertion(ctx context.Context, client *query.OIDCClient, assertion string) (err error) {
ctx, span := tracing.NewSpan(ctx)
defer func() { span.EndWithError(err) }()
if assertion == "" {
return oidc.ErrInvalidClient().WithDescription("empty client assertion")
}
verifier := op.NewJWTProfileVerifierKeySet(keySetMap(client.PublicKeys), op.IssuerFromContext(ctx), time.Hour, client.ClockSkew)
if _, err := op.VerifyJWTAssertion(ctx, assertion, verifier); err != nil {
return oidc.ErrInvalidClient().WithParent(err).WithDescription("invalid assertion")
}
return nil
}
func (s *Server) verifyClientSecret(ctx context.Context, client *query.OIDCClient, secret string) (err error) {
_, span := tracing.NewSpan(ctx)
defer func() { span.EndWithError(err) }()
if secret == "" {
return oidc.ErrInvalidClient().WithDescription("empty client secret")
}
if err = crypto.CompareHash(client.ClientSecret, []byte(secret), s.hashAlg); err != nil {
return oidc.ErrInvalidClient().WithParent(err).WithDescription("invalid secret")
}
return nil
}

View File

@@ -1,6 +1,7 @@
package oidc
import (
"slices"
"strings"
"time"
@@ -9,43 +10,40 @@ import (
"github.com/zitadel/zitadel/internal/command"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/errors"
"github.com/zitadel/zitadel/internal/query"
)
type Client struct {
app *query.App
defaultLoginURL string
defaultLoginURLV2 string
defaultAccessTokenLifetime time.Duration
defaultIdTokenLifetime time.Duration
allowedScopes []string
client *query.OIDCClient
defaultLoginURL string
defaultLoginURLV2 string
allowedScopes []string
}
func ClientFromBusiness(app *query.App, defaultLoginURL, defaultLoginURLV2 string, defaultAccessTokenLifetime, defaultIdTokenLifetime time.Duration, allowedScopes []string) (op.Client, error) {
if app.OIDCConfig == nil {
return nil, errors.ThrowInvalidArgument(nil, "OIDC-d5bhD", "client is not a proper oidc application")
func ClientFromBusiness(client *query.OIDCClient, defaultLoginURL, defaultLoginURLV2 string) op.Client {
allowedScopes := make([]string, len(client.ProjectRoleKeys))
for i, roleKey := range client.ProjectRoleKeys {
allowedScopes[i] = ScopeProjectRolePrefix + roleKey
}
return &Client{
app: app,
defaultLoginURL: defaultLoginURL,
defaultLoginURLV2: defaultLoginURLV2,
defaultAccessTokenLifetime: defaultAccessTokenLifetime,
defaultIdTokenLifetime: defaultIdTokenLifetime,
allowedScopes: allowedScopes},
nil
client: client,
defaultLoginURL: defaultLoginURL,
defaultLoginURLV2: defaultLoginURLV2,
allowedScopes: allowedScopes,
}
}
func (c *Client) ApplicationType() op.ApplicationType {
return op.ApplicationType(c.app.OIDCConfig.AppType)
return op.ApplicationType(c.client.ApplicationType)
}
func (c *Client) AuthMethod() oidc.AuthMethod {
return authMethodToOIDC(c.app.OIDCConfig.AuthMethodType)
return authMethodToOIDC(c.client.AuthMethodType)
}
func (c *Client) GetID() string {
return c.app.OIDCConfig.ClientID
return c.client.ClientID
}
func (c *Client) LoginURL(id string) string {
@@ -56,28 +54,28 @@ func (c *Client) LoginURL(id string) string {
}
func (c *Client) RedirectURIs() []string {
return c.app.OIDCConfig.RedirectURIs
return c.client.RedirectURIs
}
func (c *Client) PostLogoutRedirectURIs() []string {
return c.app.OIDCConfig.PostLogoutRedirectURIs
return c.client.PostLogoutRedirectURIs
}
func (c *Client) ResponseTypes() []oidc.ResponseType {
return responseTypesToOIDC(c.app.OIDCConfig.ResponseTypes)
return responseTypesToOIDC(c.client.ResponseTypes)
}
func (c *Client) GrantTypes() []oidc.GrantType {
return grantTypesToOIDC(c.app.OIDCConfig.GrantTypes)
return grantTypesToOIDC(c.client.GrantTypes)
}
func (c *Client) DevMode() bool {
return c.app.OIDCConfig.IsDevMode
return c.client.IsDevMode
}
func (c *Client) RestrictAdditionalIdTokenScopes() func(scopes []string) []string {
return func(scopes []string) []string {
if c.app.OIDCConfig.AssertIDTokenRole {
if c.client.IDTokenRoleAssertion {
return scopes
}
return removeScopeWithPrefix(scopes, ScopeProjectRolePrefix)
@@ -86,7 +84,7 @@ func (c *Client) RestrictAdditionalIdTokenScopes() func(scopes []string) []strin
func (c *Client) RestrictAdditionalAccessTokenScopes() func(scopes []string) []string {
return func(scopes []string) []string {
if c.app.OIDCConfig.AssertAccessTokenRole {
if c.client.AccessTokenRoleAssertion {
return scopes
}
return removeScopeWithPrefix(scopes, ScopeProjectRolePrefix)
@@ -94,15 +92,15 @@ func (c *Client) RestrictAdditionalAccessTokenScopes() func(scopes []string) []s
}
func (c *Client) AccessTokenLifetime() time.Duration {
return c.defaultAccessTokenLifetime //PLANNED: impl from real client
return c.client.AccessTokenLifetime
}
func (c *Client) IDTokenLifetime() time.Duration {
return c.defaultIdTokenLifetime //PLANNED: impl from real client
return c.client.IDTokenLifetime
}
func (c *Client) AccessTokenType() op.AccessTokenType {
return accessTokenTypeToOIDC(c.app.OIDCConfig.AccessTokenType)
return accessTokenTypeToOIDC(c.client.AccessTokenType)
}
func (c *Client) IsScopeAllowed(scope string) bool {
@@ -127,20 +125,15 @@ func (c *Client) IsScopeAllowed(scope string) bool {
if scope == ScopeProjectsRoles {
return true
}
for _, allowedScope := range c.allowedScopes {
if scope == allowedScope {
return true
}
}
return false
return slices.Contains(c.allowedScopes, scope)
}
func (c *Client) ClockSkew() time.Duration {
return c.app.OIDCConfig.ClockSkew
return c.client.ClockSkew
}
func (c *Client) IDTokenUserinfoClaimsAssertion() bool {
return c.app.OIDCConfig.AssertIDTokenUserinfo
return c.client.IDTokenUserinfoAssertion
}
func accessTokenTypeToOIDC(tokenType domain.OIDCTokenType) op.AccessTokenType {
@@ -229,3 +222,14 @@ func removeScopeWithPrefix(scopes []string, scopePrefix ...string) []string {
}
return newScopeList
}
func clientIDFromCredentials(cc *op.ClientCredentials) (clientID string, assertion bool, err error) {
if cc.ClientAssertion != "" {
claims := new(oidc.JWTTokenRequest)
if _, err := oidc.ParseToken(cc.ClientAssertion, claims); err != nil {
return "", false, oidc.ErrInvalidClient().WithParent(err)
}
return claims.Issuer, true, nil
}
return cc.ClientID, false, nil
}

View File

@@ -1,10 +1,15 @@
package oidc
import (
"context"
"time"
"github.com/zitadel/oidc/v3/pkg/oidc"
"github.com/zitadel/oidc/v3/pkg/op"
"github.com/zitadel/zitadel/internal/crypto"
"github.com/zitadel/zitadel/internal/errors"
"github.com/zitadel/zitadel/internal/query"
)
type clientCredentialsRequest struct {
@@ -28,15 +33,42 @@ func (c *clientCredentialsRequest) GetScopes() []string {
return c.scopes
}
func (s *Server) clientCredentialsAuth(ctx context.Context, clientID, clientSecret string) (op.Client, error) {
searchQuery, err := query.NewUserLoginNamesSearchQuery(clientID)
if err != nil {
return nil, err
}
user, err := s.query.GetUser(ctx, false, searchQuery)
if errors.IsNotFound(err) {
return nil, oidc.ErrInvalidClient().WithParent(err).WithDescription("client not found")
}
if err != nil {
return nil, err // defaults to server error
}
if user.Machine == nil || user.Machine.Secret == nil {
return nil, errors.ThrowPreconditionFailed(nil, "OIDC-pieP8", "Errors.User.Machine.Secret.NotExisting")
}
if err = crypto.CompareHash(user.Machine.Secret, []byte(clientSecret), s.hashAlg); err != nil {
s.command.MachineSecretCheckFailed(ctx, user.ID, user.ResourceOwner)
return nil, errors.ThrowInvalidArgument(err, "OIDC-VoXo6", "Errors.User.Machine.Secret.Invalid")
}
s.command.MachineSecretCheckSucceeded(ctx, user.ID, user.ResourceOwner)
return &clientCredentialsClient{
id: clientID,
user: user,
}, nil
}
type clientCredentialsClient struct {
id string
tokenType op.AccessTokenType
id string
user *query.User
}
// AccessTokenType returns the AccessTokenType for the token to be created because of the client credentials request
// machine users currently only have opaque tokens ([op.AccessTokenTypeBearer])
func (c *clientCredentialsClient) AccessTokenType() op.AccessTokenType {
return c.tokenType
return accessTokenTypeToOIDC(c.user.Machine.AccessTokenType)
}
// GetID returns the client_id (username of the machine user) for the token to be created because of the client credentials request

View File

@@ -4,20 +4,26 @@ package oidc_test
import (
"context"
"fmt"
"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 TestOPStorage_SetUserinfoFromToken(t *testing.T) {
@@ -142,3 +148,245 @@ func assertIntrospection(
assert.NotEmpty(t, introspection.Claims[oidc_api.ClaimResourceOwner+"name"])
assert.NotEmpty(t, introspection.Claims[oidc_api.ClaimResourceOwner+"primary_domain"])
}
// 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())
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) {
fmt.Printf("\n\n%s\n\n", tt.client.keyData)
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, 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)
})
}
}

View File

@@ -11,7 +11,7 @@ import (
"github.com/zitadel/oidc/v3/pkg/op"
"github.com/zitadel/zitadel/internal/crypto"
errz "github.com/zitadel/zitadel/internal/errors"
zerrors "github.com/zitadel/zitadel/internal/errors"
"github.com/zitadel/zitadel/internal/query"
"github.com/zitadel/zitadel/internal/telemetry/tracing"
)
@@ -31,14 +31,14 @@ func (s *Server) Introspect(ctx context.Context, r *op.Request[op.IntrospectionR
ctx, cancel := context.WithCancel(ctx)
defer cancel()
clientChan := make(chan *instrospectionClientResult)
go s.instrospectionClientAuth(ctx, r.Data.ClientCredentials, clientChan)
clientChan := make(chan *introspectionClientResult)
go s.introspectionClientAuth(ctx, r.Data.ClientCredentials, clientChan)
tokenChan := make(chan *introspectionTokenResult)
go s.introspectionToken(ctx, r.Data.Token, tokenChan)
var (
client *instrospectionClientResult
client *introspectionClientResult
token *introspectionTokenResult
)
@@ -116,13 +116,13 @@ func (s *Server) Introspect(ctx context.Context, r *op.Request[op.IntrospectionR
return op.NewResponse(introspectionResp), nil
}
type instrospectionClientResult struct {
type introspectionClientResult struct {
clientID string
projectID string
err error
}
func (s *Server) instrospectionClientAuth(ctx context.Context, cc *op.ClientCredentials, rc chan<- *instrospectionClientResult) {
func (s *Server) introspectionClientAuth(ctx context.Context, cc *op.ClientCredentials, rc chan<- *introspectionClientResult) {
ctx, span := tracing.NewSpan(ctx)
clientID, projectID, err := func() (string, string, error) {
@@ -147,7 +147,7 @@ func (s *Server) instrospectionClientAuth(ctx context.Context, cc *op.ClientCred
span.EndWithError(err)
rc <- &instrospectionClientResult{
rc <- &introspectionClientResult{
clientID: clientID,
projectID: projectID,
err: err,
@@ -157,15 +157,11 @@ func (s *Server) instrospectionClientAuth(ctx context.Context, cc *op.ClientCred
// clientFromCredentials parses the client ID early,
// and makes a single query for the client for either auth methods.
func (s *Server) clientFromCredentials(ctx context.Context, cc *op.ClientCredentials) (client *query.IntrospectionClient, err error) {
if cc.ClientAssertion != "" {
claims := new(oidc.JWTTokenRequest)
if _, err := oidc.ParseToken(cc.ClientAssertion, claims); err != nil {
return nil, oidc.ErrUnauthorizedClient().WithParent(err)
}
client, err = s.query.GetIntrospectionClientByID(ctx, claims.Issuer, true)
} else {
client, err = s.query.GetIntrospectionClientByID(ctx, cc.ClientID, false)
clientID, assertion, err := clientIDFromCredentials(cc)
if err != nil {
return nil, err
}
client, err = s.query.GetIntrospectionClientByID(ctx, clientID, assertion)
if errors.Is(err, sql.ErrNoRows) {
return nil, oidc.ErrUnauthorizedClient().WithParent(err)
}
@@ -196,5 +192,5 @@ func validateIntrospectionAudience(audience []string, clientID, projectID string
return nil
}
return errz.ThrowPermissionDenied(nil, "OIDC-sdg3G", "token is not valid for this client")
return zerrors.ThrowPermissionDenied(nil, "OIDC-sdg3G", "token is not valid for this client")
}

View File

@@ -31,9 +31,9 @@ var (
)
const (
redirectURI = "oidcintegrationtest://callback"
redirectURI = "https://callback"
redirectURIImplicit = "http://localhost:9999/callback"
logoutRedirectURI = "oidcintegrationtest://logged-out"
logoutRedirectURI = "https://logged-out"
zitadelAudienceScope = domain.ProjectIDScope + domain.ProjectIDScopeZITADEL + domain.AudSuffix
)

View File

@@ -128,6 +128,9 @@ func NewServer(
query: query,
command: command,
keySet: newKeySet(context.TODO(), time.Hour, query.GetActivePublicKeyByID),
defaultLoginURL: fmt.Sprintf("%s%s?%s=", login.HandlerPrefix, login.EndpointLogin, login.QueryAuthRequestID),
defaultLoginURLV2: config.DefaultLoginURLV2,
defaultLogoutURLV2: config.DefaultLogoutURLV2,
fallbackLogger: fallbackLogger,
hashAlg: crypto.NewBCrypt(10), // as we are only verifying in oidc, the cost is already part of the hash string and the config here is irrelevant.
signingKeyAlgorithm: config.SigningKeyAlgorithm,

View File

@@ -27,6 +27,10 @@ type Server struct {
command *command.Commands
keySet *keySetCache
defaultLoginURL string
defaultLoginURLV2 string
defaultLogoutURLV2 string
fallbackLogger *slog.Logger
hashAlg crypto.HashAlgorithm
signingKeyAlgorithm string
@@ -143,13 +147,6 @@ func (s *Server) DeviceAuthorization(ctx context.Context, r *op.ClientRequest[oi
return s.LegacyServer.DeviceAuthorization(ctx, r)
}
func (s *Server) VerifyClient(ctx context.Context, r *op.Request[op.ClientCredentials]) (_ op.Client, err error) {
ctx, span := tracing.NewSpan(ctx)
defer func() { span.EndWithError(err) }()
return s.LegacyServer.VerifyClient(ctx, r)
}
func (s *Server) CodeExchange(ctx context.Context, r *op.ClientRequest[oidc.AccessTokenRequest]) (_ *op.Response, err error) {
ctx, span := tracing.NewSpan(ctx)
defer func() { span.EndWithError(err) }()