mirror of
https://github.com/zitadel/zitadel.git
synced 2025-08-12 00:17:32 +00:00
feat: specify login UI version on instance and apps (#9071)
# Which Problems Are Solved To be able to migrate or test the new login UI, admins might want to (temporarily) switch individual apps. At a later point admin might want to make sure all applications use the new login UI. # How the Problems Are Solved - Added a feature flag `` on instance level to require all apps to use the new login and provide an optional base url. - if the flag is enabled, all (OIDC) applications will automatically use the v2 login. - if disabled, applications can decide based on their configuration - Added an option on OIDC apps to use the new login UI and an optional base url. - Removed the requirement to use `x-zitadel-login-client` to be redirected to the login V2 and retrieve created authrequest and link them to SSO sessions. - Added a new "IAM_LOGIN_CLIENT" role to allow management of users, sessions, grants and more without `x-zitadel-login-client`. # Additional Changes None # Additional Context closes https://github.com/zitadel/zitadel/issues/8702
This commit is contained in:
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -16,6 +17,7 @@ import (
|
||||
"github.com/zitadel/zitadel/internal/api/authz"
|
||||
http_utils "github.com/zitadel/zitadel/internal/api/http"
|
||||
"github.com/zitadel/zitadel/internal/api/http/middleware"
|
||||
"github.com/zitadel/zitadel/internal/api/ui/login"
|
||||
"github.com/zitadel/zitadel/internal/auth/repository/eventsourcing/handler"
|
||||
"github.com/zitadel/zitadel/internal/command"
|
||||
"github.com/zitadel/zitadel/internal/domain"
|
||||
@@ -26,7 +28,11 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
LoginClientHeader = "x-zitadel-login-client"
|
||||
LoginClientHeader = "x-zitadel-login-client"
|
||||
LoginPostLogoutRedirectParam = "post_logout_redirect"
|
||||
LoginPath = "/login"
|
||||
LogoutPath = "/logout"
|
||||
LogoutDonePath = "/logout/done"
|
||||
)
|
||||
|
||||
func (o *OPStorage) CreateAuthRequest(ctx context.Context, req *oidc.AuthRequest, userID string) (_ op.AuthRequest, err error) {
|
||||
@@ -36,12 +42,34 @@ func (o *OPStorage) CreateAuthRequest(ctx context.Context, req *oidc.AuthRequest
|
||||
span.EndWithError(err)
|
||||
}()
|
||||
|
||||
// for backwards compatibility we pass the login client if set
|
||||
headers, _ := http_utils.HeadersFromCtx(ctx)
|
||||
if loginClient := headers.Get(LoginClientHeader); loginClient != "" {
|
||||
loginClient := headers.Get(LoginClientHeader)
|
||||
|
||||
// if the instance requires the v2 login, use it no matter what the application configured
|
||||
if authz.GetFeatures(ctx).LoginV2.Required {
|
||||
return o.createAuthRequestLoginClient(ctx, req, userID, loginClient)
|
||||
}
|
||||
|
||||
return o.createAuthRequest(ctx, req, userID)
|
||||
version, err := o.query.OIDCClientLoginVersion(ctx, req.ClientID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
switch version {
|
||||
case domain.LoginVersion1:
|
||||
return o.createAuthRequest(ctx, req, userID)
|
||||
case domain.LoginVersion2:
|
||||
return o.createAuthRequestLoginClient(ctx, req, userID, loginClient)
|
||||
case domain.LoginVersionUnspecified:
|
||||
fallthrough
|
||||
default:
|
||||
// if undefined, use the v2 login if the header is sent, to retain the current behavior
|
||||
if loginClient != "" {
|
||||
return o.createAuthRequestLoginClient(ctx, req, userID, loginClient)
|
||||
}
|
||||
return o.createAuthRequest(ctx, req, userID)
|
||||
}
|
||||
}
|
||||
|
||||
func (o *OPStorage) createAuthRequestScopeAndAudience(ctx context.Context, clientID string, reqScope []string) (scope, audience []string, err error) {
|
||||
@@ -240,18 +268,35 @@ func (o *OPStorage) TerminateSessionFromRequest(ctx context.Context, endSessionR
|
||||
|
||||
// check for the login client header
|
||||
headers, _ := http_utils.HeadersFromCtx(ctx)
|
||||
// in case there is no id_token_hint, redirect to the UI and let it decide which session to terminate
|
||||
if headers.Get(LoginClientHeader) != "" && endSessionRequest.IDTokenHintClaims == nil {
|
||||
return o.defaultLogoutURLV2 + endSessionRequest.RedirectURI, nil
|
||||
|
||||
// V2:
|
||||
// In case there is no id_token_hint and login V2 is either required by feature
|
||||
// or requested via header (backwards compatibility),
|
||||
// we'll redirect to the UI (V2) and let it decide which session to terminate
|
||||
//
|
||||
// If there's no id_token_hint and for v1 logins, we handle them separately
|
||||
if endSessionRequest.IDTokenHintClaims == nil &&
|
||||
(authz.GetFeatures(ctx).LoginV2.Required || headers.Get(LoginClientHeader) != "") {
|
||||
redirectURI := v2PostLogoutRedirectURI(endSessionRequest.RedirectURI)
|
||||
// if no base uri is set, fallback to the default configured in the runtime config
|
||||
if authz.GetFeatures(ctx).LoginV2.BaseURI == nil || authz.GetFeatures(ctx).LoginV2.BaseURI.String() == "" {
|
||||
return o.defaultLogoutURLV2 + redirectURI, nil
|
||||
}
|
||||
return buildLoginV2LogoutURL(authz.GetFeatures(ctx).LoginV2.BaseURI, redirectURI), nil
|
||||
}
|
||||
|
||||
// If there is no login client header and no id_token_hint or the id_token_hint does not have a session ID,
|
||||
// do a v1 Terminate session (which terminates all sessions of the user agent, identified by cookie).
|
||||
// V1:
|
||||
// We check again for the id_token_hint param and if a session is set in it.
|
||||
// All explicit V2 sessions with empty id_token_hint are handled above and all V2 session contain a sessionID
|
||||
// So if any condition is not met, we handle the request as a V1 request and do a (v1) TerminateSession,
|
||||
// which terminates all sessions of the user agent, identified by cookie.
|
||||
if endSessionRequest.IDTokenHintClaims == nil || endSessionRequest.IDTokenHintClaims.SessionID == "" {
|
||||
return endSessionRequest.RedirectURI, o.TerminateSession(ctx, endSessionRequest.UserID, endSessionRequest.ClientID)
|
||||
}
|
||||
|
||||
// If the sessionID is prefixed by V1, we also terminate a v1 session.
|
||||
// V1:
|
||||
// If the sessionID is prefixed by V1, we also terminate a v1 session, but based on the SingleV1SessionTermination feature flag,
|
||||
// we either terminate all sessions of the user agent or only the specific session
|
||||
if strings.HasPrefix(endSessionRequest.IDTokenHintClaims.SessionID, handler.IDPrefixV1) {
|
||||
err = o.terminateV1Session(ctx, endSessionRequest.UserID, endSessionRequest.IDTokenHintClaims.SessionID)
|
||||
if err != nil {
|
||||
@@ -260,12 +305,31 @@ func (o *OPStorage) TerminateSessionFromRequest(ctx context.Context, endSessionR
|
||||
return endSessionRequest.RedirectURI, nil
|
||||
}
|
||||
|
||||
// terminate the v2 session of the id_token_hint
|
||||
// V2:
|
||||
// Terminate the v2 session of the id_token_hint
|
||||
_, err = o.command.TerminateSessionWithoutTokenCheck(ctx, endSessionRequest.IDTokenHintClaims.SessionID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return endSessionRequest.RedirectURI, nil
|
||||
return v2PostLogoutRedirectURI(endSessionRequest.RedirectURI), nil
|
||||
}
|
||||
|
||||
func buildLoginV2LogoutURL(baseURI *url.URL, redirectURI string) string {
|
||||
baseURI.JoinPath(LogoutPath)
|
||||
q := baseURI.Query()
|
||||
q.Set(LoginPostLogoutRedirectParam, redirectURI)
|
||||
baseURI.RawQuery = q.Encode()
|
||||
return baseURI.String()
|
||||
}
|
||||
|
||||
// v2PostLogoutRedirectURI will take care that the post_logout_redirect_uri is correctly set for v2 logins.
|
||||
// The default value set by the [op.SessionEnder] only handles V1 logins. In case the redirect_uri is set to the default
|
||||
// we'll return the path for the v2 login.
|
||||
func v2PostLogoutRedirectURI(redirectURI string) string {
|
||||
if redirectURI != login.DefaultLoggedOutPath {
|
||||
return redirectURI
|
||||
}
|
||||
return LogoutDonePath
|
||||
}
|
||||
|
||||
// terminateV1Session terminates "v1" sessions created through the login UI.
|
||||
|
@@ -15,6 +15,10 @@ import (
|
||||
"github.com/zitadel/zitadel/internal/query"
|
||||
)
|
||||
|
||||
const (
|
||||
LoginAuthRequestParam = "authRequest"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
client *query.OIDCClient
|
||||
defaultLoginURL string
|
||||
@@ -49,10 +53,21 @@ func (c *Client) GetID() string {
|
||||
}
|
||||
|
||||
func (c *Client) LoginURL(id string) string {
|
||||
if strings.HasPrefix(id, command.IDPrefixV2) {
|
||||
// if the authRequest does not have the v2 prefix, it was created for login V1
|
||||
if !strings.HasPrefix(id, command.IDPrefixV2) {
|
||||
return c.defaultLoginURL + id
|
||||
}
|
||||
// any v2 login without a specific base uri will be sent to the configured login v2 UI
|
||||
// this way we're also backwards compatible
|
||||
if c.client.LoginBaseURI == nil || c.client.LoginBaseURI.URL().String() == "" {
|
||||
return c.defaultLoginURLV2 + id
|
||||
}
|
||||
return c.defaultLoginURL + id
|
||||
// for clients with a specific URI (internal or external) we only need to add the auth request id
|
||||
uri := c.client.LoginBaseURI.URL().JoinPath(LoginPath)
|
||||
q := uri.Query()
|
||||
q.Set(LoginAuthRequestParam, id)
|
||||
uri.RawQuery = q.Encode()
|
||||
return uri.String()
|
||||
}
|
||||
|
||||
func (c *Client) RedirectURIs() []string {
|
||||
|
@@ -29,157 +29,255 @@ var (
|
||||
|
||||
func TestOPStorage_CreateAuthRequest(t *testing.T) {
|
||||
clientID, _ := createClient(t, Instance)
|
||||
clientIDV2, _ := createClientLoginV2(t, Instance)
|
||||
|
||||
id := createAuthRequest(t, Instance, clientID, redirectURI)
|
||||
require.Contains(t, id, command.IDPrefixV2)
|
||||
|
||||
id2 := createAuthRequestNoLoginClientHeader(t, Instance, clientIDV2, redirectURI)
|
||||
require.Contains(t, id2, command.IDPrefixV2)
|
||||
}
|
||||
|
||||
func TestOPStorage_CreateAccessToken_code(t *testing.T) {
|
||||
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,
|
||||
},
|
||||
tests := []struct {
|
||||
name string
|
||||
clientID string
|
||||
authRequestID func(t testing.TB, instance *integration.Instance, clientID, redirectURI string, scope ...string) string
|
||||
}{
|
||||
{
|
||||
name: "login header",
|
||||
clientID: func() string {
|
||||
clientID, _ := createClient(t, Instance)
|
||||
return clientID
|
||||
}(),
|
||||
authRequestID: createAuthRequest,
|
||||
},
|
||||
})
|
||||
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,
|
||||
},
|
||||
{
|
||||
name: "login v2 config",
|
||||
clientID: func() string {
|
||||
clientID, _ := createClientLoginV2(t, Instance)
|
||||
return clientID
|
||||
}(),
|
||||
authRequestID: createAuthRequestNoLoginClientHeader,
|
||||
},
|
||||
})
|
||||
require.Error(t, err)
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
authRequestID := tt.authRequestID(t, Instance, tt.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)
|
||||
|
||||
// exchange with a used code must fail
|
||||
_, err = exchangeTokens(t, Instance, clientID, code, redirectURI)
|
||||
require.Error(t, err)
|
||||
// test code exchange
|
||||
code := assertCodeResponse(t, linkResp.GetCallbackUrl())
|
||||
tokens, err := exchangeTokens(t, Instance, tt.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, tt.clientID, code, redirectURI)
|
||||
require.Error(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOPStorage_CreateAccessToken_implicit(t *testing.T) {
|
||||
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,
|
||||
},
|
||||
tests := []struct {
|
||||
name string
|
||||
clientID string
|
||||
authRequestID func(t testing.TB, clientID, redirectURI string, scope ...string) string
|
||||
}{
|
||||
{
|
||||
name: "login header",
|
||||
clientID: createImplicitClient(t),
|
||||
authRequestID: createAuthRequestImplicit,
|
||||
},
|
||||
})
|
||||
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,
|
||||
},
|
||||
{
|
||||
name: "login v2 config",
|
||||
clientID: createImplicitClientNoLoginClientHeader(t),
|
||||
authRequestID: createAuthRequestImplicitNoLoginClientHeader,
|
||||
},
|
||||
})
|
||||
require.Error(t, err)
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
authRequestID := tt.authRequestID(t, tt.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, tt.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) {
|
||||
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,
|
||||
},
|
||||
tests := []struct {
|
||||
name string
|
||||
clientID string
|
||||
authRequestID func(t testing.TB, instance *integration.Instance, clientID, redirectURI string, scope ...string) string
|
||||
}{
|
||||
{
|
||||
name: "login header",
|
||||
clientID: func() string {
|
||||
clientID, _ := createClient(t, Instance)
|
||||
return clientID
|
||||
}(),
|
||||
authRequestID: createAuthRequest,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
{
|
||||
name: "login v2 config",
|
||||
clientID: func() string {
|
||||
clientID, _ := createClientLoginV2(t, Instance)
|
||||
return clientID
|
||||
}(),
|
||||
authRequestID: createAuthRequestNoLoginClientHeader,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
authRequestID := tt.authRequestID(t, Instance, tt.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)
|
||||
// test code exchange (expect refresh token to be returned)
|
||||
code := assertCodeResponse(t, linkResp.GetCallbackUrl())
|
||||
tokens, err := exchangeTokens(t, Instance, tt.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) {
|
||||
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,
|
||||
},
|
||||
tests := []struct {
|
||||
name string
|
||||
clientID string
|
||||
authRequestID func(t testing.TB, instance *integration.Instance, clientID, redirectURI string, scope ...string) string
|
||||
}{
|
||||
{
|
||||
name: "login header",
|
||||
clientID: func() string {
|
||||
clientID, _ := createClient(t, Instance)
|
||||
return clientID
|
||||
}(),
|
||||
authRequestID: createAuthRequest,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
{
|
||||
name: "login v2 config",
|
||||
clientID: func() string {
|
||||
clientID, _ := createClientLoginV2(t, Instance)
|
||||
return clientID
|
||||
}(),
|
||||
authRequestID: createAuthRequestNoLoginClientHeader,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
provider, err := Instance.CreateRelyingParty(CTX, tt.clientID, redirectURI)
|
||||
require.NoError(t, err)
|
||||
authRequestID := tt.authRequestID(t, Instance, tt.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)
|
||||
// code exchange
|
||||
code := assertCodeResponse(t, linkResp.GetCallbackUrl())
|
||||
tokens, err := exchangeTokens(t, Instance, tt.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)
|
||||
// test actual refresh grant
|
||||
newTokens, err := refreshTokens(t, tt.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)
|
||||
// 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) {
|
||||
@@ -454,47 +552,75 @@ func TestOPStorage_TerminateSession_refresh_grant(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestOPStorage_TerminateSession_empty_id_token_hint(t *testing.T) {
|
||||
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,
|
||||
},
|
||||
tests := []struct {
|
||||
name string
|
||||
clientID string
|
||||
authRequestID func(t testing.TB, instance *integration.Instance, clientID, redirectURI string, scope ...string) string
|
||||
logoutURL string
|
||||
}{
|
||||
{
|
||||
name: "login header",
|
||||
clientID: func() string {
|
||||
clientID, _ := createClient(t, Instance)
|
||||
return clientID
|
||||
}(),
|
||||
authRequestID: createAuthRequest,
|
||||
logoutURL: http_utils.BuildOrigin(Instance.Host(), Instance.Config.Secure) + Instance.Config.LogoutURLV2 + logoutRedirectURI + "?state=state",
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
{
|
||||
name: "login v2 config",
|
||||
clientID: func() string {
|
||||
clientID, _ := createClientLoginV2(t, Instance)
|
||||
return clientID
|
||||
}(),
|
||||
authRequestID: createAuthRequestNoLoginClientHeader,
|
||||
logoutURL: http_utils.BuildOrigin(Instance.Host(), Instance.Config.Secure) + Instance.Config.LogoutURLV2 + logoutRedirectURI + "?state=state",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
provider, err := Instance.CreateRelyingParty(CTX, tt.clientID, redirectURI)
|
||||
require.NoError(t, err)
|
||||
authRequestID := tt.authRequestID(t, Instance, tt.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)
|
||||
// test code exchange
|
||||
code := assertCodeResponse(t, linkResp.GetCallbackUrl())
|
||||
tokens, err := exchangeTokens(t, Instance, tt.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())
|
||||
postLogoutRedirect, err := rp.EndSession(CTX, provider, "", logoutRedirectURI, "state")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.logoutURL, 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)
|
||||
// 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)
|
||||
// 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)
|
||||
// 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) {
|
||||
|
@@ -18,6 +18,7 @@ import (
|
||||
|
||||
"github.com/zitadel/zitadel/internal/domain"
|
||||
"github.com/zitadel/zitadel/internal/integration"
|
||||
"github.com/zitadel/zitadel/pkg/grpc/app"
|
||||
"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"
|
||||
@@ -394,16 +395,27 @@ func Test_ZITADEL_API_terminated_session_user_disabled(t *testing.T) {
|
||||
|
||||
func createClient(t testing.TB, instance *integration.Instance) (clientID, projectID string) {
|
||||
return createClientWithOpts(t, instance, clientOpts{
|
||||
redirectURI: redirectURI,
|
||||
logoutURI: logoutRedirectURI,
|
||||
devMode: false,
|
||||
redirectURI: redirectURI,
|
||||
logoutURI: logoutRedirectURI,
|
||||
devMode: false,
|
||||
LoginVersion: nil,
|
||||
})
|
||||
}
|
||||
|
||||
func createClientLoginV2(t testing.TB, instance *integration.Instance) (clientID, projectID string) {
|
||||
return createClientWithOpts(t, instance, clientOpts{
|
||||
redirectURI: redirectURI,
|
||||
logoutURI: logoutRedirectURI,
|
||||
devMode: false,
|
||||
LoginVersion: &app.LoginVersion{Version: &app.LoginVersion_LoginV2{LoginV2: &app.LoginV2{BaseUri: nil}}},
|
||||
})
|
||||
}
|
||||
|
||||
type clientOpts struct {
|
||||
redirectURI string
|
||||
logoutURI string
|
||||
devMode bool
|
||||
redirectURI string
|
||||
logoutURI string
|
||||
devMode bool
|
||||
LoginVersion *app.LoginVersion
|
||||
}
|
||||
|
||||
func createClientWithOpts(t testing.TB, instance *integration.Instance, opts clientOpts) (clientID, projectID string) {
|
||||
@@ -411,13 +423,19 @@ func createClientWithOpts(t testing.TB, instance *integration.Instance, opts cli
|
||||
|
||||
project, err := instance.CreateProject(ctx)
|
||||
require.NoError(t, err)
|
||||
app, err := instance.CreateOIDCNativeClient(ctx, opts.redirectURI, opts.logoutURI, project.GetId(), opts.devMode)
|
||||
app, err := instance.CreateOIDCClientLoginVersion(ctx, opts.redirectURI, opts.logoutURI, project.GetId(), app.OIDCAppType_OIDC_APP_TYPE_NATIVE, app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_NONE, opts.devMode, opts.LoginVersion)
|
||||
require.NoError(t, err)
|
||||
return app.GetClientId(), project.GetId()
|
||||
}
|
||||
|
||||
func createImplicitClient(t testing.TB) string {
|
||||
app, err := Instance.CreateOIDCImplicitFlowClient(CTX, redirectURIImplicit)
|
||||
app, err := Instance.CreateOIDCImplicitFlowClient(CTX, redirectURIImplicit, nil)
|
||||
require.NoError(t, err)
|
||||
return app.GetClientId()
|
||||
}
|
||||
|
||||
func createImplicitClientNoLoginClientHeader(t testing.TB) string {
|
||||
app, err := Instance.CreateOIDCImplicitFlowClient(CTX, redirectURIImplicit, &app.LoginVersion{Version: &app.LoginVersion_LoginV2{LoginV2: &app.LoginV2{BaseUri: nil}}})
|
||||
require.NoError(t, err)
|
||||
return app.GetClientId()
|
||||
}
|
||||
@@ -428,12 +446,24 @@ func createAuthRequest(t testing.TB, instance *integration.Instance, clientID, r
|
||||
return redURL
|
||||
}
|
||||
|
||||
func createAuthRequestNoLoginClientHeader(t testing.TB, instance *integration.Instance, clientID, redirectURI string, scope ...string) string {
|
||||
redURL, err := instance.CreateOIDCAuthRequestWithoutLoginClientHeader(CTX, clientID, 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 createAuthRequestImplicitNoLoginClientHeader(t testing.TB, clientID, redirectURI string, scope ...string) string {
|
||||
redURL, err := Instance.CreateOIDCAuthRequestImplicitWithoutLoginClientHeader(CTX, clientID, redirectURI, scope...)
|
||||
require.NoError(t, err)
|
||||
return redURL
|
||||
}
|
||||
|
||||
func assertOIDCTime(t *testing.T, actual oidc.Time, expected time.Time) {
|
||||
assertOIDCTimeRange(t, actual, expected, expected)
|
||||
}
|
||||
|
Reference in New Issue
Block a user