mirror of
https://github.com/zitadel/zitadel.git
synced 2025-08-12 03:57:32 +00:00
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:
@@ -72,7 +72,7 @@ func MachineToPb(view *query.Machine) *user_pb.Machine {
|
||||
return &user_pb.Machine{
|
||||
Name: view.Name,
|
||||
Description: view.Description,
|
||||
HasSecret: view.HasSecret,
|
||||
HasSecret: view.Secret != nil,
|
||||
AccessTokenType: AccessTokenTypeToPb(view.AccessTokenType),
|
||||
}
|
||||
}
|
||||
|
@@ -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 {
|
||||
|
@@ -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) {
|
||||
|
@@ -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
|
||||
}
|
||||
|
@@ -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
|
||||
}
|
||||
|
@@ -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
|
||||
|
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
@@ -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")
|
||||
}
|
||||
|
@@ -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
|
||||
)
|
||||
|
||||
|
@@ -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,
|
||||
|
@@ -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) }()
|
||||
|
Reference in New Issue
Block a user