chore(tests): use a coverage server binary (#8407)

# Which Problems Are Solved

Use a single server instance for API integration tests. This optimizes
the time taken for the integration test pipeline,
because it allows running tests on multiple packages in parallel. Also,
it saves time by not start and stopping a zitadel server for every
package.

# How the Problems Are Solved

- Build a binary with `go build -race -cover ....`
- Integration tests only construct clients. The server remains running
in the background.
- The integration package and tested packages now fully utilize the API.
No more direct database access trough `query` and `command` packages.
- Use Makefile recipes to setup, start and stop the server in the
background.
- The binary has the race detector enabled
- Init and setup jobs are configured to halt immediately on race
condition
- Because the server runs in the background, races are only logged. When
the server is stopped and race logs exist, the Makefile recipe will
throw an error and print the logs.
- Makefile recipes include logic to print logs and convert coverage
reports after the server is stopped.
- Some tests need a downstream HTTP server to make requests, like quota
and milestones. A new `integration/sink` package creates an HTTP server
and uses websockets to forward HTTP request back to the test packages.
The package API uses Go channels for abstraction and easy usage.

# Additional Changes

- Integration test files already used the `//go:build integration`
directive. In order to properly split integration from unit tests,
integration test files need to be in a `integration_test` subdirectory
of their package.
- `UseIsolatedInstance` used to overwrite the `Tester.Client` for each
instance. Now a `Instance` object is returned with a gRPC client that is
connected to the isolated instance's hostname.
- The `Tester` type is now `Instance`. The object is created for the
first instance, used by default in any test. Isolated instances are also
`Instance` objects and therefore benefit from the same methods and
values. The first instance and any other us capable of creating an
isolated instance over the system API.
- All test packages run in an Isolated instance by calling
`NewInstance()`
- Individual tests that use an isolated instance use `t.Parallel()`

# Additional Context

- Closes #6684
- https://go.dev/doc/articles/race_detector
- https://go.dev/doc/build-cover

---------

Co-authored-by: Stefan Benz <46600784+stebenz@users.noreply.github.com>
This commit is contained in:
Tim Möhlmann
2024-09-06 15:47:57 +03:00
committed by GitHub
parent b522588d98
commit d2e0ac07f1
115 changed files with 3632 additions and 3348 deletions

View File

@@ -0,0 +1,571 @@
//go:build integration
package oidc_test
import (
"context"
"net/url"
"testing"
"time"
"github.com/muhlemmer/gu"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/zitadel/oidc/v3/pkg/client/rp"
"github.com/zitadel/oidc/v3/pkg/oidc"
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/v2"
"github.com/zitadel/zitadel/pkg/grpc/session/v2"
)
var (
armPasskey = []string{oidc_api.UserPresence, oidc_api.MFA}
armPassword = []string{oidc_api.PWD}
)
func TestOPStorage_CreateAuthRequest(t *testing.T) {
t.Parallel()
clientID, _ := createClient(t, Instance)
id := createAuthRequest(t, Instance, clientID, redirectURI)
require.Contains(t, id, command.IDPrefixV2)
}
func TestOPStorage_CreateAccessToken_code(t *testing.T) {
t.Parallel()
clientID, _ := createClient(t, Instance)
authRequestID := createAuthRequest(t, Instance, clientID, redirectURI)
sessionID, sessionToken, startTime, changeTime := Instance.CreateVerifiedWebAuthNSession(t, CTXLOGIN, User.GetUserId())
linkResp, err := Instance.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)
// test code exchange
code := assertCodeResponse(t, linkResp.GetCallbackUrl())
tokens, err := exchangeTokens(t, Instance, clientID, code, redirectURI)
require.NoError(t, err)
assertTokens(t, tokens, false)
assertIDTokenClaims(t, tokens.IDTokenClaims, User.GetUserId(), armPasskey, startTime, changeTime, sessionID)
// callback on a succeeded request must fail
linkResp, err = Instance.Client.OIDCv2.CreateCallback(CTXLOGIN, &oidc_pb.CreateCallbackRequest{
AuthRequestId: authRequestID,
CallbackKind: &oidc_pb.CreateCallbackRequest_Session{
Session: &oidc_pb.Session{
SessionId: sessionID,
SessionToken: sessionToken,
},
},
})
require.Error(t, err)
// exchange with a used code must fail
_, err = exchangeTokens(t, Instance, clientID, code, redirectURI)
require.Error(t, err)
}
func TestOPStorage_CreateAccessToken_implicit(t *testing.T) {
t.Parallel()
clientID := createImplicitClient(t)
authRequestID := createAuthRequestImplicit(t, clientID, redirectURIImplicit)
sessionID, sessionToken, startTime, changeTime := Instance.CreateVerifiedWebAuthNSession(t, CTXLOGIN, User.GetUserId())
linkResp, err := Instance.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)
// test implicit callback
callback, err := url.Parse(linkResp.GetCallbackUrl())
require.NoError(t, err)
values, err := url.ParseQuery(callback.Fragment)
require.NoError(t, err)
accessToken := values.Get("access_token")
idToken := values.Get("id_token")
refreshToken := values.Get("refresh_token")
assert.NotEmpty(t, accessToken)
assert.NotEmpty(t, idToken)
assert.Empty(t, refreshToken)
assert.NotEmpty(t, values.Get("expires_in"))
assert.Equal(t, oidc.BearerToken, values.Get("token_type"))
assert.Equal(t, "state", values.Get("state"))
// check id_token / claims
provider, err := Instance.CreateRelyingParty(CTX, clientID, redirectURIImplicit)
require.NoError(t, err)
claims, err := rp.VerifyTokens[*oidc.IDTokenClaims](context.Background(), accessToken, idToken, provider.IDTokenVerifier())
require.NoError(t, err)
assertIDTokenClaims(t, claims, User.GetUserId(), armPasskey, startTime, changeTime, sessionID)
// callback on a succeeded request must fail
linkResp, err = Instance.Client.OIDCv2.CreateCallback(CTXLOGIN, &oidc_pb.CreateCallbackRequest{
AuthRequestId: authRequestID,
CallbackKind: &oidc_pb.CreateCallbackRequest_Session{
Session: &oidc_pb.Session{
SessionId: sessionID,
SessionToken: sessionToken,
},
},
})
require.Error(t, err)
}
func TestOPStorage_CreateAccessAndRefreshTokens_code(t *testing.T) {
t.Parallel()
clientID, _ := createClient(t, Instance)
authRequestID := createAuthRequest(t, Instance, clientID, redirectURI, oidc.ScopeOpenID, oidc.ScopeOfflineAccess)
sessionID, sessionToken, startTime, changeTime := Instance.CreateVerifiedWebAuthNSession(t, CTXLOGIN, User.GetUserId())
linkResp, err := Instance.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)
// test code exchange (expect refresh token to be returned)
code := assertCodeResponse(t, linkResp.GetCallbackUrl())
tokens, err := exchangeTokens(t, Instance, clientID, code, redirectURI)
require.NoError(t, err)
assertTokens(t, tokens, true)
assertIDTokenClaims(t, tokens.IDTokenClaims, User.GetUserId(), armPasskey, startTime, changeTime, sessionID)
}
func TestOPStorage_CreateAccessAndRefreshTokens_refresh(t *testing.T) {
t.Parallel()
clientID, _ := createClient(t, Instance)
provider, err := Instance.CreateRelyingParty(CTX, clientID, redirectURI)
require.NoError(t, err)
authRequestID := createAuthRequest(t, Instance, clientID, redirectURI, oidc.ScopeOpenID, oidc.ScopeOfflineAccess)
sessionID, sessionToken, startTime, changeTime := Instance.CreateVerifiedWebAuthNSession(t, CTXLOGIN, User.GetUserId())
linkResp, err := Instance.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, Instance, clientID, code, redirectURI)
require.NoError(t, err)
assertTokens(t, tokens, true)
assertIDTokenClaims(t, tokens.IDTokenClaims, User.GetUserId(), armPasskey, startTime, changeTime, sessionID)
// test actual refresh grant
newTokens, err := refreshTokens(t, clientID, tokens.RefreshToken)
require.NoError(t, err)
assertTokens(t, newTokens, true)
// auth time must still be the initial
assertIDTokenClaims(t, newTokens.IDTokenClaims, User.GetUserId(), armPasskey, startTime, changeTime, sessionID)
// refresh with an old refresh_token must fail
_, err = rp.RefreshTokens[*oidc.IDTokenClaims](CTX, provider, tokens.RefreshToken, "", "")
require.Error(t, err)
}
func TestOPStorage_RevokeToken_access_token(t *testing.T) {
t.Parallel()
clientID, _ := createClient(t, Instance)
provider, err := Instance.CreateRelyingParty(CTX, clientID, redirectURI)
require.NoError(t, err)
authRequestID := createAuthRequest(t, Instance, clientID, redirectURI, oidc.ScopeOpenID, oidc.ScopeOfflineAccess)
sessionID, sessionToken, startTime, changeTime := Instance.CreateVerifiedWebAuthNSession(t, CTXLOGIN, User.GetUserId())
linkResp, err := Instance.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, Instance, clientID, code, redirectURI)
require.NoError(t, err)
assertTokens(t, tokens, true)
assertIDTokenClaims(t, tokens.IDTokenClaims, User.GetUserId(), armPasskey, startTime, changeTime, sessionID)
// revoke access token
err = rp.RevokeToken(CTX, provider, tokens.AccessToken, "access_token")
require.NoError(t, err)
// userinfo must fail
_, err = rp.Userinfo[*oidc.UserInfo](CTX, tokens.AccessToken, tokens.TokenType, tokens.IDTokenClaims.Subject, provider)
require.Error(t, err)
// refresh grant must still work
_, err = refreshTokens(t, clientID, tokens.RefreshToken)
require.NoError(t, err)
// revocation with the same access token must not fail (with or without hint)
err = rp.RevokeToken(CTX, provider, tokens.AccessToken, "access_token")
require.NoError(t, err)
err = rp.RevokeToken(CTX, provider, tokens.AccessToken, "")
require.NoError(t, err)
}
func TestOPStorage_RevokeToken_access_token_invalid_token_hint_type(t *testing.T) {
t.Parallel()
clientID, _ := createClient(t, Instance)
provider, err := Instance.CreateRelyingParty(CTX, clientID, redirectURI)
require.NoError(t, err)
authRequestID := createAuthRequest(t, Instance, clientID, redirectURI, oidc.ScopeOpenID, oidc.ScopeOfflineAccess)
sessionID, sessionToken, startTime, changeTime := Instance.CreateVerifiedWebAuthNSession(t, CTXLOGIN, User.GetUserId())
linkResp, err := Instance.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, Instance, clientID, code, redirectURI)
require.NoError(t, err)
assertTokens(t, tokens, true)
assertIDTokenClaims(t, tokens.IDTokenClaims, User.GetUserId(), armPasskey, startTime, changeTime, sessionID)
// revoke access token
err = rp.RevokeToken(CTX, provider, tokens.AccessToken, "refresh_token")
require.NoError(t, err)
// userinfo must fail
_, err = rp.Userinfo[*oidc.UserInfo](CTX, tokens.AccessToken, tokens.TokenType, tokens.IDTokenClaims.Subject, provider)
require.Error(t, err)
// refresh grant must still work
_, err = refreshTokens(t, clientID, tokens.RefreshToken)
require.NoError(t, err)
}
func TestOPStorage_RevokeToken_refresh_token(t *testing.T) {
t.Parallel()
clientID, _ := createClient(t, Instance)
provider, err := Instance.CreateRelyingParty(CTX, clientID, redirectURI)
require.NoError(t, err)
authRequestID := createAuthRequest(t, Instance, clientID, redirectURI, oidc.ScopeOpenID, oidc.ScopeOfflineAccess)
sessionID, sessionToken, startTime, changeTime := Instance.CreateVerifiedWebAuthNSession(t, CTXLOGIN, User.GetUserId())
linkResp, err := Instance.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, Instance, clientID, code, redirectURI)
require.NoError(t, err)
assertTokens(t, tokens, true)
assertIDTokenClaims(t, tokens.IDTokenClaims, User.GetUserId(), armPasskey, startTime, changeTime, sessionID)
// revoke refresh token -> invalidates also access token
err = rp.RevokeToken(CTX, provider, tokens.RefreshToken, "refresh_token")
require.NoError(t, err)
// userinfo must fail
_, err = rp.Userinfo[*oidc.UserInfo](CTX, tokens.AccessToken, tokens.TokenType, tokens.IDTokenClaims.Subject, provider)
require.Error(t, err)
// refresh must fail
_, err = refreshTokens(t, clientID, tokens.RefreshToken)
require.Error(t, err)
// revocation with the same refresh token must not fail (with or without hint)
err = rp.RevokeToken(CTX, provider, tokens.RefreshToken, "refresh_token")
require.NoError(t, err)
err = rp.RevokeToken(CTX, provider, tokens.RefreshToken, "")
require.NoError(t, err)
}
func TestOPStorage_RevokeToken_refresh_token_invalid_token_type_hint(t *testing.T) {
t.Parallel()
clientID, _ := createClient(t, Instance)
provider, err := Instance.CreateRelyingParty(CTX, clientID, redirectURI)
require.NoError(t, err)
authRequestID := createAuthRequest(t, Instance, clientID, redirectURI, oidc.ScopeOpenID, oidc.ScopeOfflineAccess)
sessionID, sessionToken, startTime, changeTime := Instance.CreateVerifiedWebAuthNSession(t, CTXLOGIN, User.GetUserId())
linkResp, err := Instance.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, Instance, clientID, code, redirectURI)
require.NoError(t, err)
assertTokens(t, tokens, true)
assertIDTokenClaims(t, tokens.IDTokenClaims, User.GetUserId(), armPasskey, startTime, changeTime, sessionID)
// revoke refresh token even with a wrong hint
err = rp.RevokeToken(CTX, provider, tokens.RefreshToken, "access_token")
require.NoError(t, err)
// userinfo must fail
_, err = rp.Userinfo[*oidc.UserInfo](CTX, tokens.AccessToken, tokens.TokenType, tokens.IDTokenClaims.Subject, provider)
require.Error(t, err)
// refresh must fail
_, err = refreshTokens(t, clientID, tokens.RefreshToken)
require.Error(t, err)
}
func TestOPStorage_RevokeToken_invalid_client(t *testing.T) {
t.Parallel()
clientID, _ := createClient(t, Instance)
authRequestID := createAuthRequest(t, Instance, clientID, redirectURI, oidc.ScopeOpenID, oidc.ScopeOfflineAccess)
sessionID, sessionToken, startTime, changeTime := Instance.CreateVerifiedWebAuthNSession(t, CTXLOGIN, User.GetUserId())
linkResp, err := Instance.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, Instance, clientID, code, redirectURI)
require.NoError(t, err)
assertTokens(t, tokens, true)
assertIDTokenClaims(t, tokens.IDTokenClaims, User.GetUserId(), armPasskey, startTime, changeTime, sessionID)
// simulate second client (not part of the audience) trying to revoke the token
otherClientID, _ := createClient(t, Instance)
provider, err := Instance.CreateRelyingParty(CTX, otherClientID, redirectURI)
require.NoError(t, err)
err = rp.RevokeToken(CTX, provider, tokens.AccessToken, "")
require.Error(t, err)
}
func TestOPStorage_TerminateSession(t *testing.T) {
t.Parallel()
clientID, _ := createClient(t, Instance)
provider, err := Instance.CreateRelyingParty(CTX, clientID, redirectURI)
require.NoError(t, err)
authRequestID := createAuthRequest(t, Instance, clientID, redirectURI)
sessionID, sessionToken, startTime, changeTime := Instance.CreateVerifiedWebAuthNSession(t, CTXLOGIN, User.GetUserId())
linkResp, err := Instance.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)
// test code exchange
code := assertCodeResponse(t, linkResp.GetCallbackUrl())
tokens, err := exchangeTokens(t, Instance, clientID, code, redirectURI)
require.NoError(t, err)
assertTokens(t, tokens, false)
assertIDTokenClaims(t, tokens.IDTokenClaims, User.GetUserId(), armPasskey, startTime, changeTime, sessionID)
// userinfo must not fail
_, err = rp.Userinfo[*oidc.UserInfo](CTX, tokens.AccessToken, tokens.TokenType, tokens.IDTokenClaims.Subject, provider)
require.NoError(t, err)
postLogoutRedirect, err := rp.EndSession(CTX, provider, tokens.IDToken, logoutRedirectURI, "state")
require.NoError(t, err)
assert.Equal(t, logoutRedirectURI+"?state=state", postLogoutRedirect.String())
// userinfo must fail
_, err = rp.Userinfo[*oidc.UserInfo](CTX, tokens.AccessToken, tokens.TokenType, tokens.IDTokenClaims.Subject, provider)
require.Error(t, err)
}
func TestOPStorage_TerminateSession_refresh_grant(t *testing.T) {
t.Parallel()
clientID, _ := createClient(t, Instance)
provider, err := Instance.CreateRelyingParty(CTX, clientID, redirectURI)
require.NoError(t, err)
authRequestID := createAuthRequest(t, Instance, clientID, redirectURI, oidc.ScopeOpenID, oidc.ScopeOfflineAccess)
sessionID, sessionToken, startTime, changeTime := Instance.CreateVerifiedWebAuthNSession(t, CTXLOGIN, User.GetUserId())
linkResp, err := Instance.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)
// test code exchange
code := assertCodeResponse(t, linkResp.GetCallbackUrl())
tokens, err := exchangeTokens(t, Instance, clientID, code, redirectURI)
require.NoError(t, err)
assertTokens(t, tokens, true)
assertIDTokenClaims(t, tokens.IDTokenClaims, User.GetUserId(), armPasskey, startTime, changeTime, sessionID)
// userinfo must not fail
_, err = rp.Userinfo[*oidc.UserInfo](CTX, tokens.AccessToken, tokens.TokenType, tokens.IDTokenClaims.Subject, provider)
require.NoError(t, err)
postLogoutRedirect, err := rp.EndSession(CTX, provider, tokens.IDToken, logoutRedirectURI, "state")
require.NoError(t, err)
assert.Equal(t, logoutRedirectURI+"?state=state", postLogoutRedirect.String())
// userinfo must fail
_, err = rp.Userinfo[*oidc.UserInfo](CTX, tokens.AccessToken, tokens.TokenType, tokens.IDTokenClaims.Subject, provider)
require.Error(t, err)
refreshedTokens, err := refreshTokens(t, clientID, tokens.RefreshToken)
require.NoError(t, err)
// userinfo must not fail
_, err = rp.Userinfo[*oidc.UserInfo](CTX, refreshedTokens.AccessToken, refreshedTokens.TokenType, refreshedTokens.IDTokenClaims.Subject, provider)
require.NoError(t, err)
}
func TestOPStorage_TerminateSession_empty_id_token_hint(t *testing.T) {
t.Parallel()
clientID, _ := createClient(t, Instance)
provider, err := Instance.CreateRelyingParty(CTX, clientID, redirectURI)
require.NoError(t, err)
authRequestID := createAuthRequest(t, Instance, clientID, redirectURI)
sessionID, sessionToken, startTime, changeTime := Instance.CreateVerifiedWebAuthNSession(t, CTXLOGIN, User.GetUserId())
linkResp, err := Instance.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)
// test code exchange
code := assertCodeResponse(t, linkResp.GetCallbackUrl())
tokens, err := exchangeTokens(t, Instance, clientID, code, redirectURI)
require.NoError(t, err)
assertTokens(t, tokens, false)
assertIDTokenClaims(t, tokens.IDTokenClaims, User.GetUserId(), armPasskey, startTime, changeTime, sessionID)
postLogoutRedirect, err := rp.EndSession(CTX, provider, "", logoutRedirectURI, "state")
require.NoError(t, err)
assert.Equal(t, http_utils.BuildOrigin(Instance.Host(), Instance.Config.Secure)+Instance.Config.LogoutURLV2+logoutRedirectURI+"?state=state", postLogoutRedirect.String())
// userinfo must not fail until login UI terminated session
_, err = rp.Userinfo[*oidc.UserInfo](CTX, tokens.AccessToken, tokens.TokenType, tokens.IDTokenClaims.Subject, provider)
require.NoError(t, err)
// simulate termination by login UI
_, err = Instance.Client.SessionV2.DeleteSession(CTXLOGIN, &session.DeleteSessionRequest{
SessionId: sessionID,
SessionToken: gu.Ptr(sessionToken),
})
require.NoError(t, err)
// userinfo must fail
_, err = rp.Userinfo[*oidc.UserInfo](CTX, tokens.AccessToken, tokens.TokenType, tokens.IDTokenClaims.Subject, provider)
require.Error(t, err)
}
func exchangeTokens(t testing.TB, instance *integration.Instance, clientID, code, redirectURI string) (*oidc.Tokens[*oidc.IDTokenClaims], error) {
provider, err := instance.CreateRelyingParty(CTX, clientID, redirectURI)
require.NoError(t, err)
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) {
provider, err := Instance.CreateRelyingParty(CTX, clientID, redirectURI)
require.NoError(t, err)
return rp.RefreshTokens[*oidc.IDTokenClaims](CTX, provider, refreshToken, "", "")
}
func assertCodeResponse(t *testing.T, callback string) string {
callbackURL, err := url.Parse(callback)
require.NoError(t, err)
code := callbackURL.Query().Get("code")
require.NotEmpty(t, code)
assert.Equal(t, "state", callbackURL.Query().Get("state"))
return code
}
func assertTokens(t *testing.T, tokens *oidc.Tokens[*oidc.IDTokenClaims], requireRefreshToken bool) {
assert.NotEmpty(t, tokens.AccessToken)
assert.NotEmpty(t, tokens.IDToken)
if requireRefreshToken {
assert.NotEmpty(t, tokens.RefreshToken)
} else {
assert.Empty(t, tokens.RefreshToken)
}
// since we test implicit flow directly, we can check that any token response must not
// return a `state` in the response
assert.Empty(t, tokens.Extra("state"))
}
func assertIDTokenClaims(t *testing.T, claims *oidc.IDTokenClaims, userID string, arm []string, sessionStart, sessionChange time.Time, sessionID string) {
assert.Equal(t, userID, claims.Subject)
assert.Equal(t, arm, claims.AuthenticationMethodsReferences)
assertOIDCTimeRange(t, claims.AuthTime, sessionStart, sessionChange)
assert.Equal(t, sessionID, claims.SessionID)
assert.Empty(t, claims.Name)
assert.Empty(t, claims.GivenName)
assert.Empty(t, claims.FamilyName)
assert.Empty(t, claims.PreferredUsername)
}

View File

@@ -0,0 +1,361 @@
//go:build integration
package oidc_test
import (
"context"
"testing"
"time"
"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/v2"
)
func TestServer_Introspect(t *testing.T) {
t.Parallel()
project, err := Instance.CreateProject(CTX)
require.NoError(t, err)
app, err := Instance.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 := Instance.CreateAPIClientJWT(CTX, project.GetId())
require.NoError(t, err)
keyResp, err := Instance.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 := Instance.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 := Instance.CreateAPIClientBasic(CTX, project.GetId())
require.NoError(t, err)
resourceServer, err := Instance.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 := Instance.CreateAPIClientBasic(CTX, project.GetId())
require.NoError(t, err)
resourceServer, err := Instance.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 := Instance.CreateAPIClientBasic(CTX, project.GetId())
require.NoError(t, err)
resourceServer, err := Instance.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 := Instance.CreateAPIClientJWT(CTX, project.GetId())
require.NoError(t, err)
resourceServer, err := Instance.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, Instance, app.GetClientId(), redirectURI, scope...)
sessionID, sessionToken, startTime, changeTime := Instance.CreateVerifiedWebAuthNSession(t, CTXLOGIN, User.GetUserId())
linkResp, err := Instance.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, Instance, app.GetClientId(), code, redirectURI)
require.NoError(t, err)
assertTokens(t, tokens, true)
assertIDTokenClaims(t, tokens.IDTokenClaims, User.GetUserId(), armPasskey, startTime, changeTime, sessionID)
// 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,
Instance.OIDCIssuer(), app.GetClientId(),
scope, wantAudience,
tokens.Expiry, tokens.Expiry.Add(-12*time.Hour))
})
}
}
func TestServer_Introspect_invalid_auth_invalid_token(t *testing.T) {
t.Parallel()
// ensure that when an invalid authentication and token is sent, the authentication error is returned
// https://github.com/zitadel/zitadel/pull/8133
resourceServer, err := Instance.CreateResourceServerClientCredentials(CTX, "xxxxx", "xxxxx")
require.NoError(t, err)
_, err = rs.Introspect[*oidc.IntrospectionResponse](context.Background(), resourceServer, "xxxxx")
require.Error(t, err)
}
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) {
t.Parallel()
sessionID, sessionToken, startTime, changeTime := Instance.CreateVerifiedWebAuthNSession(t, CTXLOGIN, User.GetUserId())
project, err := Instance.CreateProject(CTX)
require.NoError(t, err)
inactiveClient, err := Instance.CreateOIDCInactivateClient(CTX, redirectURI, logoutRedirectURI, project.GetId())
require.NoError(t, err)
nativeClient, err := Instance.CreateOIDCNativeClient(CTX, redirectURI, logoutRedirectURI, project.GetId(), false)
require.NoError(t, err)
basicWebClient, err := Instance.CreateOIDCWebClientBasic(CTX, redirectURI, logoutRedirectURI, project.GetId())
require.NoError(t, err)
jwtWebClient, keyData, err := Instance.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 := Instance.CreateOIDCAuthRequest(CTX, tt.client.authReqClientID, Instance.Users.Get(integration.UserTypeLogin).ID, redirectURI, oidc.ScopeOpenID)
require.NoError(t, err)
linkResp, err := Instance.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, Instance.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, sessionID)
})
}
}
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
}

View File

@@ -0,0 +1,123 @@
//go:build integration
package oidc_test
import (
"crypto/rsa"
"encoding/json"
"net/http"
"testing"
"time"
"github.com/go-jose/go-jose/v4"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/zitadel/oidc/v3/pkg/client"
"github.com/zitadel/oidc/v3/pkg/oidc"
"google.golang.org/protobuf/proto"
http_util "github.com/zitadel/zitadel/internal/api/http"
"github.com/zitadel/zitadel/internal/crypto"
"github.com/zitadel/zitadel/internal/integration"
"github.com/zitadel/zitadel/pkg/grpc/feature/v2"
oidc_pb "github.com/zitadel/zitadel/pkg/grpc/oidc/v2"
)
func TestServer_Keys(t *testing.T) {
t.Parallel()
instance := integration.NewInstance(CTX)
ctxLogin := instance.WithAuthorization(CTX, integration.UserTypeLogin)
clientID, _ := createClient(t, instance)
authRequestID := createAuthRequest(t, instance, clientID, redirectURI, oidc.ScopeOpenID, oidc.ScopeOfflineAccess, zitadelAudienceScope)
instance.RegisterUserPasskey(instance.WithAuthorization(CTX, integration.UserTypeOrgOwner), instance.AdminUserID)
sessionID, sessionToken, _, _ := instance.CreateVerifiedWebAuthNSession(t, ctxLogin, instance.AdminUserID)
linkResp, err := instance.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 so we are sure there is 1 legacy key pair.
code := assertCodeResponse(t, linkResp.GetCallbackUrl())
_, err = exchangeTokens(t, instance, clientID, code, redirectURI)
require.NoError(t, err)
issuer := http_util.BuildHTTP(instance.Domain, instance.Config.Port, instance.Config.Secure)
discovery, err := client.Discover(CTX, issuer, http.DefaultClient)
require.NoError(t, err)
tests := []struct {
name string
webKeyFeature bool
wantLen int
}{
{
name: "legacy only",
webKeyFeature: false,
wantLen: 1,
},
{
name: "webkeys with legacy",
webKeyFeature: true,
wantLen: 3, // 1 legacy + 2 created by enabling feature flag
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ensureWebKeyFeature(t, instance, tt.webKeyFeature)
assert.EventuallyWithT(t, func(ttt *assert.CollectT) {
resp, err := http.Get(discovery.JwksURI)
require.NoError(ttt, err)
require.Equal(ttt, resp.StatusCode, http.StatusOK)
defer resp.Body.Close()
got := new(jose.JSONWebKeySet)
err = json.NewDecoder(resp.Body).Decode(got)
require.NoError(ttt, err)
assert.Len(t, got.Keys, tt.wantLen)
for _, key := range got.Keys {
_, ok := key.Key.(*rsa.PublicKey)
require.True(ttt, ok)
require.NotEmpty(ttt, key.KeyID)
require.Equal(ttt, key.Algorithm, string(jose.RS256))
require.Equal(ttt, key.Use, crypto.KeyUsageSigning.String())
}
cacheControl := resp.Header.Get("cache-control")
if tt.webKeyFeature {
require.Equal(ttt, "max-age=300, must-revalidate", cacheControl)
return
}
require.Equal(ttt, "no-store", cacheControl)
}, time.Minute, time.Second/10)
})
}
}
func ensureWebKeyFeature(t *testing.T, instance *integration.Instance, set bool) {
ctxIam := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner)
_, err := instance.Client.FeatureV2.SetInstanceFeatures(ctxIam, &feature.SetInstanceFeaturesRequest{
WebKey: proto.Bool(set),
})
require.NoError(t, err)
t.Cleanup(func() {
_, err := instance.Client.FeatureV2.SetInstanceFeatures(ctxIam, &feature.SetInstanceFeaturesRequest{
WebKey: proto.Bool(false),
})
require.NoError(t, err)
})
}

View File

@@ -0,0 +1,442 @@
//go:build integration
package oidc_test
import (
"context"
"fmt"
"os"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/zitadel/oidc/v3/pkg/client/rp"
"github.com/zitadel/oidc/v3/pkg/oidc"
"google.golang.org/grpc/metadata"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/integration"
"github.com/zitadel/zitadel/pkg/grpc/auth"
mgmt "github.com/zitadel/zitadel/pkg/grpc/management"
oidc_pb "github.com/zitadel/zitadel/pkg/grpc/oidc/v2"
"github.com/zitadel/zitadel/pkg/grpc/session/v2"
"github.com/zitadel/zitadel/pkg/grpc/user/v2"
)
var (
CTX context.Context
CTXLOGIN context.Context
CTXIAM context.Context
Instance *integration.Instance
User *user.AddHumanUserResponse
)
const (
redirectURI = "https://callback"
redirectURIImplicit = "http://localhost:9999/callback"
logoutRedirectURI = "https://logged-out"
zitadelAudienceScope = domain.ProjectIDScope + domain.ProjectIDScopeZITADEL + domain.AudSuffix
)
func TestMain(m *testing.M) {
os.Exit(func() int {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
defer cancel()
Instance = integration.NewInstance(ctx)
CTX = Instance.WithAuthorization(ctx, integration.UserTypeOrgOwner)
User = Instance.CreateHumanUser(CTX)
Instance.SetUserPassword(CTX, User.GetUserId(), integration.UserPassword, false)
Instance.RegisterUserPasskey(CTX, User.GetUserId())
CTXLOGIN = Instance.WithAuthorization(ctx, integration.UserTypeLogin)
CTXIAM = Instance.WithAuthorization(ctx, integration.UserTypeIAMOwner)
return m.Run()
}())
}
func Test_ZITADEL_API_missing_audience_scope(t *testing.T) {
clientID, _ := createClient(t, Instance)
authRequestID := createAuthRequest(t, Instance, clientID, redirectURI, oidc.ScopeOpenID)
sessionID, sessionToken, startTime, changeTime := Instance.CreateVerifiedWebAuthNSession(t, CTXLOGIN, User.GetUserId())
linkResp, err := Instance.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, Instance, clientID, code, redirectURI)
require.NoError(t, err)
assertTokens(t, tokens, false)
assertIDTokenClaims(t, tokens.IDTokenClaims, User.GetUserId(), armPasskey, startTime, changeTime, sessionID)
ctx := metadata.AppendToOutgoingContext(context.Background(), "Authorization", fmt.Sprintf("%s %s", tokens.TokenType, tokens.AccessToken))
myUserResp, err := Instance.Client.Auth.GetMyUser(ctx, &auth.GetMyUserRequest{})
require.Error(t, err)
require.Nil(t, myUserResp)
}
func Test_ZITADEL_API_missing_authentication(t *testing.T) {
clientID, _ := createClient(t, Instance)
authRequestID := createAuthRequest(t, Instance, clientID, redirectURI, oidc.ScopeOpenID, zitadelAudienceScope)
createResp, err := Instance.Client.SessionV2.CreateSession(CTX, &session.CreateSessionRequest{
Checks: &session.Checks{
User: &session.CheckUser{
Search: &session.CheckUser_UserId{UserId: User.GetUserId()},
},
},
})
require.NoError(t, err)
linkResp, err := Instance.Client.OIDCv2.CreateCallback(CTXLOGIN, &oidc_pb.CreateCallbackRequest{
AuthRequestId: authRequestID,
CallbackKind: &oidc_pb.CreateCallbackRequest_Session{
Session: &oidc_pb.Session{
SessionId: createResp.GetSessionId(),
SessionToken: createResp.GetSessionToken(),
},
},
})
require.NoError(t, err)
// code exchange
code := assertCodeResponse(t, linkResp.GetCallbackUrl())
tokens, err := exchangeTokens(t, Instance, clientID, code, redirectURI)
require.NoError(t, err)
ctx := metadata.AppendToOutgoingContext(context.Background(), "Authorization", fmt.Sprintf("%s %s", tokens.TokenType, tokens.AccessToken))
myUserResp, err := Instance.Client.Auth.GetMyUser(ctx, &auth.GetMyUserRequest{})
require.Error(t, err)
require.Nil(t, myUserResp)
}
func Test_ZITADEL_API_missing_mfa_policy(t *testing.T) {
clientID, _ := createClient(t, Instance)
org := Instance.CreateOrganization(CTXIAM, fmt.Sprintf("ZITADEL_API_MISSING_MFA_%d", time.Now().UnixNano()), fmt.Sprintf("%d@mouse.com", time.Now().UnixNano()))
userID := org.CreatedAdmins[0].GetUserId()
Instance.SetUserPassword(CTXIAM, userID, integration.UserPassword, false)
authRequestID := createAuthRequest(t, Instance, clientID, redirectURI, oidc.ScopeOpenID, zitadelAudienceScope)
sessionID, sessionToken, startTime, changeTime := Instance.CreatePasswordSession(t, CTXLOGIN, userID, integration.UserPassword)
linkResp, err := Instance.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, Instance, clientID, code, redirectURI)
require.NoError(t, err)
assertIDTokenClaims(t, tokens.IDTokenClaims, userID, armPassword, startTime, changeTime, sessionID)
ctx := metadata.AppendToOutgoingContext(context.Background(), "Authorization", fmt.Sprintf("%s %s", tokens.TokenType, tokens.AccessToken))
// pre check if request would succeed
myUserResp, err := Instance.Client.Auth.GetMyUser(ctx, &auth.GetMyUserRequest{})
require.NoError(t, err)
require.Equal(t, userID, myUserResp.GetUser().GetId())
// require MFA
ctxOrg := metadata.AppendToOutgoingContext(CTXIAM, "x-zitadel-orgid", org.GetOrganizationId())
_, err = Instance.Client.Mgmt.AddCustomLoginPolicy(ctxOrg, &mgmt.AddCustomLoginPolicyRequest{
ForceMfa: true,
})
require.NoError(t, err)
// make sure policy is projected
retryDuration := 5 * time.Second
if ctxDeadline, ok := CTX.Deadline(); ok {
retryDuration = time.Until(ctxDeadline)
}
require.EventuallyWithT(t, func(ttt *assert.CollectT) {
got, getErr := Instance.Client.Mgmt.GetLoginPolicy(ctxOrg, &mgmt.GetLoginPolicyRequest{})
assert.NoError(ttt, getErr)
assert.False(ttt, got.GetPolicy().IsDefault)
}, retryDuration, time.Millisecond*100, "timeout waiting for login policy")
// now it must fail
myUserResp, err = Instance.Client.Auth.GetMyUser(ctx, &auth.GetMyUserRequest{})
require.Error(t, err)
require.Nil(t, myUserResp)
}
func Test_ZITADEL_API_success(t *testing.T) {
clientID, _ := createClient(t, Instance)
authRequestID := createAuthRequest(t, Instance, clientID, redirectURI, oidc.ScopeOpenID, zitadelAudienceScope)
sessionID, sessionToken, startTime, changeTime := Instance.CreateVerifiedWebAuthNSession(t, CTXLOGIN, User.GetUserId())
linkResp, err := Instance.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, Instance, clientID, code, redirectURI)
require.NoError(t, err)
assertTokens(t, tokens, false)
assertIDTokenClaims(t, tokens.IDTokenClaims, User.GetUserId(), armPasskey, startTime, changeTime, sessionID)
ctx := metadata.AppendToOutgoingContext(context.Background(), "Authorization", fmt.Sprintf("%s %s", tokens.TokenType, tokens.AccessToken))
myUserResp, err := Instance.Client.Auth.GetMyUser(ctx, &auth.GetMyUserRequest{})
require.NoError(t, err)
require.Equal(t, User.GetUserId(), myUserResp.GetUser().GetId())
}
func Test_ZITADEL_API_glob_redirects(t *testing.T) {
const redirectURI = "https://my-org-1yfnjl2xj-my-app.vercel.app/api/auth/callback/zitadel"
clientID, _ := createClientWithOpts(t, Instance, clientOpts{
redirectURI: "https://my-org-*-my-app.vercel.app/api/auth/callback/zitadel",
logoutURI: "https://my-org-*-my-app.vercel.app/",
devMode: true,
})
authRequestID := createAuthRequest(t, Instance, clientID, redirectURI, oidc.ScopeOpenID, zitadelAudienceScope)
sessionID, sessionToken, startTime, changeTime := Instance.CreateVerifiedWebAuthNSession(t, CTXLOGIN, User.GetUserId())
linkResp, err := Instance.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, Instance, clientID, code, redirectURI)
require.NoError(t, err)
assertTokens(t, tokens, false)
assertIDTokenClaims(t, tokens.IDTokenClaims, User.GetUserId(), armPasskey, startTime, changeTime, sessionID)
ctx := metadata.AppendToOutgoingContext(context.Background(), "Authorization", fmt.Sprintf("%s %s", tokens.TokenType, tokens.AccessToken))
myUserResp, err := Instance.Client.Auth.GetMyUser(ctx, &auth.GetMyUserRequest{})
require.NoError(t, err)
require.Equal(t, User.GetUserId(), myUserResp.GetUser().GetId())
}
func Test_ZITADEL_API_inactive_access_token(t *testing.T) {
clientID, _ := createClient(t, Instance)
authRequestID := createAuthRequest(t, Instance, clientID, redirectURI, oidc.ScopeOpenID, oidc.ScopeOfflineAccess, zitadelAudienceScope)
sessionID, sessionToken, startTime, changeTime := Instance.CreateVerifiedWebAuthNSession(t, CTXLOGIN, User.GetUserId())
linkResp, err := Instance.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, Instance, clientID, code, redirectURI)
require.NoError(t, err)
assertTokens(t, tokens, true)
assertIDTokenClaims(t, tokens.IDTokenClaims, User.GetUserId(), armPasskey, startTime, changeTime, sessionID)
// make sure token works
ctx := metadata.AppendToOutgoingContext(context.Background(), "Authorization", fmt.Sprintf("%s %s", tokens.TokenType, tokens.AccessToken))
myUserResp, err := Instance.Client.Auth.GetMyUser(ctx, &auth.GetMyUserRequest{})
require.NoError(t, err)
require.Equal(t, User.GetUserId(), myUserResp.GetUser().GetId())
// refresh token
newTokens, err := refreshTokens(t, clientID, tokens.RefreshToken)
require.NoError(t, err)
assert.NotEmpty(t, newTokens.AccessToken)
// use invalidated token
ctx = metadata.AppendToOutgoingContext(context.Background(), "Authorization", fmt.Sprintf("%s %s", tokens.TokenType, tokens.AccessToken))
myUserResp, err = Instance.Client.Auth.GetMyUser(ctx, &auth.GetMyUserRequest{})
require.Error(t, err)
require.Nil(t, myUserResp)
}
func Test_ZITADEL_API_terminated_session(t *testing.T) {
clientID, _ := createClient(t, Instance)
provider, err := Instance.CreateRelyingParty(CTX, clientID, redirectURI)
require.NoError(t, err)
authRequestID := createAuthRequest(t, Instance, clientID, redirectURI, oidc.ScopeOpenID, oidc.ScopeOfflineAccess, zitadelAudienceScope)
sessionID, sessionToken, startTime, changeTime := Instance.CreateVerifiedWebAuthNSession(t, CTXLOGIN, User.GetUserId())
linkResp, err := Instance.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, Instance, clientID, code, redirectURI)
require.NoError(t, err)
assertTokens(t, tokens, true)
assertIDTokenClaims(t, tokens.IDTokenClaims, User.GetUserId(), armPasskey, startTime, changeTime, sessionID)
// make sure token works
ctx := metadata.AppendToOutgoingContext(context.Background(), "Authorization", fmt.Sprintf("%s %s", tokens.TokenType, tokens.AccessToken))
myUserResp, err := Instance.Client.Auth.GetMyUser(ctx, &auth.GetMyUserRequest{})
require.NoError(t, err)
require.Equal(t, User.GetUserId(), myUserResp.GetUser().GetId())
// end session
postLogoutRedirect, err := rp.EndSession(CTX, provider, tokens.IDToken, logoutRedirectURI, "state")
require.NoError(t, err)
assert.Equal(t, logoutRedirectURI+"?state=state", postLogoutRedirect.String())
// use token from terminated session
ctx = metadata.AppendToOutgoingContext(context.Background(), "Authorization", fmt.Sprintf("%s %s", tokens.TokenType, tokens.AccessToken))
myUserResp, err = Instance.Client.Auth.GetMyUser(ctx, &auth.GetMyUserRequest{})
require.Error(t, err)
require.Nil(t, myUserResp)
}
func Test_ZITADEL_API_terminated_session_user_disabled(t *testing.T) {
clientID, _ := createClient(t, Instance)
tests := []struct {
name string
disable func(userID string) error
}{
{
name: "deactivated",
disable: func(userID string) error {
_, err := Instance.Client.UserV2.DeactivateUser(CTX, &user.DeactivateUserRequest{UserId: userID})
return err
},
},
{
name: "locked",
disable: func(userID string) error {
_, err := Instance.Client.UserV2.LockUser(CTX, &user.LockUserRequest{UserId: userID})
return err
},
},
{
name: "deleted",
disable: func(userID string) error {
_, err := Instance.Client.UserV2.DeleteUser(CTX, &user.DeleteUserRequest{UserId: userID})
return err
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
disabledUser := Instance.CreateHumanUser(CTX)
Instance.SetUserPassword(CTX, disabledUser.GetUserId(), integration.UserPassword, false)
authRequestID := createAuthRequest(t, Instance, clientID, redirectURI, oidc.ScopeOpenID, oidc.ScopeOfflineAccess, zitadelAudienceScope)
sessionID, sessionToken, startTime, changeTime := Instance.CreatePasswordSession(t, CTXLOGIN, disabledUser.GetUserId(), integration.UserPassword)
linkResp, err := Instance.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, Instance, clientID, code, redirectURI)
require.NoError(t, err)
assertTokens(t, tokens, true)
assertIDTokenClaims(t, tokens.IDTokenClaims, disabledUser.GetUserId(), armPassword, startTime, changeTime, sessionID)
// make sure token works
ctx := metadata.AppendToOutgoingContext(context.Background(), "Authorization", fmt.Sprintf("%s %s", tokens.TokenType, tokens.AccessToken))
myUserResp, err := Instance.Client.Auth.GetMyUser(ctx, &auth.GetMyUserRequest{})
require.NoError(t, err)
require.Equal(t, disabledUser.GetUserId(), myUserResp.GetUser().GetId())
// deactivate user
err = tt.disable(disabledUser.GetUserId())
require.NoError(t, err)
// use token from deactivated user
ctx = metadata.AppendToOutgoingContext(context.Background(), "Authorization", fmt.Sprintf("%s %s", tokens.TokenType, tokens.AccessToken))
myUserResp, err = Instance.Client.Auth.GetMyUser(ctx, &auth.GetMyUserRequest{})
require.Error(t, err)
require.Nil(t, myUserResp)
})
}
}
func createClient(t testing.TB, instance *integration.Instance) (clientID, projectID string) {
return createClientWithOpts(t, instance, clientOpts{
redirectURI: redirectURI,
logoutURI: logoutRedirectURI,
devMode: false,
})
}
type clientOpts struct {
redirectURI string
logoutURI string
devMode bool
}
func createClientWithOpts(t testing.TB, instance *integration.Instance, opts clientOpts) (clientID, projectID string) {
ctx := instance.WithAuthorization(CTX, integration.UserTypeOrgOwner)
project, err := instance.CreateProject(ctx)
require.NoError(t, err)
app, err := instance.CreateOIDCNativeClient(ctx, opts.redirectURI, opts.logoutURI, project.GetId(), opts.devMode)
require.NoError(t, err)
return app.GetClientId(), project.GetId()
}
func createImplicitClient(t testing.TB) string {
app, err := Instance.CreateOIDCImplicitFlowClient(CTX, redirectURIImplicit)
require.NoError(t, err)
return app.GetClientId()
}
func createAuthRequest(t testing.TB, instance *integration.Instance, clientID, redirectURI string, scope ...string) string {
redURL, err := instance.CreateOIDCAuthRequest(CTX, clientID, instance.Users.Get(integration.UserTypeLogin).ID, redirectURI, scope...)
require.NoError(t, err)
return redURL
}
func createAuthRequestImplicit(t testing.TB, clientID, redirectURI string, scope ...string) string {
redURL, err := Instance.CreateOIDCAuthRequestImplicit(CTX, clientID, Instance.Users.Get(integration.UserTypeLogin).ID, redirectURI, scope...)
require.NoError(t, err)
return redURL
}
func assertOIDCTime(t *testing.T, actual oidc.Time, expected time.Time) {
assertOIDCTimeRange(t, actual, expected, expected)
}
func assertOIDCTimeRange(t *testing.T, actual oidc.Time, expectedStart, expectedEnd time.Time) {
assert.WithinRange(t, actual.AsTime(), expectedStart.Add(-10*time.Second), expectedEnd.Add(10*time.Second))
}

View File

@@ -0,0 +1,78 @@
//go:build integration
package oidc_test
import (
"io"
"net/http"
"net/url"
"strings"
"testing"
"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/oidc"
"github.com/zitadel/schema"
)
func TestServer_RefreshToken_Status(t *testing.T) {
t.Parallel()
clientID, _ := createClient(t, Instance)
provider, err := Instance.CreateRelyingParty(CTX, clientID, redirectURI)
require.NoError(t, err)
tests := []struct {
name string
refreshToken string
}{
{
name: "invalid base64",
refreshToken: "~!~@#$%",
},
{
name: "invalid after decrypt",
refreshToken: "DEADBEEFDEADBEEF",
},
{
name: "short input",
refreshToken: "DEAD",
},
{
name: "empty input",
refreshToken: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
request := rp.RefreshTokenRequest{
RefreshToken: tt.refreshToken,
ClientID: clientID,
GrantType: oidc.GrantTypeRefreshToken,
}
client.CallTokenEndpoint(CTX, request, tokenEndpointCaller{RelyingParty: provider})
values := make(url.Values)
err := schema.NewEncoder().Encode(request, values)
require.NoError(t, err)
resp, err := http.Post(provider.OAuthConfig().Endpoint.TokenURL, "application/x-www-form-urlencoded", strings.NewReader(values.Encode()))
require.NoError(t, err)
defer resp.Body.Close()
assert.Less(t, resp.StatusCode, 500)
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
t.Log(string(body))
})
}
}
type tokenEndpointCaller struct {
rp.RelyingParty
}
func (t tokenEndpointCaller) TokenEndpoint() string {
return t.OAuthConfig().Endpoint.TokenURL
}

View File

@@ -0,0 +1,168 @@
//go:build integration
package oidc_test
import (
"testing"
"time"
"github.com/brianvoe/gofakeit/v6"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/zitadel/oidc/v3/pkg/client/rp"
"github.com/zitadel/oidc/v3/pkg/oidc"
oidc_api "github.com/zitadel/zitadel/internal/api/oidc"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/pkg/grpc/management"
"github.com/zitadel/zitadel/pkg/grpc/user"
)
func TestServer_ClientCredentialsExchange(t *testing.T) {
t.Parallel()
machine, name, clientID, clientSecret, err := Instance.CreateOIDCCredentialsClient(CTX)
require.NoError(t, err)
type claims struct {
name string
username string
updated time.Time
resourceOwnerID any
resourceOwnerName any
resourceOwnerPrimaryDomain any
orgDomain any
}
tests := []struct {
name string
clientID string
clientSecret string
scope []string
wantClaims claims
wantErr bool
}{
{
name: "missing client ID error",
clientID: "",
clientSecret: clientSecret,
scope: []string{oidc.ScopeOpenID},
wantErr: true,
},
{
name: "client not found error",
clientID: "foo",
clientSecret: clientSecret,
scope: []string{oidc.ScopeOpenID},
wantErr: true,
},
{
name: "machine user without secret error",
clientID: func() string {
name := gofakeit.Username()
_, err := Instance.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,
scope: []string{oidc.ScopeOpenID},
wantErr: true,
},
{
name: "wrong secret error",
clientID: clientID,
clientSecret: "bar",
scope: []string{oidc.ScopeOpenID},
wantErr: true,
},
{
name: "success",
clientID: clientID,
clientSecret: clientSecret,
scope: []string{oidc.ScopeOpenID},
},
{
name: "openid, profile, email",
clientID: clientID,
clientSecret: clientSecret,
scope: []string{oidc.ScopeOpenID, oidc.ScopeProfile, oidc.ScopeEmail},
wantClaims: claims{
name: name,
username: name,
updated: machine.GetDetails().GetChangeDate().AsTime(),
},
},
{
name: "org id and domain scope",
clientID: clientID,
clientSecret: clientSecret,
scope: []string{
oidc.ScopeOpenID,
domain.OrgIDScope + Instance.DefaultOrg.Id,
domain.OrgDomainPrimaryScope + Instance.DefaultOrg.PrimaryDomain,
},
wantClaims: claims{
resourceOwnerID: Instance.DefaultOrg.Id,
resourceOwnerName: Instance.DefaultOrg.Name,
resourceOwnerPrimaryDomain: Instance.DefaultOrg.PrimaryDomain,
orgDomain: Instance.DefaultOrg.PrimaryDomain,
},
},
{
name: "invalid org domain filtered",
clientID: clientID,
clientSecret: clientSecret,
scope: []string{
oidc.ScopeOpenID,
domain.OrgDomainPrimaryScope + Instance.DefaultOrg.PrimaryDomain,
domain.OrgDomainPrimaryScope + "foo"},
wantClaims: claims{
orgDomain: Instance.DefaultOrg.PrimaryDomain,
},
},
{
name: "invalid org id filtered",
clientID: clientID,
clientSecret: clientSecret,
scope: []string{oidc.ScopeOpenID,
domain.OrgIDScope + Instance.DefaultOrg.Id,
domain.OrgIDScope + "foo",
},
wantClaims: claims{
resourceOwnerID: Instance.DefaultOrg.Id,
resourceOwnerName: Instance.DefaultOrg.Name,
resourceOwnerPrimaryDomain: Instance.DefaultOrg.PrimaryDomain,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
provider, err := rp.NewRelyingPartyOIDC(CTX, Instance.OIDCIssuer(), tt.clientID, tt.clientSecret, redirectURI, tt.scope)
require.NoError(t, err)
tokens, err := rp.ClientCredentials(CTX, provider, nil)
if tt.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
require.NotNil(t, tokens)
userinfo, err := rp.Userinfo[*oidc.UserInfo](CTX, tokens.AccessToken, oidc.BearerToken, machine.GetUserId(), provider)
require.NoError(t, err)
assert.Equal(t, tt.wantClaims.resourceOwnerID, userinfo.Claims[oidc_api.ClaimResourceOwnerID])
assert.Equal(t, tt.wantClaims.resourceOwnerName, userinfo.Claims[oidc_api.ClaimResourceOwnerName])
assert.Equal(t, tt.wantClaims.resourceOwnerPrimaryDomain, userinfo.Claims[oidc_api.ClaimResourceOwnerPrimaryDomain])
assert.Equal(t, tt.wantClaims.orgDomain, userinfo.Claims[domain.OrgDomainPrimaryClaim])
assert.Equal(t, tt.wantClaims.name, userinfo.Name)
assert.Equal(t, tt.wantClaims.username, userinfo.PreferredUsername)
assertOIDCTime(t, userinfo.UpdatedAt, tt.wantClaims.updated)
assert.Empty(t, userinfo.UserInfoProfile.FamilyName)
assert.Empty(t, userinfo.UserInfoProfile.GivenName)
assert.Empty(t, userinfo.UserInfoEmail)
assert.Empty(t, userinfo.UserInfoPhone)
assert.Empty(t, userinfo.Address)
})
}
}

View File

@@ -0,0 +1,593 @@
//go:build integration
package oidc_test
import (
"context"
"testing"
"time"
"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/client/tokenexchange"
"github.com/zitadel/oidc/v3/pkg/crypto"
"github.com/zitadel/oidc/v3/pkg/oidc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/proto"
oidc_api "github.com/zitadel/zitadel/internal/api/oidc"
"github.com/zitadel/zitadel/internal/integration"
"github.com/zitadel/zitadel/pkg/grpc/admin"
"github.com/zitadel/zitadel/pkg/grpc/feature/v2"
)
func setTokenExchangeFeature(t *testing.T, value bool) {
iamCTX := Instance.WithAuthorization(CTX, integration.UserTypeIAMOwner)
_, err := Instance.Client.FeatureV2.SetInstanceFeatures(iamCTX, &feature.SetInstanceFeaturesRequest{
OidcTokenExchange: proto.Bool(value),
})
require.NoError(t, err)
time.Sleep(time.Second)
}
func resetFeatures(t *testing.T) {
iamCTX := Instance.WithAuthorization(CTX, integration.UserTypeIAMOwner)
_, err := Instance.Client.FeatureV2.ResetInstanceFeatures(iamCTX, &feature.ResetInstanceFeaturesRequest{})
require.NoError(t, err)
time.Sleep(time.Second)
}
func setImpersonationPolicy(t *testing.T, value bool) {
iamCTX := Instance.WithAuthorization(CTX, integration.UserTypeIAMOwner)
policy, err := Instance.Client.Admin.GetSecurityPolicy(iamCTX, &admin.GetSecurityPolicyRequest{})
require.NoError(t, err)
if policy.GetPolicy().GetEnableImpersonation() != value {
_, err = Instance.Client.Admin.SetSecurityPolicy(iamCTX, &admin.SetSecurityPolicyRequest{
EnableImpersonation: value,
})
require.NoError(t, err)
}
time.Sleep(time.Second)
}
func createMachineUserPATWithMembership(t *testing.T, roles ...string) (userID, pat string) {
iamCTX := Instance.WithAuthorization(CTX, integration.UserTypeIAMOwner)
userID, pat, err := Instance.CreateMachineUserPATWithMembership(iamCTX, roles...)
require.NoError(t, err)
return userID, pat
}
func accessTokenVerifier(ctx context.Context, server rs.ResourceServer, subject, actorSubject string) func(t *testing.T, token string) {
return func(t *testing.T, token string) {
resp, err := rs.Introspect[*oidc.IntrospectionResponse](ctx, server, token)
require.NoError(t, err)
assert.True(t, resp.Active)
if subject != "" {
assert.Equal(t, subject, resp.Subject)
}
if actorSubject != "" {
require.NotNil(t, resp.Actor)
assert.Equal(t, actorSubject, resp.Actor.Subject)
}
}
}
func idTokenVerifier(ctx context.Context, provider rp.RelyingParty, subject, actorSubject string) func(t *testing.T, token string) {
return func(t *testing.T, token string) {
verifier := provider.IDTokenVerifier()
resp, err := rp.VerifyIDToken[*oidc.IDTokenClaims](ctx, token, verifier)
require.NoError(t, err)
if subject != "" {
assert.Equal(t, subject, resp.Subject)
}
if actorSubject != "" {
require.NotNil(t, resp.Actor)
assert.Equal(t, actorSubject, resp.Actor.Subject)
}
}
}
func refreshTokenVerifier(ctx context.Context, provider rp.RelyingParty, subject, actorSubject string) func(t *testing.T, token string) {
return func(t *testing.T, token string) {
clientAssertion, err := client.SignedJWTProfileAssertion(provider.OAuthConfig().ClientID, []string{provider.Issuer()}, time.Hour, provider.Signer())
require.NoError(t, err)
tokens, err := rp.RefreshTokens[*oidc.IDTokenClaims](ctx, provider, token, clientAssertion, oidc.ClientAssertionTypeJWTAssertion)
require.NoError(t, err)
if subject != "" {
assert.Equal(t, subject, tokens.IDTokenClaims.Subject)
}
if actorSubject != "" {
require.NotNil(t, tokens.IDTokenClaims.Actor)
assert.Equal(t, actorSubject, tokens.IDTokenClaims.Actor.Subject)
}
assert.NotEmpty(t, tokens.RefreshToken)
}
}
func TestServer_TokenExchange(t *testing.T) {
t.Parallel()
t.Cleanup(func() {
resetFeatures(t)
setImpersonationPolicy(t, false)
})
client, keyData, err := Instance.CreateOIDCTokenExchangeClient(CTX)
require.NoError(t, err)
signer, err := rp.SignerFromKeyFile(keyData)()
require.NoError(t, err)
exchanger, err := tokenexchange.NewTokenExchangerJWTProfile(CTX, Instance.OIDCIssuer(), client.GetClientId(), signer)
require.NoError(t, err)
time.Sleep(time.Second)
iamUserID, iamImpersonatorPAT := createMachineUserPATWithMembership(t, "IAM_ADMIN_IMPERSONATOR")
orgUserID, orgImpersonatorPAT := createMachineUserPATWithMembership(t, "ORG_ADMIN_IMPERSONATOR")
serviceUserID, noPermPAT := createMachineUserPATWithMembership(t)
// exchange some tokens for later use
setTokenExchangeFeature(t, true)
teResp, err := tokenexchange.ExchangeToken(CTX, exchanger, noPermPAT, oidc.AccessTokenType, "", "", nil, nil, nil, oidc.AccessTokenType)
require.NoError(t, err)
patScopes := oidc.SpaceDelimitedArray{"openid", "profile", "urn:zitadel:iam:user:metadata", "urn:zitadel:iam:user:resourceowner"}
relyingParty, err := rp.NewRelyingPartyOIDC(CTX, Instance.OIDCIssuer(), client.GetClientId(), "", "", []string{"openid"}, rp.WithJWTProfile(rp.SignerFromKeyFile(keyData)))
require.NoError(t, err)
resourceServer, err := Instance.CreateResourceServerJWTProfile(CTX, keyData)
require.NoError(t, err)
type settings struct {
tokenExchangeFeature bool
impersonationPolicy bool
}
type args struct {
SubjectToken string
SubjectTokenType oidc.TokenType
ActorToken string
ActorTokenType oidc.TokenType
Resource []string
Audience []string
Scopes []string
RequestedTokenType oidc.TokenType
}
type result struct {
issuedTokenType oidc.TokenType
tokenType string
expiresIn uint64
scopes oidc.SpaceDelimitedArray
verifyAccessToken func(t *testing.T, token string)
verifyRefreshToken func(t *testing.T, token string)
verifyIDToken func(t *testing.T, token string)
}
tests := []struct {
name string
settings settings
args args
want result
wantErr bool
}{
{
name: "feature disabled error",
settings: settings{
tokenExchangeFeature: false,
impersonationPolicy: false,
},
args: args{
SubjectToken: noPermPAT,
SubjectTokenType: oidc.AccessTokenType,
},
wantErr: true,
},
{
name: "unsupported resource parameter",
settings: settings{
tokenExchangeFeature: true,
impersonationPolicy: false,
},
args: args{
SubjectToken: noPermPAT,
SubjectTokenType: oidc.AccessTokenType,
Resource: []string{"https://example.com"},
},
wantErr: true,
},
{
name: "invalid subject token",
settings: settings{
tokenExchangeFeature: true,
impersonationPolicy: false,
},
args: args{
SubjectToken: "foo",
SubjectTokenType: oidc.AccessTokenType,
},
wantErr: true,
},
{
name: "EXCHANGE: access token to default",
settings: settings{
tokenExchangeFeature: true,
impersonationPolicy: false,
},
args: args{
SubjectToken: noPermPAT,
SubjectTokenType: oidc.AccessTokenType,
},
want: result{
issuedTokenType: oidc.AccessTokenType,
tokenType: oidc.BearerToken,
expiresIn: 43100,
scopes: patScopes,
verifyAccessToken: accessTokenVerifier(CTX, resourceServer, serviceUserID, ""),
verifyIDToken: idTokenVerifier(CTX, relyingParty, serviceUserID, ""),
},
},
{
name: "EXCHANGE: access token to access token",
settings: settings{
tokenExchangeFeature: true,
impersonationPolicy: false,
},
args: args{
SubjectToken: noPermPAT,
SubjectTokenType: oidc.AccessTokenType,
RequestedTokenType: oidc.AccessTokenType,
},
want: result{
issuedTokenType: oidc.AccessTokenType,
tokenType: oidc.BearerToken,
expiresIn: 43100,
scopes: patScopes,
verifyAccessToken: accessTokenVerifier(CTX, resourceServer, serviceUserID, ""),
verifyIDToken: idTokenVerifier(CTX, relyingParty, serviceUserID, ""),
},
},
{
name: "EXCHANGE: access token to JWT",
settings: settings{
tokenExchangeFeature: true,
impersonationPolicy: false,
},
args: args{
SubjectToken: noPermPAT,
SubjectTokenType: oidc.AccessTokenType,
RequestedTokenType: oidc.JWTTokenType,
},
want: result{
issuedTokenType: oidc.JWTTokenType,
tokenType: oidc.BearerToken,
expiresIn: 43100,
scopes: patScopes,
verifyAccessToken: accessTokenVerifier(CTX, resourceServer, serviceUserID, ""),
verifyIDToken: idTokenVerifier(CTX, relyingParty, serviceUserID, ""),
},
},
{
name: "EXCHANGE: access token to ID Token",
settings: settings{
tokenExchangeFeature: true,
impersonationPolicy: false,
},
args: args{
SubjectToken: noPermPAT,
SubjectTokenType: oidc.AccessTokenType,
RequestedTokenType: oidc.IDTokenType,
},
want: result{
issuedTokenType: oidc.IDTokenType,
tokenType: "N_A",
expiresIn: 43100,
scopes: patScopes,
verifyAccessToken: idTokenVerifier(CTX, relyingParty, serviceUserID, ""),
verifyIDToken: func(t *testing.T, token string) {
assert.Empty(t, token)
},
},
},
{
name: "EXCHANGE: refresh token not allowed",
settings: settings{
tokenExchangeFeature: true,
impersonationPolicy: false,
},
args: args{
SubjectToken: teResp.RefreshToken,
SubjectTokenType: oidc.RefreshTokenType,
RequestedTokenType: oidc.IDTokenType,
},
wantErr: true,
},
{
name: "EXCHANGE: alternate scope for refresh token",
settings: settings{
tokenExchangeFeature: true,
impersonationPolicy: false,
},
args: args{
SubjectToken: noPermPAT,
SubjectTokenType: oidc.AccessTokenType,
RequestedTokenType: oidc.AccessTokenType,
Scopes: []string{oidc.ScopeOpenID, oidc.ScopeOfflineAccess, "profile"},
},
want: result{
issuedTokenType: oidc.AccessTokenType,
tokenType: oidc.BearerToken,
expiresIn: 43100,
scopes: []string{oidc.ScopeOpenID, oidc.ScopeOfflineAccess, "profile"},
verifyAccessToken: accessTokenVerifier(CTX, resourceServer, serviceUserID, ""),
verifyIDToken: idTokenVerifier(CTX, relyingParty, serviceUserID, ""),
verifyRefreshToken: refreshTokenVerifier(CTX, relyingParty, "", ""),
},
},
{
name: "EXCHANGE: access token, requested token type not supported error",
settings: settings{
tokenExchangeFeature: true,
impersonationPolicy: false,
},
args: args{
SubjectToken: noPermPAT,
SubjectTokenType: oidc.AccessTokenType,
RequestedTokenType: oidc.RefreshTokenType,
},
wantErr: true,
},
{
name: "EXCHANGE: access token, invalid audience",
settings: settings{
tokenExchangeFeature: true,
impersonationPolicy: false,
},
args: args{
SubjectToken: noPermPAT,
SubjectTokenType: oidc.AccessTokenType,
RequestedTokenType: oidc.AccessTokenType,
Audience: []string{"foo", "bar"},
},
wantErr: true,
},
{
name: "IMPERSONATION: subject: userID, actor: access token, policy disabled error",
settings: settings{
tokenExchangeFeature: true,
impersonationPolicy: false,
},
args: args{
SubjectToken: User.GetUserId(),
SubjectTokenType: oidc_api.UserIDTokenType,
RequestedTokenType: oidc.AccessTokenType,
ActorToken: orgImpersonatorPAT,
ActorTokenType: oidc.AccessTokenType,
},
wantErr: true,
},
{
name: "IMPERSONATION: subject: userID, actor: access token, membership not found error",
settings: settings{
tokenExchangeFeature: true,
impersonationPolicy: true,
},
args: args{
SubjectToken: User.GetUserId(),
SubjectTokenType: oidc_api.UserIDTokenType,
RequestedTokenType: oidc.AccessTokenType,
ActorToken: noPermPAT,
ActorTokenType: oidc.AccessTokenType,
},
wantErr: true,
},
{
name: "IAM IMPERSONATION: subject: userID, actor: access token, success",
settings: settings{
tokenExchangeFeature: true,
impersonationPolicy: true,
},
args: args{
SubjectToken: User.GetUserId(),
SubjectTokenType: oidc_api.UserIDTokenType,
RequestedTokenType: oidc.AccessTokenType,
ActorToken: iamImpersonatorPAT,
ActorTokenType: oidc.AccessTokenType,
},
want: result{
issuedTokenType: oidc.AccessTokenType,
tokenType: oidc.BearerToken,
expiresIn: 43100,
scopes: patScopes,
verifyAccessToken: accessTokenVerifier(CTX, resourceServer, User.GetUserId(), iamUserID),
verifyIDToken: idTokenVerifier(CTX, relyingParty, User.GetUserId(), iamUserID),
},
},
{
name: "ORG IMPERSONATION: subject: userID, actor: access token, success",
settings: settings{
tokenExchangeFeature: true,
impersonationPolicy: true,
},
args: args{
SubjectToken: User.GetUserId(),
SubjectTokenType: oidc_api.UserIDTokenType,
RequestedTokenType: oidc.AccessTokenType,
ActorToken: orgImpersonatorPAT,
ActorTokenType: oidc.AccessTokenType,
},
want: result{
issuedTokenType: oidc.AccessTokenType,
tokenType: oidc.BearerToken,
expiresIn: 43100,
scopes: patScopes,
verifyAccessToken: accessTokenVerifier(CTX, resourceServer, User.GetUserId(), orgUserID),
verifyIDToken: idTokenVerifier(CTX, relyingParty, User.GetUserId(), orgUserID),
},
},
{
name: "ORG IMPERSONATION: subject: access token, actor: access token, success",
settings: settings{
tokenExchangeFeature: true,
impersonationPolicy: true,
},
args: args{
SubjectToken: teResp.AccessToken,
SubjectTokenType: oidc.AccessTokenType,
RequestedTokenType: oidc.AccessTokenType,
ActorToken: orgImpersonatorPAT,
ActorTokenType: oidc.AccessTokenType,
},
want: result{
issuedTokenType: oidc.AccessTokenType,
tokenType: oidc.BearerToken,
expiresIn: 43100,
scopes: patScopes,
verifyAccessToken: accessTokenVerifier(CTX, resourceServer, serviceUserID, orgUserID),
verifyIDToken: idTokenVerifier(CTX, relyingParty, serviceUserID, orgUserID),
},
},
{
name: "ORG IMPERSONATION: subject: ID token, actor: access token, success",
settings: settings{
tokenExchangeFeature: true,
impersonationPolicy: true,
},
args: args{
SubjectToken: teResp.IDToken,
SubjectTokenType: oidc.IDTokenType,
RequestedTokenType: oidc.AccessTokenType,
ActorToken: orgImpersonatorPAT,
ActorTokenType: oidc.AccessTokenType,
},
want: result{
issuedTokenType: oidc.AccessTokenType,
tokenType: oidc.BearerToken,
expiresIn: 43100,
scopes: patScopes,
verifyAccessToken: accessTokenVerifier(CTX, resourceServer, serviceUserID, orgUserID),
verifyIDToken: idTokenVerifier(CTX, relyingParty, serviceUserID, orgUserID),
},
},
{
name: "ORG IMPERSONATION: subject: JWT, actor: access token, success",
settings: settings{
tokenExchangeFeature: true,
impersonationPolicy: true,
},
args: args{
SubjectToken: func() string {
token, err := crypto.Sign(&oidc.JWTTokenRequest{
Issuer: client.GetClientId(),
Subject: User.GetUserId(),
Audience: oidc.Audience{Instance.OIDCIssuer()},
ExpiresAt: oidc.FromTime(time.Now().Add(time.Hour)),
IssuedAt: oidc.FromTime(time.Now().Add(-time.Second)),
}, signer)
require.NoError(t, err)
return token
}(),
SubjectTokenType: oidc.JWTTokenType,
RequestedTokenType: oidc.AccessTokenType,
ActorToken: orgImpersonatorPAT,
ActorTokenType: oidc.AccessTokenType,
},
want: result{
issuedTokenType: oidc.AccessTokenType,
tokenType: oidc.BearerToken,
expiresIn: 43100,
scopes: patScopes,
verifyAccessToken: accessTokenVerifier(CTX, resourceServer, User.GetUserId(), orgUserID),
verifyIDToken: idTokenVerifier(CTX, relyingParty, User.GetUserId(), orgUserID),
},
},
{
name: "ORG IMPERSONATION: subject: access token, actor: access token, with refresh token, success",
settings: settings{
tokenExchangeFeature: true,
impersonationPolicy: true,
},
args: args{
SubjectToken: teResp.AccessToken,
SubjectTokenType: oidc.AccessTokenType,
RequestedTokenType: oidc.AccessTokenType,
ActorToken: orgImpersonatorPAT,
ActorTokenType: oidc.AccessTokenType,
Scopes: []string{oidc.ScopeOpenID, oidc.ScopeOfflineAccess},
},
want: result{
issuedTokenType: oidc.AccessTokenType,
tokenType: oidc.BearerToken,
expiresIn: 43100,
scopes: []string{oidc.ScopeOpenID, oidc.ScopeOfflineAccess},
verifyAccessToken: accessTokenVerifier(CTX, resourceServer, serviceUserID, orgUserID),
verifyIDToken: idTokenVerifier(CTX, relyingParty, serviceUserID, orgUserID),
verifyRefreshToken: refreshTokenVerifier(CTX, relyingParty, serviceUserID, orgUserID),
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
setTokenExchangeFeature(t, tt.settings.tokenExchangeFeature)
setImpersonationPolicy(t, tt.settings.impersonationPolicy)
got, err := tokenexchange.ExchangeToken(CTX, exchanger, tt.args.SubjectToken, tt.args.SubjectTokenType, tt.args.ActorToken, tt.args.ActorTokenType, tt.args.Resource, tt.args.Audience, tt.args.Scopes, tt.args.RequestedTokenType)
if tt.wantErr {
assert.Error(t, err)
return
}
require.NoError(t, err)
assert.Equal(t, tt.want.issuedTokenType, got.IssuedTokenType)
assert.Equal(t, tt.want.tokenType, got.TokenType)
assert.Greater(t, got.ExpiresIn, tt.want.expiresIn)
assert.Equal(t, tt.want.scopes, got.Scopes)
if tt.want.verifyAccessToken != nil {
tt.want.verifyAccessToken(t, got.AccessToken)
}
if tt.want.verifyRefreshToken != nil {
tt.want.verifyRefreshToken(t, got.RefreshToken)
}
if tt.want.verifyIDToken != nil {
tt.want.verifyIDToken(t, got.IDToken)
}
})
}
}
// This test tries to call the zitadel API with an impersonated token,
// which should fail.
func TestImpersonation_API_Call(t *testing.T) {
client, keyData, err := Instance.CreateOIDCTokenExchangeClient(CTX)
require.NoError(t, err)
signer, err := rp.SignerFromKeyFile(keyData)()
require.NoError(t, err)
exchanger, err := tokenexchange.NewTokenExchangerJWTProfile(CTX, Instance.OIDCIssuer(), client.GetClientId(), signer)
require.NoError(t, err)
resourceServer, err := Instance.CreateResourceServerJWTProfile(CTX, keyData)
require.NoError(t, err)
setTokenExchangeFeature(t, true)
setImpersonationPolicy(t, true)
t.Cleanup(func() {
resetFeatures(t)
setImpersonationPolicy(t, false)
})
iamUserID, iamImpersonatorPAT := createMachineUserPATWithMembership(t, "IAM_ADMIN_IMPERSONATOR")
iamOwner := Instance.Users.Get(integration.UserTypeIAMOwner)
// impersonating the IAM owner!
resp, err := tokenexchange.ExchangeToken(CTX, exchanger, iamOwner.Token, oidc.AccessTokenType, iamImpersonatorPAT, oidc.AccessTokenType, nil, nil, nil, oidc.AccessTokenType)
require.NoError(t, err)
accessTokenVerifier(CTX, resourceServer, iamOwner.ID, iamUserID)
impersonatedCTX := integration.WithAuthorizationToken(CTX, resp.AccessToken)
_, err = Instance.Client.Admin.GetAllowedLanguages(impersonatedCTX, &admin.GetAllowedLanguagesRequest{})
status := status.Convert(err)
assert.Equal(t, codes.PermissionDenied, status.Code())
assert.Equal(t, "Errors.TokenExchange.Token.NotForAPI (APP-Shi0J)", status.Message())
}

View File

@@ -0,0 +1,127 @@
//go:build integration
package oidc_test
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/zitadel/oidc/v3/pkg/client/profile"
"github.com/zitadel/oidc/v3/pkg/client/rp"
"github.com/zitadel/oidc/v3/pkg/oidc"
oidc_api "github.com/zitadel/zitadel/internal/api/oidc"
"github.com/zitadel/zitadel/internal/domain"
)
func TestServer_JWTProfile(t *testing.T) {
t.Parallel()
user, name, keyData, err := Instance.CreateOIDCJWTProfileClient(CTX)
require.NoError(t, err)
type claims struct {
name string
username string
updated time.Time
resourceOwnerID any
resourceOwnerName any
resourceOwnerPrimaryDomain any
orgDomain any
}
tests := []struct {
name string
keyData []byte
scope []string
wantClaims claims
wantErr bool
}{
{
name: "success",
keyData: keyData,
scope: []string{oidc.ScopeOpenID},
},
{
name: "openid, profile, email",
keyData: keyData,
scope: []string{oidc.ScopeOpenID, oidc.ScopeProfile, oidc.ScopeEmail},
wantClaims: claims{
name: name,
username: name,
updated: user.GetDetails().GetChangeDate().AsTime(),
},
},
{
name: "org id and domain scope",
keyData: keyData,
scope: []string{
oidc.ScopeOpenID,
domain.OrgIDScope + Instance.DefaultOrg.Id,
domain.OrgDomainPrimaryScope + Instance.DefaultOrg.PrimaryDomain,
},
wantClaims: claims{
resourceOwnerID: Instance.DefaultOrg.Id,
resourceOwnerName: Instance.DefaultOrg.Name,
resourceOwnerPrimaryDomain: Instance.DefaultOrg.PrimaryDomain,
orgDomain: Instance.DefaultOrg.PrimaryDomain,
},
},
{
name: "invalid org domain filtered",
keyData: keyData,
scope: []string{
oidc.ScopeOpenID,
domain.OrgDomainPrimaryScope + Instance.DefaultOrg.PrimaryDomain,
domain.OrgDomainPrimaryScope + "foo"},
wantClaims: claims{
orgDomain: Instance.DefaultOrg.PrimaryDomain,
},
},
{
name: "invalid org id filtered",
keyData: keyData,
scope: []string{oidc.ScopeOpenID,
domain.OrgIDScope + Instance.DefaultOrg.Id,
domain.OrgIDScope + "foo",
},
wantClaims: claims{
resourceOwnerID: Instance.DefaultOrg.Id,
resourceOwnerName: Instance.DefaultOrg.Name,
resourceOwnerPrimaryDomain: Instance.DefaultOrg.PrimaryDomain,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tokenSource, err := profile.NewJWTProfileTokenSourceFromKeyFileData(CTX, Instance.OIDCIssuer(), tt.keyData, tt.scope)
require.NoError(t, err)
tokens, err := tokenSource.TokenCtx(CTX)
if tt.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
require.NotNil(t, tokens)
provider, err := rp.NewRelyingPartyOIDC(CTX, Instance.OIDCIssuer(), "", "", redirectURI, tt.scope)
require.NoError(t, err)
userinfo, err := rp.Userinfo[*oidc.UserInfo](CTX, tokens.AccessToken, oidc.BearerToken, user.GetUserId(), provider)
require.NoError(t, err)
assert.Equal(t, tt.wantClaims.resourceOwnerID, userinfo.Claims[oidc_api.ClaimResourceOwnerID])
assert.Equal(t, tt.wantClaims.resourceOwnerName, userinfo.Claims[oidc_api.ClaimResourceOwnerName])
assert.Equal(t, tt.wantClaims.resourceOwnerPrimaryDomain, userinfo.Claims[oidc_api.ClaimResourceOwnerPrimaryDomain])
assert.Equal(t, tt.wantClaims.orgDomain, userinfo.Claims[domain.OrgDomainPrimaryClaim])
assert.Equal(t, tt.wantClaims.name, userinfo.Name)
assert.Equal(t, tt.wantClaims.username, userinfo.PreferredUsername)
assertOIDCTime(t, userinfo.UpdatedAt, tt.wantClaims.updated)
assert.Empty(t, userinfo.UserInfoProfile.FamilyName)
assert.Empty(t, userinfo.UserInfoProfile.GivenName)
assert.Empty(t, userinfo.UserInfoEmail)
assert.Empty(t, userinfo.UserInfoPhone)
assert.Empty(t, userinfo.Address)
})
}
}

View File

@@ -0,0 +1,454 @@
//go:build integration
package oidc_test
import (
"fmt"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/zitadel/oidc/v3/pkg/client/rp"
"github.com/zitadel/oidc/v3/pkg/oidc"
"golang.org/x/oauth2"
"google.golang.org/grpc/metadata"
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/feature/v2"
"github.com/zitadel/zitadel/pkg/grpc/management"
oidc_pb "github.com/zitadel/zitadel/pkg/grpc/oidc/v2"
)
// TestServer_UserInfo is a top-level test which re-executes the actual
// userinfo integration test against a matrix of different feature flags.
// This ensure that the response of the different implementations remains the same.
func TestServer_UserInfo(t *testing.T) {
iamOwnerCTX := Instance.WithAuthorization(CTX, integration.UserTypeIAMOwner)
t.Cleanup(func() {
_, err := Instance.Client.FeatureV2.ResetInstanceFeatures(iamOwnerCTX, &feature.ResetInstanceFeaturesRequest{})
require.NoError(t, err)
})
tests := []struct {
name string
legacy bool
trigger bool
webKey bool
}{
{
name: "legacy enabled",
legacy: true,
},
{
name: "legacy disabled, trigger disabled",
legacy: false,
trigger: false,
},
{
name: "legacy disabled, trigger enabled",
legacy: false,
trigger: true,
},
// This is the only functional test we need to cover web keys.
// - By creating tokens the signer is tested
// - When obtaining the tokens, the RP verifies the ID Token using the key set from the jwks endpoint.
// - By calling userinfo with the access token as JWT, the Token Verifier with the public key cache is tested.
{
name: "web keys",
legacy: false,
trigger: false,
webKey: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := Instance.Client.FeatureV2.SetInstanceFeatures(iamOwnerCTX, &feature.SetInstanceFeaturesRequest{
OidcLegacyIntrospection: &tt.legacy,
OidcTriggerIntrospectionProjections: &tt.trigger,
WebKey: &tt.webKey,
})
require.NoError(t, err)
testServer_UserInfo(t)
})
}
}
// testServer_UserInfo is the actual userinfo integration test,
// which calls the userinfo endpoint with different client configurations, roles and token scopes.
func testServer_UserInfo(t *testing.T) {
const (
roleFoo = "foo"
roleBar = "bar"
)
clientID, projectID := createClient(t, Instance)
addProjectRolesGrants(t, User.GetUserId(), projectID, roleFoo, roleBar)
tests := []struct {
name string
prepare func(t *testing.T, clientID string, scope []string) *oidc.Tokens[*oidc.IDTokenClaims]
scope []string
assertions []func(*testing.T, *oidc.UserInfo)
wantErr bool
}{
{
name: "invalid token",
prepare: func(*testing.T, string, []string) *oidc.Tokens[*oidc.IDTokenClaims] {
return &oidc.Tokens[*oidc.IDTokenClaims]{
Token: &oauth2.Token{
AccessToken: "DEAFBEEFDEADBEEF",
TokenType: oidc.BearerToken,
},
IDTokenClaims: &oidc.IDTokenClaims{
TokenClaims: oidc.TokenClaims{
Subject: User.GetUserId(),
},
},
}
},
scope: []string{oidc.ScopeProfile, oidc.ScopeOpenID, oidc.ScopeEmail, oidc.ScopeOfflineAccess},
assertions: []func(*testing.T, *oidc.UserInfo){
func(t *testing.T, ui *oidc.UserInfo) {
assert.Nil(t, ui)
},
},
wantErr: true,
},
{
name: "standard scopes",
prepare: getTokens,
scope: []string{oidc.ScopeProfile, oidc.ScopeOpenID, oidc.ScopeEmail, oidc.ScopeOfflineAccess},
assertions: []func(*testing.T, *oidc.UserInfo){
assertUserinfo,
func(t *testing.T, ui *oidc.UserInfo) {
assertNoReservedScopes(t, ui.Claims)
},
},
},
{
name: "project role assertion",
prepare: func(t *testing.T, clientID string, scope []string) *oidc.Tokens[*oidc.IDTokenClaims] {
_, err := Instance.Client.Mgmt.UpdateProject(CTX, &management.UpdateProjectRequest{
Id: projectID,
Name: fmt.Sprintf("project-%d", time.Now().UnixNano()),
ProjectRoleAssertion: true,
})
require.NoError(t, err)
t.Cleanup(func() {
_, err := Instance.Client.Mgmt.UpdateProject(CTX, &management.UpdateProjectRequest{
Id: projectID,
Name: fmt.Sprintf("project-%d", time.Now().UnixNano()),
ProjectRoleAssertion: false,
})
require.NoError(t, err)
})
resp, err := Instance.Client.Mgmt.GetProjectByID(CTX, &management.GetProjectByIDRequest{Id: projectID})
require.NoError(t, err)
require.True(t, resp.GetProject().GetProjectRoleAssertion(), "project role assertion")
return getTokens(t, clientID, scope)
},
scope: []string{oidc.ScopeProfile, oidc.ScopeOpenID, oidc.ScopeEmail, oidc.ScopeOfflineAccess},
assertions: []func(*testing.T, *oidc.UserInfo){
assertUserinfo,
func(t *testing.T, ui *oidc.UserInfo) {
assertProjectRoleClaims(t, projectID, ui.Claims, true, []string{roleFoo, roleBar}, []string{Instance.DefaultOrg.Id})
},
},
},
{
name: "project role scope",
prepare: getTokens,
scope: []string{oidc.ScopeProfile, oidc.ScopeOpenID, oidc.ScopeEmail, oidc.ScopeOfflineAccess,
oidc_api.ScopeProjectRolePrefix + roleFoo,
},
assertions: []func(*testing.T, *oidc.UserInfo){
assertUserinfo,
func(t *testing.T, ui *oidc.UserInfo) {
assertProjectRoleClaims(t, projectID, ui.Claims, true, []string{roleFoo}, []string{Instance.DefaultOrg.Id})
},
},
},
{
name: "project role and audience scope",
prepare: getTokens,
scope: []string{oidc.ScopeProfile, oidc.ScopeOpenID, oidc.ScopeEmail, oidc.ScopeOfflineAccess,
oidc_api.ScopeProjectRolePrefix + roleFoo,
domain.ProjectIDScope + projectID + domain.AudSuffix,
},
assertions: []func(*testing.T, *oidc.UserInfo){
assertUserinfo,
func(t *testing.T, ui *oidc.UserInfo) {
assertProjectRoleClaims(t, projectID, ui.Claims, true, []string{roleFoo}, []string{Instance.DefaultOrg.Id})
},
},
},
{
name: "PAT",
prepare: func(t *testing.T, clientID string, scope []string) *oidc.Tokens[*oidc.IDTokenClaims] {
user := Instance.Users.Get(integration.UserTypeOrgOwner)
return &oidc.Tokens[*oidc.IDTokenClaims]{
Token: &oauth2.Token{
AccessToken: user.Token,
TokenType: oidc.BearerToken,
},
IDTokenClaims: &oidc.IDTokenClaims{
TokenClaims: oidc.TokenClaims{
Subject: user.ID,
},
},
}
},
assertions: []func(*testing.T, *oidc.UserInfo){
func(t *testing.T, ui *oidc.UserInfo) {
user := Instance.Users.Get(integration.UserTypeOrgOwner)
assert.Equal(t, user.ID, ui.Subject)
assert.NotEmpty(t, ui.Claims[oidc_api.ClaimResourceOwnerName])
assert.NotEmpty(t, ui.Claims[oidc_api.ClaimResourceOwnerPrimaryDomain])
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tokens := tt.prepare(t, clientID, tt.scope)
provider, err := Instance.CreateRelyingParty(CTX, clientID, redirectURI)
require.NoError(t, err)
userinfo, err := rp.Userinfo[*oidc.UserInfo](CTX, tokens.AccessToken, tokens.TokenType, tokens.IDTokenClaims.Subject, provider)
if tt.wantErr {
assert.Error(t, err)
return
}
require.NoError(t, err)
for _, assertion := range tt.assertions {
assertion(t, userinfo)
}
})
}
}
// TestServer_UserInfo_OrgIDRoles tests the [domain.OrgRoleIDScope] functionality
// it is a separate test because it is not supported in legacy mode.
func TestServer_UserInfo_OrgIDRoles(t *testing.T) {
const (
roleFoo = "foo"
roleBar = "bar"
)
clientID, projectID := createClient(t, Instance)
addProjectRolesGrants(t, User.GetUserId(), projectID, roleFoo, roleBar)
grantedOrgID := addProjectOrgGrant(t, User.GetUserId(), projectID, roleFoo, roleBar)
_, err := Instance.Client.Mgmt.UpdateProject(CTX, &management.UpdateProjectRequest{
Id: projectID,
Name: fmt.Sprintf("project-%d", time.Now().UnixNano()),
ProjectRoleAssertion: true,
})
require.NoError(t, err)
resp, err := Instance.Client.Mgmt.GetProjectByID(CTX, &management.GetProjectByIDRequest{Id: projectID})
require.NoError(t, err)
require.True(t, resp.GetProject().GetProjectRoleAssertion(), "project role assertion")
tests := []struct {
name string
scope []string
wantRoleOrgIDs []string
}{
{
name: "default returns all role orgs",
scope: []string{
oidc.ScopeOpenID, oidc.ScopeOfflineAccess,
},
wantRoleOrgIDs: []string{Instance.DefaultOrg.Id, grantedOrgID},
},
{
name: "only granted org",
scope: []string{
oidc.ScopeOpenID, oidc.ScopeOfflineAccess,
domain.OrgRoleIDScope + grantedOrgID},
wantRoleOrgIDs: []string{grantedOrgID},
},
{
name: "only own org",
scope: []string{
oidc.ScopeOpenID, oidc.ScopeOfflineAccess,
domain.OrgRoleIDScope + Instance.DefaultOrg.Id,
},
wantRoleOrgIDs: []string{Instance.DefaultOrg.Id},
},
{
name: "request both orgs",
scope: []string{
oidc.ScopeOpenID, oidc.ScopeOfflineAccess,
domain.OrgRoleIDScope + Instance.DefaultOrg.Id,
domain.OrgRoleIDScope + grantedOrgID,
},
wantRoleOrgIDs: []string{Instance.DefaultOrg.Id, grantedOrgID},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tokens := getTokens(t, clientID, tt.scope)
provider, err := Instance.CreateRelyingParty(CTX, clientID, redirectURI)
require.NoError(t, err)
userinfo, err := rp.Userinfo[*oidc.UserInfo](CTX, tokens.AccessToken, tokens.TokenType, tokens.IDTokenClaims.Subject, provider)
require.NoError(t, err)
assertProjectRoleClaims(t, projectID, userinfo.Claims, true, []string{roleFoo, roleBar}, tt.wantRoleOrgIDs)
})
}
}
// https://github.com/zitadel/zitadel/issues/6662
func TestServer_UserInfo_Issue6662(t *testing.T) {
const (
roleFoo = "foo"
roleBar = "bar"
)
project, err := Instance.CreateProject(CTX)
projectID := project.GetId()
require.NoError(t, err)
user, _, clientID, clientSecret, err := Instance.CreateOIDCCredentialsClient(CTX)
require.NoError(t, err)
addProjectRolesGrants(t, user.GetUserId(), projectID, roleFoo, roleBar)
scope := []string{oidc.ScopeProfile, oidc.ScopeOpenID, oidc.ScopeEmail, oidc.ScopeOfflineAccess,
oidc_api.ScopeProjectRolePrefix + roleFoo,
domain.ProjectIDScope + projectID + domain.AudSuffix,
}
provider, err := rp.NewRelyingPartyOIDC(CTX, Instance.OIDCIssuer(), clientID, clientSecret, redirectURI, scope)
require.NoError(t, err)
tokens, err := rp.ClientCredentials(CTX, provider, nil)
require.NoError(t, err)
userinfo, err := rp.Userinfo[*oidc.UserInfo](CTX, tokens.AccessToken, tokens.TokenType, user.GetUserId(), provider)
require.NoError(t, err)
assertProjectRoleClaims(t, projectID, userinfo.Claims, false, []string{roleFoo}, []string{Instance.DefaultOrg.Id})
}
func addProjectRolesGrants(t *testing.T, userID, projectID string, roles ...string) {
t.Helper()
bulkRoles := make([]*management.BulkAddProjectRolesRequest_Role, len(roles))
for i, role := range roles {
bulkRoles[i] = &management.BulkAddProjectRolesRequest_Role{
Key: role,
DisplayName: role,
}
}
_, err := Instance.Client.Mgmt.BulkAddProjectRoles(CTX, &management.BulkAddProjectRolesRequest{
ProjectId: projectID,
Roles: bulkRoles,
})
require.NoError(t, err)
_, err = Instance.Client.Mgmt.AddUserGrant(CTX, &management.AddUserGrantRequest{
UserId: userID,
ProjectId: projectID,
RoleKeys: roles,
})
require.NoError(t, err)
}
// addProjectOrgGrant adds a new organization which will be granted on the projectID with the specified roles.
// The userID will be granted in the new organization to the project with the same roles.
func addProjectOrgGrant(t *testing.T, userID, projectID string, roles ...string) (grantedOrgID string) {
grantedOrg := Instance.CreateOrganization(CTXIAM, fmt.Sprintf("ZITADEL_GRANTED_%d", time.Now().UnixNano()), fmt.Sprintf("%d@mouse.com", time.Now().UnixNano()))
projectGrant, err := Instance.Client.Mgmt.AddProjectGrant(CTX, &management.AddProjectGrantRequest{
ProjectId: projectID,
GrantedOrgId: grantedOrg.GetOrganizationId(),
RoleKeys: roles,
})
require.NoError(t, err)
ctxOrg := metadata.AppendToOutgoingContext(CTXIAM, "x-zitadel-orgid", grantedOrg.GetOrganizationId())
_, err = Instance.Client.Mgmt.AddUserGrant(ctxOrg, &management.AddUserGrantRequest{
UserId: userID,
ProjectId: projectID,
ProjectGrantId: projectGrant.GetGrantId(),
RoleKeys: roles,
})
require.NoError(t, err)
return grantedOrg.GetOrganizationId()
}
func getTokens(t *testing.T, clientID string, scope []string) *oidc.Tokens[*oidc.IDTokenClaims] {
authRequestID := createAuthRequest(t, Instance, clientID, redirectURI, scope...)
sessionID, sessionToken, startTime, changeTime := Instance.CreateVerifiedWebAuthNSession(t, CTXLOGIN, User.GetUserId())
linkResp, err := Instance.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, Instance, clientID, code, redirectURI)
require.NoError(t, err)
assertTokens(t, tokens, true)
assertIDTokenClaims(t, tokens.IDTokenClaims, User.GetUserId(), armPasskey, startTime, changeTime, sessionID)
return tokens
}
func assertUserinfo(t *testing.T, userinfo *oidc.UserInfo) {
t.Helper()
assert.Equal(t, User.GetUserId(), userinfo.Subject)
assert.Equal(t, "Mickey", userinfo.GivenName)
assert.Equal(t, "Mouse", userinfo.FamilyName)
assert.Equal(t, "Mickey Mouse", userinfo.Name)
assert.NotEmpty(t, userinfo.PreferredUsername)
assert.Equal(t, userinfo.PreferredUsername, userinfo.Email)
assert.False(t, bool(userinfo.EmailVerified))
assertOIDCTime(t, userinfo.UpdatedAt, User.GetDetails().GetChangeDate().AsTime())
}
func assertNoReservedScopes(t *testing.T, claims map[string]any) {
t.Helper()
t.Log(claims)
for claim := range claims {
assert.Falsef(t, strings.HasPrefix(claim, oidc_api.ClaimPrefix), "claim %s has prefix %s", claim, oidc_api.ClaimPrefix)
}
}
// assertProjectRoleClaims asserts the projectRoles in the claims.
// By default it searches for the [oidc_api.ClaimProjectRolesFormat] claim with a project ID,
// and optionally for the [oidc_api.ClaimProjectRoles] claim if claimProjectRole is true.
// Each claim should contain the roles expected by wantRoles and
// each role should contain the org IDs expected by wantRoleOrgIDs.
//
// In the claim map, each project role claim is expected to be a map of multiple roles and
// each role is expected to be a map of multiple Org IDs to Org Domains.
func assertProjectRoleClaims(t *testing.T, projectID string, claims map[string]any, claimProjectRole bool, wantRoles, wantRoleOrgIDs []string) {
t.Helper()
projectRoleClaims := []string{fmt.Sprintf(oidc_api.ClaimProjectRolesFormat, projectID)}
if claimProjectRole {
projectRoleClaims = append(projectRoleClaims, oidc_api.ClaimProjectRoles)
}
for _, claim := range projectRoleClaims {
roleMap, ok := claims[claim].(map[string]any) // map of multiple roles
require.Truef(t, ok, "claim %s not found or wrong type %T", claim, claims[claim])
gotRoles := make([]string, 0, len(roleMap))
for roleKey := range roleMap {
role, ok := roleMap[roleKey].(map[string]any) // map of multiple org IDs to org domains
require.Truef(t, ok, "role %s not found or wrong type %T", roleKey, roleMap[roleKey])
gotRoles = append(gotRoles, roleKey)
gotRoleOrgIDs := make([]string, 0, len(role))
for orgID := range role {
gotRoleOrgIDs = append(gotRoleOrgIDs, orgID)
}
assert.ElementsMatch(t, wantRoleOrgIDs, gotRoleOrgIDs)
}
assert.ElementsMatch(t, wantRoles, gotRoles)
}
}