From 80961125a7edc0165e36fa5f48ddfea801fdd8ed Mon Sep 17 00:00:00 2001 From: Livio Spring Date: Fri, 14 Jul 2023 13:16:16 +0200 Subject: [PATCH] feat(API): support V2 token and session token usage (#6180) This PR adds support for userinfo and introspection of V2 tokens. Further V2 access tokens and session tokens can be used for authentication on the ZITADEL API (like the current access tokens). --- .github/workflows/integration.yml | 2 +- .github/workflows/test-code.yml | 2 +- .github/workflows/zitadel.yml | 2 +- build/zitadel/Dockerfile | 2 +- go.mod | 2 +- internal/api/authz/token.go | 3 +- .../api/grpc/oidc/v2/oidc_integration_test.go | 8 +- internal/api/grpc/session/v2/session.go | 9 +- .../session/v2/session_integration_test.go | 52 ++++ internal/api/grpc/session/v2/session_test.go | 30 ++- .../api/oidc/auth_request_integration_test.go | 70 +---- internal/api/oidc/client.go | 112 ++++++-- internal/api/oidc/client_integration_test.go | 135 ++++++++++ internal/api/oidc/oidc_integration_test.go | 248 ++++++++++++++++++ .../eventstore/token_verifier.go | 114 +++++++- internal/command/oidc_session.go | 25 +- internal/command/oidc_session_model.go | 6 + internal/command/oidc_session_test.go | 66 +++-- internal/domain/user.go | 25 ++ internal/integration/client.go | 28 ++ internal/integration/integration.go | 1 + internal/integration/oidc.go | 50 ++-- internal/query/access_token.go | 113 ++++++++ internal/query/session.go | 15 +- internal/query/sessions_test.go | 14 + internal/query/user_auth_method.go | 158 +++++++++-- internal/query/user_auth_method_test.go | 156 +++++++++++ internal/static/i18n/bg.yaml | 3 + internal/static/i18n/de.yaml | 4 + internal/static/i18n/en.yaml | 3 + internal/static/i18n/es.yaml | 3 + internal/static/i18n/fr.yaml | 3 + internal/static/i18n/it.yaml | 3 + internal/static/i18n/ja.yaml | 3 + internal/static/i18n/mk.yaml | 9 + internal/static/i18n/pl.yaml | 3 + internal/static/i18n/zh.yaml | 3 + proto/zitadel/session/v2alpha/session.proto | 5 + 38 files changed, 1309 insertions(+), 181 deletions(-) create mode 100644 internal/api/oidc/client_integration_test.go create mode 100644 internal/api/oidc/oidc_integration_test.go create mode 100644 internal/query/access_token.go diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index 48e1603cc..9cc82d80b 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -22,7 +22,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v3 with: - go-version: 1.19 + go-version: '1.20' - name: Source checkout uses: actions/checkout@v3 - name: Set up Docker Buildx diff --git a/.github/workflows/test-code.yml b/.github/workflows/test-code.yml index b681d3b2d..ff42f23e1 100644 --- a/.github/workflows/test-code.yml +++ b/.github/workflows/test-code.yml @@ -17,7 +17,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v3 with: - go-version: 1.19 + go-version: '1.20' - name: Source checkout uses: actions/checkout@v3 - name: Set up QEMU diff --git a/.github/workflows/zitadel.yml b/.github/workflows/zitadel.yml index c5512d529..dbb49b4c2 100644 --- a/.github/workflows/zitadel.yml +++ b/.github/workflows/zitadel.yml @@ -19,7 +19,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v3 with: - go-version: 1.19 + go-version: '1.20' - name: Source checkout uses: actions/checkout@v3 with: diff --git a/build/zitadel/Dockerfile b/build/zitadel/Dockerfile index 18d3dc118..7b52b164e 100644 --- a/build/zitadel/Dockerfile +++ b/build/zitadel/Dockerfile @@ -1,4 +1,4 @@ -ARG GO_VERSION=1.19 +ARG GO_VERSION=1.20 ####################### ## Go dependencies diff --git a/go.mod b/go.mod index db53ffc42..4a122be56 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/zitadel/zitadel -go 1.19 +go 1.20 require ( cloud.google.com/go/storage v1.30.1 diff --git a/internal/api/authz/token.go b/internal/api/authz/token.go index b56d042da..615543093 100644 --- a/internal/api/authz/token.go +++ b/internal/api/authz/token.go @@ -20,7 +20,8 @@ import ( const ( BearerPrefix = "Bearer " - SessionTokenFormat = "sess_%s:%s" + SessionTokenPrefix = "sess_" + SessionTokenFormat = SessionTokenPrefix + "%s:%s" ) type TokenVerifier struct { diff --git a/internal/api/grpc/oidc/v2/oidc_integration_test.go b/internal/api/grpc/oidc/v2/oidc_integration_test.go index 78d04f64b..75c4fcaea 100644 --- a/internal/api/grpc/oidc/v2/oidc_integration_test.go +++ b/internal/api/grpc/oidc/v2/oidc_integration_test.go @@ -49,7 +49,9 @@ func TestMain(m *testing.M) { } func TestServer_GetAuthRequest(t *testing.T) { - client, err := Tester.CreateOIDCNativeClient(CTX, redirectURI) + project, err := Tester.CreateProject(CTX) + require.NoError(t, err) + client, err := Tester.CreateOIDCNativeClient(CTX, redirectURI, project.GetId()) require.NoError(t, err) authRequestID, err := Tester.CreateOIDCAuthRequest(client.GetClientId(), Tester.Users[integration.FirstInstanceUsersKey][integration.OrgOwner].ID, redirectURI) require.NoError(t, err) @@ -91,7 +93,9 @@ func TestServer_GetAuthRequest(t *testing.T) { } func TestServer_CreateCallback(t *testing.T) { - client, err := Tester.CreateOIDCNativeClient(CTX, redirectURI) + project, err := Tester.CreateProject(CTX) + require.NoError(t, err) + client, err := Tester.CreateOIDCNativeClient(CTX, redirectURI, project.GetId()) require.NoError(t, err) sessionResp, err := Tester.Client.SessionV2.CreateSession(CTX, &session.CreateSessionRequest{ Checks: &session.Checks{ diff --git a/internal/api/grpc/session/v2/session.go b/internal/api/grpc/session/v2/session.go index d92d873d7..e842476c2 100644 --- a/internal/api/grpc/session/v2/session.go +++ b/internal/api/grpc/session/v2/session.go @@ -156,10 +156,11 @@ func userFactorToPb(factor query.SessionUserFactor) *session.UserFactor { return nil } return &session.UserFactor{ - VerifiedAt: timestamppb.New(factor.UserCheckedAt), - Id: factor.UserID, - LoginName: factor.LoginName, - DisplayName: factor.DisplayName, + VerifiedAt: timestamppb.New(factor.UserCheckedAt), + Id: factor.UserID, + LoginName: factor.LoginName, + DisplayName: factor.DisplayName, + OrganisationId: factor.ResourceOwner, } } diff --git a/internal/api/grpc/session/v2/session_integration_test.go b/internal/api/grpc/session/v2/session_integration_test.go index 6d6398172..949d78ded 100644 --- a/internal/api/grpc/session/v2/session_integration_test.go +++ b/internal/api/grpc/session/v2/session_integration_test.go @@ -4,12 +4,15 @@ package session_test import ( "context" + "fmt" "os" "testing" "time" + "github.com/muhlemmer/gu" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "google.golang.org/grpc/metadata" "github.com/zitadel/zitadel/internal/integration" object "github.com/zitadel/zitadel/pkg/grpc/object/v2alpha" @@ -35,6 +38,7 @@ func TestMain(m *testing.M) { CTX, _ = Tester.WithAuthorization(ctx, integration.OrgOwner), errCtx User = Tester.CreateHumanUser(CTX) + Tester.SetUserPassword(CTX, User.GetUserId(), integration.UserPassword) Tester.RegisterUserPasskey(CTX, User.GetUserId()) return m.Run() }()) @@ -377,3 +381,51 @@ func TestServer_SetSession_flow(t *testing.T) { verifyCurrentSession(t, createResp.GetSessionId(), resp.GetSessionToken(), resp.GetDetails().GetSequence(), time.Minute, nil, wantFactors...) }) } + +func Test_ZITADEL_API_missing_authentication(t *testing.T) { + // create new, empty session + createResp, err := Client.CreateSession(CTX, &session.CreateSessionRequest{Domain: Tester.Config.ExternalDomain}) + require.NoError(t, err) + + ctx := metadata.AppendToOutgoingContext(context.Background(), "Authorization", fmt.Sprintf("Bearer %s", createResp.GetSessionToken())) + sessionResp, err := Tester.Client.SessionV2.GetSession(ctx, &session.GetSessionRequest{SessionId: createResp.GetSessionId()}) + require.Error(t, err) + require.Nil(t, sessionResp) +} + +func Test_ZITADEL_API_missing_mfa(t *testing.T) { + id, token, _, _ := Tester.CreatePasswordSession(t, CTX, User.GetUserId(), integration.UserPassword) + + ctx := metadata.AppendToOutgoingContext(context.Background(), "Authorization", fmt.Sprintf("Bearer %s", token)) + sessionResp, err := Tester.Client.SessionV2.GetSession(ctx, &session.GetSessionRequest{SessionId: id}) + require.Error(t, err) + require.Nil(t, sessionResp) +} + +func Test_ZITADEL_API_success(t *testing.T) { + id, token, _, _ := Tester.CreatePasskeySession(t, CTX, User.GetUserId()) + + ctx := metadata.AppendToOutgoingContext(context.Background(), "Authorization", fmt.Sprintf("Bearer %s", token)) + sessionResp, err := Tester.Client.SessionV2.GetSession(ctx, &session.GetSessionRequest{SessionId: id}) + require.NoError(t, err) + require.NotNil(t, id, sessionResp.GetSession().GetFactors().GetPasskey().GetVerifiedAt().AsTime()) +} + +func Test_ZITADEL_API_session_not_found(t *testing.T) { + id, token, _, _ := Tester.CreatePasskeySession(t, CTX, User.GetUserId()) + + // test session token works + ctx := metadata.AppendToOutgoingContext(context.Background(), "Authorization", fmt.Sprintf("Bearer %s", token)) + _, err := Tester.Client.SessionV2.GetSession(ctx, &session.GetSessionRequest{SessionId: id}) + require.NoError(t, err) + + //terminate the session and test it does not work anymore + _, err = Tester.Client.SessionV2.DeleteSession(CTX, &session.DeleteSessionRequest{ + SessionId: id, + SessionToken: gu.Ptr(token), + }) + require.NoError(t, err) + ctx = metadata.AppendToOutgoingContext(context.Background(), "Authorization", fmt.Sprintf("Bearer %s", token)) + _, err = Tester.Client.SessionV2.GetSession(ctx, &session.GetSessionRequest{SessionId: id}) + require.Error(t, err) +} diff --git a/internal/api/grpc/session/v2/session_test.go b/internal/api/grpc/session/v2/session_test.go index b8ae8df3a..410ab43dc 100644 --- a/internal/api/grpc/session/v2/session_test.go +++ b/internal/api/grpc/session/v2/session_test.go @@ -46,6 +46,7 @@ func Test_sessionsToPb(t *testing.T) { UserCheckedAt: past, LoginName: "donald", DisplayName: "donald duck", + ResourceOwner: "org1", }, Metadata: map[string][]byte{"hello": []byte("world")}, }, @@ -62,6 +63,7 @@ func Test_sessionsToPb(t *testing.T) { UserCheckedAt: past, LoginName: "donald", DisplayName: "donald duck", + ResourceOwner: "org1", }, PasswordFactor: query.SessionPasswordFactor{ PasswordCheckedAt: past, @@ -81,6 +83,7 @@ func Test_sessionsToPb(t *testing.T) { UserCheckedAt: past, LoginName: "donald", DisplayName: "donald duck", + ResourceOwner: "org1", }, PasskeyFactor: query.SessionPasskeyFactor{ PasskeyCheckedAt: past, @@ -105,10 +108,11 @@ func Test_sessionsToPb(t *testing.T) { Sequence: 123, Factors: &session.Factors{ User: &session.UserFactor{ - VerifiedAt: timestamppb.New(past), - Id: "345", - LoginName: "donald", - DisplayName: "donald duck", + VerifiedAt: timestamppb.New(past), + Id: "345", + LoginName: "donald", + DisplayName: "donald duck", + OrganisationId: "org1", }, }, Metadata: map[string][]byte{"hello": []byte("world")}, @@ -120,10 +124,11 @@ func Test_sessionsToPb(t *testing.T) { Sequence: 123, Factors: &session.Factors{ User: &session.UserFactor{ - VerifiedAt: timestamppb.New(past), - Id: "345", - LoginName: "donald", - DisplayName: "donald duck", + VerifiedAt: timestamppb.New(past), + Id: "345", + LoginName: "donald", + DisplayName: "donald duck", + OrganisationId: "org1", }, Password: &session.PasswordFactor{ VerifiedAt: timestamppb.New(past), @@ -138,10 +143,11 @@ func Test_sessionsToPb(t *testing.T) { Sequence: 123, Factors: &session.Factors{ User: &session.UserFactor{ - VerifiedAt: timestamppb.New(past), - Id: "345", - LoginName: "donald", - DisplayName: "donald duck", + VerifiedAt: timestamppb.New(past), + Id: "345", + LoginName: "donald", + DisplayName: "donald duck", + OrganisationId: "org1", }, Passkey: &session.PasskeyFactor{ VerifiedAt: timestamppb.New(past), diff --git a/internal/api/oidc/auth_request_integration_test.go b/internal/api/oidc/auth_request_integration_test.go index 1223ff47e..9d6b4c701 100644 --- a/internal/api/oidc/auth_request_integration_test.go +++ b/internal/api/oidc/auth_request_integration_test.go @@ -5,7 +5,6 @@ package oidc_test import ( "context" "net/url" - "os" "testing" "time" @@ -17,63 +16,14 @@ import ( 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/v2alpha" - user "github.com/zitadel/zitadel/pkg/grpc/user/v2alpha" ) var ( - CTX context.Context - CTXLOGIN context.Context - Tester *integration.Tester - User *user.AddHumanUserResponse + armPasskey = []string{oidc_api.UserPresence, oidc_api.MFA} + armPassword = []string{oidc_api.PWD} ) -const ( - redirectURI = "oidcIntegrationTest://callback" - redirectURIImplicit = "http://localhost:9999/callback" -) - -func TestMain(m *testing.M) { - os.Exit(func() int { - ctx, errCtx, cancel := integration.Contexts(5 * time.Minute) - defer cancel() - - Tester = integration.NewTester(ctx) - defer Tester.Done() - - CTX, _ = Tester.WithAuthorization(ctx, integration.OrgOwner), errCtx - User = Tester.CreateHumanUser(CTX) - Tester.RegisterUserPasskey(CTX, User.GetUserId()) - CTXLOGIN, _ = Tester.WithAuthorization(ctx, integration.Login), errCtx - return m.Run() - }()) -} - -func createClient(t testing.TB) string { - app, err := Tester.CreateOIDCNativeClient(CTX, redirectURI) - require.NoError(t, err) - return app.GetClientId() -} - -func createImplicitClient(t testing.TB) string { - app, err := Tester.CreateOIDCImplicitFlowClient(CTX, redirectURIImplicit) - require.NoError(t, err) - return app.GetClientId() -} - -func createAuthRequest(t testing.TB, clientID, redirectURI string, scope ...string) string { - redURL, err := Tester.CreateOIDCAuthRequest(clientID, Tester.Users[integration.FirstInstanceUsersKey][integration.Login].ID, redirectURI, scope...) - require.NoError(t, err) - return redURL -} - -func createAuthRequestImplicit(t testing.TB, clientID, redirectURI string, scope ...string) string { - redURL, err := Tester.CreateOIDCAuthRequestImplicit(clientID, Tester.Users[integration.FirstInstanceUsersKey][integration.Login].ID, redirectURI, scope...) - require.NoError(t, err) - return redURL -} - func TestOPStorage_CreateAuthRequest(t *testing.T) { clientID := createClient(t) @@ -101,7 +51,7 @@ func TestOPStorage_CreateAccessToken_code(t *testing.T) { tokens, err := exchangeTokens(t, clientID, code) require.NoError(t, err) assertTokens(t, tokens, false) - assertTokenClaims(t, tokens.IDTokenClaims, startTime, changeTime) + assertIDTokenClaims(t, tokens.IDTokenClaims, armPasskey, startTime, changeTime) // callback on a succeeded request must fail linkResp, err = Tester.Client.OIDCv2.CreateCallback(CTXLOGIN, &oidc_pb.CreateCallbackRequest{ @@ -155,7 +105,7 @@ func TestOPStorage_CreateAccessToken_implicit(t *testing.T) { require.NoError(t, err) claims, err := rp.VerifyTokens[*oidc.IDTokenClaims](context.Background(), accessToken, idToken, provider.IDTokenVerifier()) require.NoError(t, err) - assertTokenClaims(t, claims, startTime, changeTime) + assertIDTokenClaims(t, claims, armPasskey, startTime, changeTime) // callback on a succeeded request must fail linkResp, err = Tester.Client.OIDCv2.CreateCallback(CTXLOGIN, &oidc_pb.CreateCallbackRequest{ @@ -190,7 +140,7 @@ func TestOPStorage_CreateAccessAndRefreshTokens_code(t *testing.T) { tokens, err := exchangeTokens(t, clientID, code) require.NoError(t, err) assertTokens(t, tokens, true) - assertTokenClaims(t, tokens.IDTokenClaims, startTime, changeTime) + assertIDTokenClaims(t, tokens.IDTokenClaims, armPasskey, startTime, changeTime) } func TestOPStorage_CreateAccessAndRefreshTokens_refresh(t *testing.T) { @@ -215,7 +165,7 @@ func TestOPStorage_CreateAccessAndRefreshTokens_refresh(t *testing.T) { tokens, err := exchangeTokens(t, clientID, code) require.NoError(t, err) assertTokens(t, tokens, true) - assertTokenClaims(t, tokens.IDTokenClaims, startTime, changeTime) + assertIDTokenClaims(t, tokens.IDTokenClaims, armPasskey, startTime, changeTime) // test actual refresh grant newTokens, err := refreshTokens(t, clientID, tokens.RefreshToken) @@ -227,7 +177,7 @@ func TestOPStorage_CreateAccessAndRefreshTokens_refresh(t *testing.T) { claims, err := rp.VerifyTokens[*oidc.IDTokenClaims](context.Background(), newTokens.AccessToken, idToken, provider.IDTokenVerifier()) require.NoError(t, err) // auth time must still be the initial - assertTokenClaims(t, claims, startTime, changeTime) + assertIDTokenClaims(t, claims, armPasskey, startTime, changeTime) // refresh with an old refresh_token must fail _, err = rp.RefreshAccessToken(provider, tokens.RefreshToken, "", "") @@ -268,8 +218,8 @@ func assertTokens(t *testing.T, tokens *oidc.Tokens[*oidc.IDTokenClaims], requir } } -func assertTokenClaims(t *testing.T, claims *oidc.IDTokenClaims, sessionStart, sessionChange time.Time) { +func assertIDTokenClaims(t *testing.T, claims *oidc.IDTokenClaims, arm []string, sessionStart, sessionChange time.Time) { assert.Equal(t, User.GetUserId(), claims.Subject) - assert.Equal(t, []string{oidc_api.UserPresence, oidc_api.MFA}, claims.AuthenticationMethodsReferences) - assert.WithinRange(t, claims.AuthTime.AsTime().UTC(), sessionStart.Add(-1*time.Second), sessionChange.Add(1*time.Second)) + assert.Equal(t, arm, claims.AuthenticationMethodsReferences) + assertOIDCTimeRange(t, claims.AuthTime, sessionStart, sessionChange) } diff --git a/internal/api/oidc/client.go b/internal/api/oidc/client.go index a8c8a3feb..04b905998 100644 --- a/internal/api/oidc/client.go +++ b/internal/api/oidc/client.go @@ -6,6 +6,7 @@ import ( "encoding/json" "fmt" "strings" + "time" "github.com/dop251/goja" "github.com/zitadel/logging" @@ -17,6 +18,7 @@ import ( "github.com/zitadel/zitadel/internal/actions/object" "github.com/zitadel/zitadel/internal/api/authz" api_http "github.com/zitadel/zitadel/internal/api/http" + "github.com/zitadel/zitadel/internal/command" "github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/errors" @@ -119,18 +121,26 @@ func (o *OPStorage) AuthorizeClientIDSecret(ctx context.Context, id string, secr func (o *OPStorage) SetUserinfoFromToken(ctx context.Context, userInfo *oidc.UserInfo, tokenID, subject, origin string) (err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() + + if strings.HasPrefix(tokenID, command.IDPrefixV2) { + token, err := o.query.ActiveAccessTokenByToken(ctx, tokenID) + if err != nil { + return err + } + if err = o.isOriginAllowed(ctx, token.ClientID, origin); err != nil { + return err + } + return o.setUserinfo(ctx, userInfo, token.UserID, token.ClientID, token.Scope, nil) + } + token, err := o.repo.TokenByIDs(ctx, subject, tokenID) if err != nil { return errors.ThrowPermissionDenied(nil, "OIDC-Dsfb2", "token is not valid or has expired") } if token.ApplicationID != "" { - app, err := o.query.AppByOIDCClientID(ctx, token.ApplicationID, false) - if err != nil { + if err = o.isOriginAllowed(ctx, token.ApplicationID, origin); err != nil { return err } - if origin != "" && !api_http.IsOriginAllowed(app.OIDCConfig.AllowedOrigins, origin) { - return errors.ThrowPermissionDenied(nil, "OIDC-da1f3", "origin is not allowed") - } } return o.setUserinfo(ctx, userInfo, token.UserID, token.ApplicationID, token.Scopes, nil) } @@ -154,6 +164,24 @@ func (o *OPStorage) SetUserinfoFromScopes(ctx context.Context, userInfo *oidc.Us } func (o *OPStorage) SetIntrospectionFromToken(ctx context.Context, introspection *oidc.IntrospectionResponse, tokenID, subject, clientID string) (err error) { + ctx, span := tracing.NewSpan(ctx) + defer func() { span.EndWithError(err) }() + + if strings.HasPrefix(tokenID, command.IDPrefixV2) { + token, err := o.query.ActiveAccessTokenByToken(ctx, tokenID) + if err != nil { + return err + } + projectID, err := o.query.ProjectIDFromClientID(ctx, clientID, false) + if err != nil { + return errors.ThrowPermissionDenied(nil, "OIDC-Adfg5", "client not found") + } + return o.introspect(ctx, introspection, + tokenID, token.UserID, token.ClientID, projectID, + token.Audience, token.Scope, + token.AccessTokenCreation, token.AccessTokenExpiration) + } + token, err := o.repo.TokenByIDs(ctx, subject, tokenID) if err != nil { return errors.ThrowPermissionDenied(nil, "OIDC-Dsfb2", "token is not valid or has expired") @@ -168,27 +196,10 @@ func (o *OPStorage) SetIntrospectionFromToken(ctx context.Context, introspection return errors.ThrowPreconditionFailed(err, "OIDC-AGefw", "Errors.Internal") } } - for _, aud := range token.Audience { - if aud == clientID || aud == projectID { - userInfo := new(oidc.UserInfo) - err := o.setUserinfo(ctx, userInfo, subject, clientID, token.Scopes, []string{projectID}) // always - if err != nil { - return err - } - introspection.SetUserInfo(userInfo) - introspection.Scope = token.Scopes - introspection.ClientID = token.ApplicationID - introspection.TokenType = oidc.BearerToken - introspection.Expiration = oidc.FromTime(token.Expiration) - introspection.IssuedAt = oidc.FromTime(token.CreationDate) - introspection.NotBefore = oidc.FromTime(token.CreationDate) - introspection.Audience = token.Audience - introspection.Issuer = op.IssuerFromContext(ctx) - introspection.JWTID = token.ID - return nil - } - } - return errors.ThrowPermissionDenied(nil, "OIDC-sdg3G", "token is not valid for this client") + return o.introspect(ctx, introspection, + token.ID, token.UserID, token.ApplicationID, projectID, + token.Audience, token.Scopes, + token.CreationDate, token.Expiration) } func (o *OPStorage) ClientCredentialsTokenRequest(ctx context.Context, clientID string, scope []string) (op.TokenRequest, error) { @@ -230,6 +241,55 @@ func (o *OPStorage) ClientCredentials(ctx context.Context, clientID, clientSecre }, nil } +// isOriginAllowed checks whether a call by the client to the endpoint is allowed from the provided origin +// if no origin is provided, no error will be returned +func (o *OPStorage) isOriginAllowed(ctx context.Context, clientID, origin string) error { + if origin == "" { + return nil + } + app, err := o.query.AppByOIDCClientID(ctx, clientID, false) + if err != nil { + return err + } + if api_http.IsOriginAllowed(app.OIDCConfig.AllowedOrigins, origin) { + return nil + } + return errors.ThrowPermissionDenied(nil, "OIDC-da1f3", "origin is not allowed") +} + +func (o *OPStorage) introspect( + ctx context.Context, + introspection *oidc.IntrospectionResponse, + tokenID, subject, clientID, projectID string, + audience, scope []string, + tokenCreation, tokenExpiration time.Time, +) (err error) { + ctx, span := tracing.NewSpan(ctx) + defer func() { span.EndWithError(err) }() + + for _, aud := range audience { + if aud == clientID || aud == projectID { + userInfo := new(oidc.UserInfo) + err = o.setUserinfo(ctx, userInfo, subject, clientID, scope, []string{projectID}) // always + if err != nil { + return err + } + introspection.SetUserInfo(userInfo) + introspection.Scope = scope + introspection.ClientID = clientID + introspection.TokenType = oidc.BearerToken + introspection.Expiration = oidc.FromTime(tokenExpiration) + introspection.IssuedAt = oidc.FromTime(tokenCreation) + introspection.NotBefore = oidc.FromTime(tokenCreation) + introspection.Audience = audience + introspection.Issuer = op.IssuerFromContext(ctx) + introspection.JWTID = tokenID + return nil + } + } + return errors.ThrowPermissionDenied(nil, "OIDC-sdg3G", "token is not valid for this client") +} + func (o *OPStorage) checkOrgScopes(ctx context.Context, user *query.User, scopes []string) ([]string, error) { for i := len(scopes) - 1; i >= 0; i-- { scope := scopes[i] diff --git a/internal/api/oidc/client_integration_test.go b/internal/api/oidc/client_integration_test.go new file mode 100644 index 000000000..938c10283 --- /dev/null +++ b/internal/api/oidc/client_integration_test.go @@ -0,0 +1,135 @@ +//go:build integration + +package oidc_test + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/zitadel/oidc/v2/pkg/client/rp" + "github.com/zitadel/oidc/v2/pkg/client/rs" + "github.com/zitadel/oidc/v2/pkg/oidc" + + "github.com/zitadel/zitadel/pkg/grpc/authn" + "github.com/zitadel/zitadel/pkg/grpc/management" + oidc_pb "github.com/zitadel/zitadel/pkg/grpc/oidc/v2alpha" +) + +func TestOPStorage_SetUserinfoFromToken(t *testing.T) { + clientID := createClient(t) + authRequestID := createAuthRequest(t, clientID, redirectURI, oidc.ScopeOpenID, oidc.ScopeProfile, oidc.ScopeEmail, oidc.ScopeOfflineAccess) + sessionID, sessionToken, startTime, changeTime := Tester.CreatePasskeySession(t, CTXLOGIN, User.GetUserId()) + linkResp, err := Tester.Client.OIDCv2.CreateCallback(CTXLOGIN, &oidc_pb.CreateCallbackRequest{ + AuthRequestId: authRequestID, + CallbackKind: &oidc_pb.CreateCallbackRequest_Session{ + Session: &oidc_pb.Session{ + SessionId: sessionID, + SessionToken: sessionToken, + }, + }, + }) + require.NoError(t, err) + + // code exchange + code := assertCodeResponse(t, linkResp.GetCallbackUrl()) + tokens, err := exchangeTokens(t, clientID, code) + require.NoError(t, err) + assertTokens(t, tokens, true) + assertIDTokenClaims(t, tokens.IDTokenClaims, armPasskey, startTime, changeTime) + + // test actual userinfo + provider, err := Tester.CreateRelyingParty(clientID, redirectURI) + require.NoError(t, err) + userinfo, err := rp.Userinfo(tokens.AccessToken, tokens.TokenType, tokens.IDTokenClaims.Subject, provider) + require.NoError(t, err) + assertUserinfo(t, userinfo) +} + +func TestOPStorage_SetIntrospectionFromToken(t *testing.T) { + project, err := Tester.CreateProject(CTX) + require.NoError(t, err) + app, err := Tester.CreateOIDCNativeClient(CTX, redirectURI, project.GetId()) + require.NoError(t, err) + api, err := Tester.CreateAPIClient(CTX, project.GetId()) + require.NoError(t, err) + keyResp, err := Tester.Client.Mgmt.AddAppKey(CTX, &management.AddAppKeyRequest{ + ProjectId: project.GetId(), + AppId: api.GetAppId(), + Type: authn.KeyType_KEY_TYPE_JSON, + ExpirationDate: nil, + }) + require.NoError(t, err) + resourceServer, err := Tester.CreateResourceServer(keyResp.GetKeyDetails()) + require.NoError(t, err) + + scope := []string{oidc.ScopeOpenID, oidc.ScopeProfile, oidc.ScopeEmail, oidc.ScopeOfflineAccess} + authRequestID := createAuthRequest(t, app.GetClientId(), redirectURI, scope...) + sessionID, sessionToken, startTime, changeTime := Tester.CreatePasskeySession(t, CTXLOGIN, User.GetUserId()) + linkResp, err := Tester.Client.OIDCv2.CreateCallback(CTXLOGIN, &oidc_pb.CreateCallbackRequest{ + AuthRequestId: authRequestID, + CallbackKind: &oidc_pb.CreateCallbackRequest_Session{ + Session: &oidc_pb.Session{ + SessionId: sessionID, + SessionToken: sessionToken, + }, + }, + }) + require.NoError(t, err) + + // code exchange + code := assertCodeResponse(t, linkResp.GetCallbackUrl()) + tokens, err := exchangeTokens(t, app.GetClientId(), code) + require.NoError(t, err) + assertTokens(t, tokens, true) + assertIDTokenClaims(t, tokens.IDTokenClaims, armPasskey, startTime, changeTime) + + // test actual introspection + introspection, err := rs.Introspect(context.Background(), resourceServer, tokens.AccessToken) + require.NoError(t, err) + assertIntrospection(t, introspection, + Tester.OIDCIssuer(), app.GetClientId(), + scope, []string{app.GetClientId(), api.GetClientId(), project.GetId()}, + tokens.Expiry, tokens.Expiry.Add(-12*time.Hour)) +} + +func assertUserinfo(t *testing.T, userinfo *oidc.UserInfo) { + 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 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, introspection.Username, introspection.Email) + assert.False(t, bool(introspection.EmailVerified)) + assertOIDCTime(t, introspection.UpdatedAt, User.GetDetails().GetChangeDate().AsTime()) +} diff --git a/internal/api/oidc/oidc_integration_test.go b/internal/api/oidc/oidc_integration_test.go new file mode 100644 index 000000000..36624bc4f --- /dev/null +++ b/internal/api/oidc/oidc_integration_test.go @@ -0,0 +1,248 @@ +//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/v2/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" + oidc_pb "github.com/zitadel/zitadel/pkg/grpc/oidc/v2alpha" + session "github.com/zitadel/zitadel/pkg/grpc/session/v2alpha" + user "github.com/zitadel/zitadel/pkg/grpc/user/v2alpha" +) + +var ( + CTX context.Context + CTXLOGIN context.Context + Tester *integration.Tester + User *user.AddHumanUserResponse +) + +const ( + redirectURI = "oidcIntegrationTest://callback" + redirectURIImplicit = "http://localhost:9999/callback" + zitadelAudienceScope = domain.ProjectIDScope + domain.ProjectIDScopeZITADEL + domain.AudSuffix +) + +func TestMain(m *testing.M) { + os.Exit(func() int { + ctx, errCtx, cancel := integration.Contexts(5 * time.Minute) + defer cancel() + + Tester = integration.NewTester(ctx) + defer Tester.Done() + + CTX, _ = Tester.WithAuthorization(ctx, integration.OrgOwner), errCtx + User = Tester.CreateHumanUser(CTX) + Tester.SetUserPassword(CTX, User.GetUserId(), integration.UserPassword) + Tester.RegisterUserPasskey(CTX, User.GetUserId()) + CTXLOGIN, _ = Tester.WithAuthorization(ctx, integration.Login), errCtx + return m.Run() + }()) +} + +func Test_ZITADEL_API_missing_audience_scope(t *testing.T) { + clientID := createClient(t) + authRequestID := createAuthRequest(t, clientID, redirectURI, oidc.ScopeOpenID) + sessionID, sessionToken, startTime, changeTime := Tester.CreatePasskeySession(t, CTXLOGIN, User.GetUserId()) + linkResp, err := Tester.Client.OIDCv2.CreateCallback(CTXLOGIN, &oidc_pb.CreateCallbackRequest{ + AuthRequestId: authRequestID, + CallbackKind: &oidc_pb.CreateCallbackRequest_Session{ + Session: &oidc_pb.Session{ + SessionId: sessionID, + SessionToken: sessionToken, + }, + }, + }) + require.NoError(t, err) + + // code exchange + code := assertCodeResponse(t, linkResp.GetCallbackUrl()) + tokens, err := exchangeTokens(t, clientID, code) + require.NoError(t, err) + assertTokens(t, tokens, false) + assertIDTokenClaims(t, tokens.IDTokenClaims, armPasskey, startTime, changeTime) + + ctx := metadata.AppendToOutgoingContext(context.Background(), "Authorization", fmt.Sprintf("%s %s", tokens.TokenType, tokens.AccessToken)) + + myUserResp, err := Tester.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) + authRequestID := createAuthRequest(t, clientID, redirectURI, oidc.ScopeOpenID, zitadelAudienceScope) + createResp, err := Tester.Client.SessionV2.CreateSession(CTX, &session.CreateSessionRequest{ + Checks: &session.Checks{ + User: &session.CheckUser{ + Search: &session.CheckUser_UserId{UserId: User.GetUserId()}, + }, + }, + Domain: Tester.Config.ExternalDomain, + }) + require.NoError(t, err) + linkResp, err := Tester.Client.OIDCv2.CreateCallback(CTXLOGIN, &oidc_pb.CreateCallbackRequest{ + AuthRequestId: authRequestID, + CallbackKind: &oidc_pb.CreateCallbackRequest_Session{ + Session: &oidc_pb.Session{ + SessionId: createResp.GetSessionId(), + SessionToken: createResp.GetSessionToken(), + }, + }, + }) + require.NoError(t, err) + + // code exchange + code := assertCodeResponse(t, linkResp.GetCallbackUrl()) + tokens, err := exchangeTokens(t, clientID, code) + require.NoError(t, err) + + ctx := metadata.AppendToOutgoingContext(context.Background(), "Authorization", fmt.Sprintf("%s %s", tokens.TokenType, tokens.AccessToken)) + + myUserResp, err := Tester.Client.Auth.GetMyUser(ctx, &auth.GetMyUserRequest{}) + require.Error(t, err) + require.Nil(t, myUserResp) +} + +func Test_ZITADEL_API_missing_mfa(t *testing.T) { + clientID := createClient(t) + authRequestID := createAuthRequest(t, clientID, redirectURI, oidc.ScopeOpenID, zitadelAudienceScope) + sessionID, sessionToken, startTime, changeTime := Tester.CreatePasswordSession(t, CTX, User.GetUserId(), integration.UserPassword) + linkResp, err := Tester.Client.OIDCv2.CreateCallback(CTXLOGIN, &oidc_pb.CreateCallbackRequest{ + AuthRequestId: authRequestID, + CallbackKind: &oidc_pb.CreateCallbackRequest_Session{ + Session: &oidc_pb.Session{ + SessionId: sessionID, + SessionToken: sessionToken, + }, + }, + }) + require.NoError(t, err) + + // code exchange + code := assertCodeResponse(t, linkResp.GetCallbackUrl()) + tokens, err := exchangeTokens(t, clientID, code) + require.NoError(t, err) + assertIDTokenClaims(t, tokens.IDTokenClaims, armPassword, startTime, changeTime) + + ctx := metadata.AppendToOutgoingContext(context.Background(), "Authorization", fmt.Sprintf("%s %s", tokens.TokenType, tokens.AccessToken)) + + myUserResp, err := Tester.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) + authRequestID := createAuthRequest(t, clientID, redirectURI, oidc.ScopeOpenID, zitadelAudienceScope) + sessionID, sessionToken, startTime, changeTime := Tester.CreatePasskeySession(t, CTXLOGIN, User.GetUserId()) + linkResp, err := Tester.Client.OIDCv2.CreateCallback(CTXLOGIN, &oidc_pb.CreateCallbackRequest{ + AuthRequestId: authRequestID, + CallbackKind: &oidc_pb.CreateCallbackRequest_Session{ + Session: &oidc_pb.Session{ + SessionId: sessionID, + SessionToken: sessionToken, + }, + }, + }) + require.NoError(t, err) + + // code exchange + code := assertCodeResponse(t, linkResp.GetCallbackUrl()) + tokens, err := exchangeTokens(t, clientID, code) + require.NoError(t, err) + assertTokens(t, tokens, false) + assertIDTokenClaims(t, tokens.IDTokenClaims, armPasskey, startTime, changeTime) + + ctx := metadata.AppendToOutgoingContext(context.Background(), "Authorization", fmt.Sprintf("%s %s", tokens.TokenType, tokens.AccessToken)) + + myUserResp, err := Tester.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) + authRequestID := createAuthRequest(t, clientID, redirectURI, oidc.ScopeOpenID, oidc.ScopeOfflineAccess, zitadelAudienceScope) + sessionID, sessionToken, startTime, changeTime := Tester.CreatePasskeySession(t, CTXLOGIN, User.GetUserId()) + linkResp, err := Tester.Client.OIDCv2.CreateCallback(CTXLOGIN, &oidc_pb.CreateCallbackRequest{ + AuthRequestId: authRequestID, + CallbackKind: &oidc_pb.CreateCallbackRequest_Session{ + Session: &oidc_pb.Session{ + SessionId: sessionID, + SessionToken: sessionToken, + }, + }, + }) + require.NoError(t, err) + + // code exchange + code := assertCodeResponse(t, linkResp.GetCallbackUrl()) + tokens, err := exchangeTokens(t, clientID, code) + require.NoError(t, err) + assertTokens(t, tokens, true) + assertIDTokenClaims(t, tokens.IDTokenClaims, armPasskey, startTime, changeTime) + + // make sure token works + ctx := metadata.AppendToOutgoingContext(context.Background(), "Authorization", fmt.Sprintf("%s %s", tokens.TokenType, tokens.AccessToken)) + myUserResp, err := Tester.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 = Tester.Client.Auth.GetMyUser(ctx, &auth.GetMyUserRequest{}) + require.Error(t, err) + require.Nil(t, myUserResp) +} + +func createClient(t testing.TB) string { + project, err := Tester.CreateProject(CTX) + require.NoError(t, err) + app, err := Tester.CreateOIDCNativeClient(CTX, redirectURI, project.GetId()) + require.NoError(t, err) + return app.GetClientId() +} + +func createImplicitClient(t testing.TB) string { + app, err := Tester.CreateOIDCImplicitFlowClient(CTX, redirectURIImplicit) + require.NoError(t, err) + return app.GetClientId() +} + +func createAuthRequest(t testing.TB, clientID, redirectURI string, scope ...string) string { + redURL, err := Tester.CreateOIDCAuthRequest(clientID, Tester.Users[integration.FirstInstanceUsersKey][integration.Login].ID, redirectURI, scope...) + require.NoError(t, err) + return redURL +} + +func createAuthRequestImplicit(t testing.TB, clientID, redirectURI string, scope ...string) string { + redURL, err := Tester.CreateOIDCAuthRequestImplicit(clientID, Tester.Users[integration.FirstInstanceUsersKey][integration.Login].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(-1*time.Second), expectedEnd.Add(1*time.Second)) +} diff --git a/internal/authz/repository/eventsourcing/eventstore/token_verifier.go b/internal/authz/repository/eventsourcing/eventstore/token_verifier.go index b2bff6155..80305d007 100644 --- a/internal/authz/repository/eventsourcing/eventstore/token_verifier.go +++ b/internal/authz/repository/eventsourcing/eventstore/token_verifier.go @@ -15,7 +15,9 @@ import ( "github.com/zitadel/zitadel/internal/api/authz" http_util "github.com/zitadel/zitadel/internal/api/http" "github.com/zitadel/zitadel/internal/authz/repository/eventsourcing/view" + "github.com/zitadel/zitadel/internal/command" "github.com/zitadel/zitadel/internal/crypto" + "github.com/zitadel/zitadel/internal/domain" caos_errs "github.com/zitadel/zitadel/internal/errors" v1 "github.com/zitadel/zitadel/internal/eventstore/v1" "github.com/zitadel/zitadel/internal/eventstore/v1/models" @@ -28,7 +30,6 @@ import ( type TokenVerifierRepo struct { TokenVerificationKey crypto.EncryptionAlgorithm - IAMID string Eventstore v1.Eventstore View *view.View Query *query.Queries @@ -92,6 +93,21 @@ func (repo *TokenVerifierRepo) VerifyAccessToken(ctx context.Context, tokenStrin if !ok { return "", "", "", "", "", caos_errs.ThrowUnauthenticated(nil, "APP-Reb32", "invalid token") } + if strings.HasPrefix(tokenID, command.IDPrefixV2) { + userID, clientID, resourceOwner, err = repo.verifyAccessTokenV2(ctx, tokenID, verifierClientID, projectID) + return + } + if sessionID, ok := strings.CutPrefix(tokenID, authz.SessionTokenPrefix); ok { + userID, clientID, resourceOwner, err = repo.verifySessionToken(ctx, sessionID, tokenString) + return + } + return repo.verifyAccessTokenV1(ctx, tokenID, subject, verifierClientID, projectID) +} + +func (repo *TokenVerifierRepo) verifyAccessTokenV1(ctx context.Context, tokenID, subject, verifierClientID, projectID string) (userID string, agentID string, clientID, prefLang, resourceOwner string, err error) { + ctx, span := tracing.NewSpan(ctx) + defer func() { span.EndWithError(err) }() + _, tokenSpan := tracing.NewNamedSpan(ctx, "token") token, err := repo.tokenByID(ctx, tokenID, subject) tokenSpan.EndWithError(err) @@ -104,12 +120,89 @@ func (repo *TokenVerifierRepo) VerifyAccessToken(ctx context.Context, tokenStrin if token.IsPAT { return token.UserID, "", "", "", token.ResourceOwner, nil } - for _, aud := range token.Audience { - if verifierClientID == aud || projectID == aud { - return token.UserID, token.UserAgentID, token.ApplicationID, token.PreferredLanguage, token.ResourceOwner, nil - } + if err = verifyAudience(token.Audience, verifierClientID, projectID); err != nil { + return "", "", "", "", "", err } - return "", "", "", "", "", caos_errs.ThrowUnauthenticated(nil, "APP-Zxfako", "invalid audience") + return token.UserID, token.UserAgentID, token.ApplicationID, token.PreferredLanguage, token.ResourceOwner, nil +} + +func (repo *TokenVerifierRepo) verifyAccessTokenV2(ctx context.Context, token, verifierClientID, projectID string) (userID, clientID, resourceOwner string, err error) { + ctx, span := tracing.NewSpan(ctx) + defer func() { span.EndWithError(err) }() + + activeToken, err := repo.Query.ActiveAccessTokenByToken(ctx, token) + if err != nil { + return "", "", "", err + } + if err = verifyAudience(activeToken.Audience, verifierClientID, projectID); err != nil { + return "", "", "", err + } + if err = repo.checkAuthentication(ctx, activeToken.AuthMethods, activeToken.UserID); err != nil { + return "", "", "", err + } + return activeToken.UserID, activeToken.ClientID, activeToken.ResourceOwner, nil +} + +func (repo *TokenVerifierRepo) verifySessionToken(ctx context.Context, sessionID, token string) (userID, clientID, resourceOwner string, err error) { + ctx, span := tracing.NewSpan(ctx) + defer func() { span.EndWithError(err) }() + + session, err := repo.Query.SessionByID(ctx, false, sessionID, token) + if err != nil { + return "", "", "", err + } + if err = repo.checkAuthentication(ctx, authMethodsFromSession(session), session.UserFactor.UserID); err != nil { + return "", "", "", err + } + return session.UserFactor.UserID, "", session.UserFactor.ResourceOwner, nil +} + +// checkAuthentication ensures the session or token was authenticated (at least a single [domain.UserAuthMethodType]). +// It will also check if there was a multi factor authentication, if either MFA is forced by the login policy or if the user has set up any +func (repo *TokenVerifierRepo) checkAuthentication(ctx context.Context, authMethods []domain.UserAuthMethodType, userID string) error { + if len(authMethods) == 0 { + return caos_errs.ThrowPermissionDenied(nil, "AUTHZ-Kl3p0", "authentication required") + } + if domain.HasMFA(authMethods) { + return nil + } + availableAuthMethods, forceMFA, err := repo.Query.ListUserAuthMethodTypesRequired(setCallerCtx(ctx, userID), userID, false) + if err != nil { + return err + } + if forceMFA || domain.HasMFA(availableAuthMethods) { + return caos_errs.ThrowPermissionDenied(nil, "AUTHZ-Kl3p0", "mfa required") + } + return nil +} + +func authMethodsFromSession(session *query.Session) []domain.UserAuthMethodType { + types := make([]domain.UserAuthMethodType, 0, domain.UserAuthMethodTypeIDP) + if !session.PasswordFactor.PasswordCheckedAt.IsZero() { + types = append(types, domain.UserAuthMethodTypePassword) + } + if !session.PasskeyFactor.PasskeyCheckedAt.IsZero() { + types = append(types, domain.UserAuthMethodTypePasswordless) + } + if !session.IntentFactor.IntentCheckedAt.IsZero() { + types = append(types, domain.UserAuthMethodTypeIDP) + } + // TODO: add checks with https://github.com/zitadel/zitadel/issues/5477 + /* + if !session.TOTPFactor.TOTPCheckedAt.IsZero() { + types = append(types, domain.UserAuthMethodTypeOTP) + } + if !session.U2FFactor.U2FCheckedAt.IsZero() { + types = append(types, domain.UserAuthMethodTypeU2F) + } + */ + return types +} + +func setCallerCtx(ctx context.Context, userID string) context.Context { + ctxData := authz.GetCtxData(ctx) + ctxData.UserID = userID + return authz.SetCtxData(ctx, ctxData) } func (repo *TokenVerifierRepo) ProjectIDAndOriginsByClientID(ctx context.Context, clientID string) (projectID string, origins []string, err error) { @@ -184,6 +277,15 @@ func (repo *TokenVerifierRepo) decryptAccessToken(token string) (string, error) return tokenIDSubject, nil } +func verifyAudience(audience []string, verifierClientID, projectID string) error { + for _, aud := range audience { + if verifierClientID == aud || projectID == aud { + return nil + } + } + return caos_errs.ThrowUnauthenticated(nil, "APP-Zxfako", "invalid audience") +} + type openIDKeySet struct { *query.Queries } diff --git a/internal/command/oidc_session.go b/internal/command/oidc_session.go index 2505c22b4..7a5f5dfda 100644 --- a/internal/command/oidc_session.go +++ b/internal/command/oidc_session.go @@ -14,6 +14,7 @@ import ( "github.com/zitadel/zitadel/internal/id" "github.com/zitadel/zitadel/internal/repository/authrequest" "github.com/zitadel/zitadel/internal/repository/oidcsession" + "github.com/zitadel/zitadel/internal/repository/user" ) // AddOIDCSessionAccessToken creates a new OIDC Session, creates an access token and returns its id and expiration. @@ -101,6 +102,10 @@ func (c *Commands) newOIDCSessionAddEvents(ctx context.Context, authRequestID st if sessionWriteModel.State != domain.SessionStateActive { return nil, caos_errs.ThrowPreconditionFailed(nil, "OIDCS-sjkl3", "Errors.Session.Terminated") } + resourceOwner, err := c.getResourceOwnerOfSessionUser(ctx, sessionWriteModel.UserID, sessionWriteModel.InstanceID) + if err != nil { + return nil, err + } accessTokenLifetime, refreshTokenLifeTime, refreshTokenIdleLifetime, err := c.tokenTokenLifetimes(ctx) if err != nil { return nil, err @@ -114,7 +119,7 @@ func (c *Commands) newOIDCSessionAddEvents(ctx context.Context, authRequestID st eventstore: c.eventstore, idGenerator: c.idGenerator, encryptionAlg: c.keyAlgorithm, - oidcSessionWriteModel: NewOIDCSessionWriteModel(sessionID, authz.GetInstance(ctx).InstanceID()), + oidcSessionWriteModel: NewOIDCSessionWriteModel(sessionID, resourceOwner), sessionWriteModel: sessionWriteModel, authRequestWriteModel: authRequestWriteModel, accessTokenLifetime: accessTokenLifetime, @@ -123,6 +128,22 @@ func (c *Commands) newOIDCSessionAddEvents(ctx context.Context, authRequestID st }, nil } +func (c *Commands) getResourceOwnerOfSessionUser(ctx context.Context, userID, instanceID string) (string, error) { + events, err := c.eventstore.Filter(ctx, eventstore.NewSearchQueryBuilder(eventstore.ColumnsEvent). + InstanceID(instanceID). + AllowTimeTravel(). + OrderAsc(). + Limit(1). + AddQuery(). + AggregateTypes(user.AggregateType). + AggregateIDs(userID). + Builder()) + if err != nil || len(events) != 1 { + return "", caos_errs.ThrowInternal(err, "OIDCS-sferh", "Errors.Internal") + } + return events[0].Aggregate().ResourceOwner, nil +} + func (c *Commands) decryptRefreshToken(refreshToken string) (refreshTokenID string, err error) { decoded, err := base64.RawURLEncoding.DecodeString(refreshToken) if err != nil { @@ -144,7 +165,7 @@ func (c *Commands) newOIDCSessionUpdateEvents(ctx context.Context, oidcSessionID if err != nil { return nil, err } - sessionWriteModel := NewOIDCSessionWriteModel(oidcSessionID, authz.GetInstance(ctx).InstanceID()) + sessionWriteModel := NewOIDCSessionWriteModel(oidcSessionID, "") if err = c.eventstore.FilterToQueryReducer(ctx, sessionWriteModel); err != nil { return nil, err } diff --git a/internal/command/oidc_session_model.go b/internal/command/oidc_session_model.go index 31eb98b45..4a25bf6f4 100644 --- a/internal/command/oidc_session_model.go +++ b/internal/command/oidc_session_model.go @@ -20,6 +20,7 @@ type OIDCSessionWriteModel struct { AuthMethods []domain.UserAuthMethodType AuthTime time.Time State domain.OIDCSessionState + AccessTokenCreation time.Time AccessTokenExpiration time.Time RefreshTokenID string RefreshTokenExpiration time.Time @@ -82,6 +83,11 @@ func (wm *OIDCSessionWriteModel) reduceAdded(e *oidcsession.AddedEvent) { wm.AuthMethods = e.AuthMethods wm.AuthTime = e.AuthTime wm.State = domain.OIDCSessionStateActive + // the write model might be initialized without resource owner, + // so update the aggregate + if wm.ResourceOwner == "" { + wm.aggregate = &oidcsession.NewAggregate(wm.AggregateID, e.Aggregate().ResourceOwner).Aggregate + } } func (wm *OIDCSessionWriteModel) reduceAccessTokenAdded(e *oidcsession.AccessTokenAddedEvent) { diff --git a/internal/command/oidc_session_test.go b/internal/command/oidc_session_test.go index 3bbc8f579..9f0d305f9 100644 --- a/internal/command/oidc_session_test.go +++ b/internal/command/oidc_session_test.go @@ -9,6 +9,7 @@ import ( "github.com/muhlemmer/gu" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "golang.org/x/text/language" "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/crypto" @@ -21,6 +22,7 @@ import ( "github.com/zitadel/zitadel/internal/repository/authrequest" "github.com/zitadel/zitadel/internal/repository/oidcsession" "github.com/zitadel/zitadel/internal/repository/session" + "github.com/zitadel/zitadel/internal/repository/user" ) var ( @@ -108,7 +110,7 @@ func TestCommands_AddOIDCSessionAccessToken(t *testing.T) { authrequest.NewCodeExchangedEvent(context.Background(), &authrequest.NewAggregate("V2_authRequestID", "instanceID").Aggregate), ), ), - expectFilter(), + expectFilter(), // inactive session ), }, args{ @@ -173,15 +175,22 @@ func TestCommands_AddOIDCSessionAccessToken(t *testing.T) { testNow), ), ), + expectFilter( + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), &user.NewAggregate("userID", "org1").Aggregate, + "username", "firstName", "lastName", "", "", language.English, domain.GenderUnspecified, "", false, + ), + ), + ), expectFilter(), // token lifetime expectPush( []*repository.Event{ eventFromEventPusherWithInstanceID("instanceID", - oidcsession.NewAddedEvent(context.Background(), &oidcsession.NewAggregate("V2_oidcSessionID", "instanceID").Aggregate, + oidcsession.NewAddedEvent(context.Background(), &oidcsession.NewAggregate("V2_oidcSessionID", "org1").Aggregate, "userID", "sessionID", "clientID", []string{"audience"}, []string{"openid"}, []domain.UserAuthMethodType{domain.UserAuthMethodTypePassword}, testNow), ), eventFromEventPusherWithInstanceID("instanceID", - oidcsession.NewAccessTokenAddedEvent(context.Background(), &oidcsession.NewAggregate("V2_oidcSessionID", "instanceID").Aggregate, + oidcsession.NewAccessTokenAddedEvent(context.Background(), &oidcsession.NewAggregate("V2_oidcSessionID", "org1").Aggregate, "accessTokenID", []string{"openid"}, time.Hour), ), eventFromEventPusherWithInstanceID("instanceID", @@ -302,7 +311,7 @@ func TestCommands_AddOIDCSessionRefreshAndAccessToken(t *testing.T) { authrequest.NewCodeExchangedEvent(context.Background(), &authrequest.NewAggregate("V2_authRequestID", "instanceID").Aggregate), ), ), - expectFilter(), + expectFilter(), // inactive session ), }, args{ @@ -367,19 +376,26 @@ func TestCommands_AddOIDCSessionRefreshAndAccessToken(t *testing.T) { testNow), ), ), + expectFilter( + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), &user.NewAggregate("userID", "org1").Aggregate, + "username", "firstName", "lastName", "", "", language.English, domain.GenderUnspecified, "", false, + ), + ), + ), expectFilter(), // token lifetime expectPush( []*repository.Event{ eventFromEventPusherWithInstanceID("instanceID", - oidcsession.NewAddedEvent(context.Background(), &oidcsession.NewAggregate("V2_oidcSessionID", "instanceID").Aggregate, + oidcsession.NewAddedEvent(context.Background(), &oidcsession.NewAggregate("V2_oidcSessionID", "org1").Aggregate, "userID", "sessionID", "clientID", []string{"audience"}, []string{"openid", "offline_access"}, []domain.UserAuthMethodType{domain.UserAuthMethodTypePassword}, testNow), ), eventFromEventPusherWithInstanceID("instanceID", - oidcsession.NewAccessTokenAddedEvent(context.Background(), &oidcsession.NewAggregate("V2_oidcSessionID", "instanceID").Aggregate, + oidcsession.NewAccessTokenAddedEvent(context.Background(), &oidcsession.NewAggregate("V2_oidcSessionID", "org1").Aggregate, "accessTokenID", []string{"openid", "offline_access"}, time.Hour), ), eventFromEventPusherWithInstanceID("instanceID", - oidcsession.NewRefreshTokenAddedEvent(context.Background(), &oidcsession.NewAggregate("V2_oidcSessionID", "instanceID").Aggregate, + oidcsession.NewRefreshTokenAddedEvent(context.Background(), &oidcsession.NewAggregate("V2_oidcSessionID", "org1").Aggregate, "refreshTokenID", 7*24*time.Hour, 24*time.Hour), ), eventFromEventPusherWithInstanceID("instanceID", @@ -489,11 +505,11 @@ func TestCommands_ExchangeOIDCSessionRefreshAndAccessToken(t *testing.T) { eventstore: eventstoreExpect(t, expectFilter( eventFromEventPusher( - oidcsession.NewAddedEvent(context.Background(), &oidcsession.NewAggregate("V2_oidcSessionID", "instanceID").Aggregate, + oidcsession.NewAddedEvent(context.Background(), &oidcsession.NewAggregate("V2_oidcSessionID", "org1").Aggregate, "userID", "sessionID", "clientID", []string{"audience"}, []string{"openid", "profile", "offline_access"}, []domain.UserAuthMethodType{domain.UserAuthMethodTypePassword}, testNow), ), eventFromEventPusher( - oidcsession.NewAccessTokenAddedEvent(context.Background(), &oidcsession.NewAggregate("V2_oidcSessionID", "instanceID").Aggregate, + oidcsession.NewAccessTokenAddedEvent(context.Background(), &oidcsession.NewAggregate("V2_oidcSessionID", "org1").Aggregate, "accessTokenID", []string{"openid", "profile", "offline_access"}, time.Hour), ), ), @@ -515,15 +531,15 @@ func TestCommands_ExchangeOIDCSessionRefreshAndAccessToken(t *testing.T) { eventstore: eventstoreExpect(t, expectFilter( eventFromEventPusher( - oidcsession.NewAddedEvent(context.Background(), &oidcsession.NewAggregate("V2_oidcSessionID", "instanceID").Aggregate, + oidcsession.NewAddedEvent(context.Background(), &oidcsession.NewAggregate("V2_oidcSessionID", "org1").Aggregate, "userID", "sessionID", "clientID", []string{"audience"}, []string{"openid", "profile", "offline_access"}, []domain.UserAuthMethodType{domain.UserAuthMethodTypePassword}, testNow), ), eventFromEventPusher( - oidcsession.NewAccessTokenAddedEvent(context.Background(), &oidcsession.NewAggregate("V2_oidcSessionID", "instanceID").Aggregate, + oidcsession.NewAccessTokenAddedEvent(context.Background(), &oidcsession.NewAggregate("V2_oidcSessionID", "org1").Aggregate, "accessTokenID", []string{"openid", "profile", "offline_access"}, time.Hour), ), eventFromEventPusher( - oidcsession.NewRefreshTokenAddedEvent(context.Background(), &oidcsession.NewAggregate("V2_oidcSessionID", "instanceID").Aggregate, + oidcsession.NewRefreshTokenAddedEvent(context.Background(), &oidcsession.NewAggregate("V2_oidcSessionID", "org1").Aggregate, "refreshTokenID", 7*24*time.Hour, 24*time.Hour), ), ), @@ -545,15 +561,15 @@ func TestCommands_ExchangeOIDCSessionRefreshAndAccessToken(t *testing.T) { eventstore: eventstoreExpect(t, expectFilter( eventFromEventPusherWithCreationDateNow( - oidcsession.NewAddedEvent(context.Background(), &oidcsession.NewAggregate("V2_oidcSessionID", "instanceID").Aggregate, + oidcsession.NewAddedEvent(context.Background(), &oidcsession.NewAggregate("V2_oidcSessionID", "org1").Aggregate, "userID", "sessionID", "clientID", []string{"audience"}, []string{"openid", "profile", "offline_access"}, []domain.UserAuthMethodType{domain.UserAuthMethodTypePassword}, testNow), ), eventFromEventPusherWithCreationDateNow( - oidcsession.NewAccessTokenAddedEvent(context.Background(), &oidcsession.NewAggregate("V2_oidcSessionID", "instanceID").Aggregate, + oidcsession.NewAccessTokenAddedEvent(context.Background(), &oidcsession.NewAggregate("V2_oidcSessionID", "org1").Aggregate, "accessTokenID", []string{"openid", "profile", "offline_access"}, time.Hour), ), eventFromEventPusherWithCreationDateNow( - oidcsession.NewRefreshTokenAddedEvent(context.Background(), &oidcsession.NewAggregate("V2_oidcSessionID", "instanceID").Aggregate, + oidcsession.NewRefreshTokenAddedEvent(context.Background(), &oidcsession.NewAggregate("V2_oidcSessionID", "org1").Aggregate, "refreshTokenID", 7*24*time.Hour, 24*time.Hour), ), ), @@ -561,11 +577,11 @@ func TestCommands_ExchangeOIDCSessionRefreshAndAccessToken(t *testing.T) { expectPush( []*repository.Event{ eventFromEventPusherWithInstanceID("instanceID", - oidcsession.NewAccessTokenAddedEvent(context.Background(), &oidcsession.NewAggregate("V2_oidcSessionID", "instanceID").Aggregate, + oidcsession.NewAccessTokenAddedEvent(context.Background(), &oidcsession.NewAggregate("V2_oidcSessionID", "org1").Aggregate, "accessTokenID", []string{"openid", "offline_access"}, time.Hour), ), eventFromEventPusherWithInstanceID("instanceID", - oidcsession.NewRefreshTokenRenewedEvent(context.Background(), &oidcsession.NewAggregate("V2_oidcSessionID", "instanceID").Aggregate, + oidcsession.NewRefreshTokenRenewedEvent(context.Background(), &oidcsession.NewAggregate("V2_oidcSessionID", "org1").Aggregate, "refreshTokenID2", 24*time.Hour), ), }, @@ -668,11 +684,11 @@ func TestCommands_OIDCSessionByRefreshToken(t *testing.T) { eventstore: eventstoreExpect(t, expectFilter( eventFromEventPusher( - oidcsession.NewAddedEvent(context.Background(), &oidcsession.NewAggregate("V2_oidcSessionID", "instanceID").Aggregate, + oidcsession.NewAddedEvent(context.Background(), &oidcsession.NewAggregate("V2_oidcSessionID", "org1").Aggregate, "userID", "sessionID", "clientID", []string{"audience"}, []string{"openid", "profile", "offline_access"}, []domain.UserAuthMethodType{domain.UserAuthMethodTypePassword}, testNow), ), eventFromEventPusher( - oidcsession.NewAccessTokenAddedEvent(context.Background(), &oidcsession.NewAggregate("V2_oidcSessionID", "instanceID").Aggregate, + oidcsession.NewAccessTokenAddedEvent(context.Background(), &oidcsession.NewAggregate("V2_oidcSessionID", "org1").Aggregate, "accessTokenID", []string{"openid", "profile", "offline_access"}, time.Hour), ), ), @@ -693,15 +709,15 @@ func TestCommands_OIDCSessionByRefreshToken(t *testing.T) { eventstore: eventstoreExpect(t, expectFilter( eventFromEventPusher( - oidcsession.NewAddedEvent(context.Background(), &oidcsession.NewAggregate("V2_oidcSessionID", "instanceID").Aggregate, + oidcsession.NewAddedEvent(context.Background(), &oidcsession.NewAggregate("V2_oidcSessionID", "org1").Aggregate, "userID", "sessionID", "clientID", []string{"audience"}, []string{"openid", "profile", "offline_access"}, []domain.UserAuthMethodType{domain.UserAuthMethodTypePassword}, testNow), ), eventFromEventPusher( - oidcsession.NewAccessTokenAddedEvent(context.Background(), &oidcsession.NewAggregate("V2_oidcSessionID", "instanceID").Aggregate, + oidcsession.NewAccessTokenAddedEvent(context.Background(), &oidcsession.NewAggregate("V2_oidcSessionID", "org1").Aggregate, "accessTokenID", []string{"openid", "profile", "offline_access"}, time.Hour), ), eventFromEventPusher( - oidcsession.NewRefreshTokenAddedEvent(context.Background(), &oidcsession.NewAggregate("V2_oidcSessionID", "instanceID").Aggregate, + oidcsession.NewRefreshTokenAddedEvent(context.Background(), &oidcsession.NewAggregate("V2_oidcSessionID", "org1").Aggregate, "refreshTokenID", 7*24*time.Hour, 24*time.Hour), ), ), @@ -722,15 +738,15 @@ func TestCommands_OIDCSessionByRefreshToken(t *testing.T) { eventstore: eventstoreExpect(t, expectFilter( eventFromEventPusherWithCreationDateNow( - oidcsession.NewAddedEvent(context.Background(), &oidcsession.NewAggregate("V2_oidcSessionID", "instanceID").Aggregate, + oidcsession.NewAddedEvent(context.Background(), &oidcsession.NewAggregate("V2_oidcSessionID", "org1").Aggregate, "userID", "sessionID", "clientID", []string{"audience"}, []string{"openid", "profile", "offline_access"}, []domain.UserAuthMethodType{domain.UserAuthMethodTypePassword}, testNow), ), eventFromEventPusherWithCreationDateNow( - oidcsession.NewAccessTokenAddedEvent(context.Background(), &oidcsession.NewAggregate("V2_oidcSessionID", "instanceID").Aggregate, + oidcsession.NewAccessTokenAddedEvent(context.Background(), &oidcsession.NewAggregate("V2_oidcSessionID", "org1").Aggregate, "accessTokenID", []string{"openid", "profile", "offline_access"}, time.Hour), ), eventFromEventPusherWithCreationDateNow( - oidcsession.NewRefreshTokenAddedEvent(context.Background(), &oidcsession.NewAggregate("V2_oidcSessionID", "instanceID").Aggregate, + oidcsession.NewRefreshTokenAddedEvent(context.Background(), &oidcsession.NewAggregate("V2_oidcSessionID", "org1").Aggregate, "refreshTokenID", 7*24*time.Hour, 24*time.Hour), ), ), diff --git a/internal/domain/user.go b/internal/domain/user.go index a5e01361f..b73b1df4d 100644 --- a/internal/domain/user.go +++ b/internal/domain/user.go @@ -60,6 +60,31 @@ func (f UserAuthMethodType) Valid() bool { return f >= 0 && f < userAuthMethodTypeCount } +// HasMFA checks whether the user authenticated with multiple auth factors. +// This can either be true if the list contains a [UserAuthMethodType] which by itself is MFA (e.g. [UserAuthMethodTypePasswordless]) +// or if multiple factors were used (e.g. [UserAuthMethodTypePassword] and [UserAuthMethodTypeU2F]) +func HasMFA(methods []UserAuthMethodType) bool { + var factors int + for _, method := range methods { + switch method { + case UserAuthMethodTypePassword: + factors++ + case UserAuthMethodTypePasswordless: + return true + case UserAuthMethodTypeU2F: + factors++ + case UserAuthMethodTypeOTP: + factors++ + case UserAuthMethodTypeIDP: + factors++ + case UserAuthMethodTypeUnspecified, + userAuthMethodTypeCount: + // ignore + } + } + return factors > 1 +} + type PersonalAccessTokenState int32 const ( diff --git a/internal/integration/client.go b/internal/integration/client.go index d4849a0fa..65e0e9ab1 100644 --- a/internal/integration/client.go +++ b/internal/integration/client.go @@ -17,6 +17,7 @@ import ( openid "github.com/zitadel/zitadel/internal/idp/providers/oidc" "github.com/zitadel/zitadel/internal/repository/idp" "github.com/zitadel/zitadel/pkg/grpc/admin" + "github.com/zitadel/zitadel/pkg/grpc/auth" mgmt "github.com/zitadel/zitadel/pkg/grpc/management" object "github.com/zitadel/zitadel/pkg/grpc/object/v2alpha" oidc_pb "github.com/zitadel/zitadel/pkg/grpc/oidc/v2alpha" @@ -29,6 +30,7 @@ type Client struct { CC *grpc.ClientConn Admin admin.AdminServiceClient Mgmt mgmt.ManagementServiceClient + Auth auth.AuthServiceClient UserV2 user.UserServiceClient SessionV2 session.SessionServiceClient OIDCv2 oidc_pb.OIDCServiceClient @@ -40,6 +42,7 @@ func newClient(cc *grpc.ClientConn) Client { CC: cc, Admin: admin.NewAdminServiceClient(cc), Mgmt: mgmt.NewManagementServiceClient(cc), + Auth: auth.NewAuthServiceClient(cc), UserV2: user.NewUserServiceClient(cc), SessionV2: session.NewSessionServiceClient(cc), OIDCv2: oidc_pb.NewOIDCServiceClient(cc), @@ -134,6 +137,14 @@ func (s *Tester) RegisterUserPasskey(ctx context.Context, userID string) { logging.OnError(err).Fatal("create user passkey") } +func (s *Tester) SetUserPassword(ctx context.Context, userID, password string) { + _, err := s.Client.UserV2.SetPassword(ctx, &user.SetPasswordRequest{ + UserId: userID, + NewPassword: &user.Password{Password: password}, + }) + logging.OnError(err).Fatal("set user password") +} + func (s *Tester) AddGenericOAuthProvider(t *testing.T) string { ctx := authz.WithInstance(context.Background(), s.Instance) id, _, err := s.Commands.AddOrgGenericOAuthProvider(ctx, s.Organisation.ID, command.GenericOAuthProvider{ @@ -219,3 +230,20 @@ func (s *Tester) CreatePasskeySession(t *testing.T, ctx context.Context, userID return createResp.GetSessionId(), updateResp.GetSessionToken(), createResp.GetDetails().GetChangeDate().AsTime(), updateResp.GetDetails().GetChangeDate().AsTime() } + +func (s *Tester) CreatePasswordSession(t *testing.T, ctx context.Context, userID, password string) (id, token string, start, change time.Time) { + createResp, err := s.Client.SessionV2.CreateSession(ctx, &session.CreateSessionRequest{ + Checks: &session.Checks{ + User: &session.CheckUser{ + Search: &session.CheckUser_UserId{UserId: userID}, + }, + Password: &session.CheckPassword{ + Password: password, + }, + }, + Domain: s.Config.ExternalDomain, + }) + require.NoError(t, err) + return createResp.GetSessionId(), createResp.GetSessionToken(), + createResp.GetDetails().GetChangeDate().AsTime(), createResp.GetDetails().GetChangeDate().AsTime() +} diff --git a/internal/integration/integration.go b/internal/integration/integration.go index fe5e081ef..8634095f3 100644 --- a/internal/integration/integration.go +++ b/internal/integration/integration.go @@ -64,6 +64,7 @@ const ( const ( FirstInstanceUsersKey = "first" + UserPassword = "VeryS3cret!" ) // User information with a Personal Access Token. diff --git a/internal/integration/oidc.go b/internal/integration/oidc.go index 42f2ab174..1dbfdc35e 100644 --- a/internal/integration/oidc.go +++ b/internal/integration/oidc.go @@ -8,25 +8,20 @@ import ( "strings" "time" + "github.com/zitadel/oidc/v2/pkg/client" "github.com/zitadel/oidc/v2/pkg/client/rp" + "github.com/zitadel/oidc/v2/pkg/client/rs" "github.com/zitadel/oidc/v2/pkg/oidc" http_util "github.com/zitadel/zitadel/internal/api/http" oidc_internal "github.com/zitadel/zitadel/internal/api/oidc" - "github.com/zitadel/zitadel/internal/command" "github.com/zitadel/zitadel/pkg/grpc/app" "github.com/zitadel/zitadel/pkg/grpc/management" ) -func (s *Tester) CreateOIDCNativeClient(ctx context.Context, redirectURI string) (*management.AddOIDCAppResponse, error) { - project, err := s.Client.Mgmt.AddProject(ctx, &management.AddProjectRequest{ - Name: fmt.Sprintf("project-%d", time.Now().UnixNano()), - }) - if err != nil { - return nil, err - } +func (s *Tester) CreateOIDCNativeClient(ctx context.Context, redirectURI, projectID string) (*management.AddOIDCAppResponse, error) { return s.Client.Mgmt.AddOIDCApp(ctx, &management.AddOIDCAppRequest{ - ProjectId: project.GetId(), + ProjectId: projectID, Name: fmt.Sprintf("app-%d", time.Now().UnixNano()), RedirectUris: []string{redirectURI}, ResponseTypes: []app.OIDCResponseType{app.OIDCResponseType_OIDC_RESPONSE_TYPE_CODE}, @@ -74,6 +69,20 @@ func (s *Tester) CreateOIDCImplicitFlowClient(ctx context.Context, redirectURI s }) } +func (s *Tester) CreateProject(ctx context.Context) (*management.AddProjectResponse, error) { + return s.Client.Mgmt.AddProject(ctx, &management.AddProjectRequest{ + Name: fmt.Sprintf("project-%d", time.Now().UnixNano()), + }) +} + +func (s *Tester) CreateAPIClient(ctx context.Context, projectID string) (*management.AddAPIAppResponse, error) { + return s.Client.Mgmt.AddAPIApp(ctx, &management.AddAPIAppRequest{ + ProjectId: projectID, + Name: fmt.Sprintf("api-%d", time.Now().UnixNano()), + AuthMethodType: app.APIAuthMethodType_API_AUTH_METHOD_TYPE_PRIVATE_KEY_JWT, + }) +} + func (s *Tester) CreateOIDCAuthRequest(clientID, loginClient, redirectURI string, scope ...string) (authRequestID string, err error) { provider, err := s.CreateRelyingParty(clientID, redirectURI, scope...) if err != nil { @@ -123,12 +132,23 @@ func (s *Tester) CreateOIDCAuthRequestImplicit(clientID, loginClient, redirectUR return strings.TrimPrefix(loc.String(), prefixWithHost), nil } +func (s *Tester) OIDCIssuer() string { + return http_util.BuildHTTP(s.Config.ExternalDomain, s.Config.Port, s.Config.ExternalSecure) +} + func (s *Tester) CreateRelyingParty(clientID, redirectURI string, scope ...string) (rp.RelyingParty, error) { - issuer := http_util.BuildHTTP(s.Config.ExternalDomain, s.Config.Port, s.Config.ExternalSecure) if len(scope) == 0 { scope = []string{oidc.ScopeOpenID} } - return rp.NewRelyingPartyOIDC(issuer, clientID, "", redirectURI, scope) + return rp.NewRelyingPartyOIDC(s.OIDCIssuer(), clientID, "", redirectURI, scope) +} + +func (s *Tester) CreateResourceServer(keyFileData []byte) (rs.ResourceServer, error) { + keyFile, err := client.ConfigFromKeyFileData(keyFileData) + if err != nil { + return nil, err + } + return rs.NewResourceServerJWTProfile(s.OIDCIssuer(), keyFile.ClientID, keyFile.KeyID, []byte(keyFile.Key)) } func CheckRedirect(url string, headers map[string]string) (*url.URL, error) { @@ -153,11 +173,3 @@ func CheckRedirect(url string, headers map[string]string) (*url.URL, error) { return resp.Location() } - -func (s *Tester) CreateSession(ctx context.Context, userID string) (string, string, error) { - session, err := s.Commands.CreateSession(ctx, []command.SessionCommand{command.CheckUser(userID)}, "domain.tld", nil) - if err != nil { - return "", "", err - } - return session.ID, session.NewToken, nil -} diff --git a/internal/query/access_token.go b/internal/query/access_token.go new file mode 100644 index 000000000..617c3c062 --- /dev/null +++ b/internal/query/access_token.go @@ -0,0 +1,113 @@ +package query + +import ( + "context" + "strings" + "time" + + "github.com/zitadel/zitadel/internal/domain" + caos_errs "github.com/zitadel/zitadel/internal/errors" + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/repository/oidcsession" + "github.com/zitadel/zitadel/internal/telemetry/tracing" +) + +type OIDCSessionAccessTokenReadModel struct { + eventstore.WriteModel + + UserID string + SessionID string + ClientID string + Audience []string + Scope []string + AuthMethods []domain.UserAuthMethodType + AuthTime time.Time + State domain.OIDCSessionState + AccessTokenID string + AccessTokenCreation time.Time + AccessTokenExpiration time.Time +} + +func newOIDCSessionAccessTokenWriteModel(id string) *OIDCSessionAccessTokenReadModel { + return &OIDCSessionAccessTokenReadModel{ + WriteModel: eventstore.WriteModel{ + AggregateID: id, + }, + } +} + +func (wm *OIDCSessionAccessTokenReadModel) Reduce() error { + for _, event := range wm.Events { + switch e := event.(type) { + case *oidcsession.AddedEvent: + wm.reduceAdded(e) + case *oidcsession.AccessTokenAddedEvent: + wm.reduceAccessTokenAdded(e) + } + } + return wm.WriteModel.Reduce() +} + +func (wm *OIDCSessionAccessTokenReadModel) Query() *eventstore.SearchQueryBuilder { + return eventstore.NewSearchQueryBuilder(eventstore.ColumnsEvent). + AllowTimeTravel(). + AddQuery(). + AggregateTypes(oidcsession.AggregateType). + AggregateIDs(wm.AggregateID). + EventTypes( + oidcsession.AddedType, + oidcsession.AccessTokenAddedType, + ). + Builder() +} + +func (wm *OIDCSessionAccessTokenReadModel) reduceAdded(e *oidcsession.AddedEvent) { + wm.UserID = e.UserID + wm.SessionID = e.SessionID + wm.ClientID = e.ClientID + wm.Audience = e.Audience + wm.Scope = e.Scope + wm.AuthMethods = e.AuthMethods + wm.AuthTime = e.AuthTime + wm.State = domain.OIDCSessionStateActive +} + +func (wm *OIDCSessionAccessTokenReadModel) reduceAccessTokenAdded(e *oidcsession.AccessTokenAddedEvent) { + wm.AccessTokenID = e.ID + wm.AccessTokenCreation = e.CreationDate() + wm.AccessTokenExpiration = e.CreationDate().Add(e.Lifetime) +} + +// ActiveAccessTokenByToken will check if the token is active by retrieving the OIDCSession events from the eventstore. +// refreshed or expired tokens will return an error +func (q *Queries) ActiveAccessTokenByToken(ctx context.Context, token string) (model *OIDCSessionAccessTokenReadModel, err error) { + ctx, span := tracing.NewSpan(ctx) + defer func() { span.EndWithError(err) }() + + split := strings.Split(token, "-") + if len(split) != 2 { + return nil, caos_errs.ThrowPermissionDenied(nil, "QUERY-SAhtk", "Errors.OIDCSession.Token.Invalid") + } + model, err = q.accessTokenByOIDCSessionAndTokenID(ctx, split[0], split[1]) + if err != nil { + return nil, err + } + if !model.AccessTokenExpiration.After(time.Now()) { + return nil, caos_errs.ThrowPermissionDenied(nil, "QUERY-SAF3rf", "Errors.OIDCSession.Token.Expired") + } + return +} + +func (q *Queries) accessTokenByOIDCSessionAndTokenID(ctx context.Context, oidcSessionID, tokenID string) (model *OIDCSessionAccessTokenReadModel, err error) { + ctx, span := tracing.NewSpan(ctx) + defer func() { span.EndWithError(err) }() + + model = newOIDCSessionAccessTokenWriteModel(oidcSessionID) + if err = q.eventstore.FilterToQueryReducer(ctx, model); err != nil { + return nil, caos_errs.ThrowPermissionDenied(err, "QUERY-ASfe2", "Errors.OIDCSession.Token.Invalid") + } + if model.AccessTokenID != tokenID { + return nil, caos_errs.ThrowPermissionDenied(nil, "QUERY-M2u9w", "Errors.OIDCSession.Token.Invalid") + } + return model, nil +} diff --git a/internal/query/session.go b/internal/query/session.go index 76ccefba6..bcf62ba5d 100644 --- a/internal/query/session.go +++ b/internal/query/session.go @@ -40,6 +40,7 @@ type Session struct { type SessionUserFactor struct { UserID string + ResourceOwner string UserCheckedAt time.Time LoginName string DisplayName string @@ -225,6 +226,7 @@ func prepareSessionQuery(ctx context.Context, db prepareDatabase) (sq.SelectBuil SessionColumnUserCheckedAt.identifier(), LoginNameNameCol.identifier(), HumanDisplayNameCol.identifier(), + UserResourceOwnerCol.identifier(), SessionColumnPasswordCheckedAt.identifier(), SessionColumnIntentCheckedAt.identifier(), SessionColumnPasskeyCheckedAt.identifier(), @@ -232,7 +234,8 @@ func prepareSessionQuery(ctx context.Context, db prepareDatabase) (sq.SelectBuil SessionColumnToken.identifier(), ).From(sessionsTable.identifier()). LeftJoin(join(LoginNameUserIDCol, SessionColumnUserID)). - LeftJoin(join(HumanUserIDCol, SessionColumnUserID) + db.Timetravel(call.Took(ctx))). + LeftJoin(join(HumanUserIDCol, SessionColumnUserID)). + LeftJoin(join(UserIDCol, SessionColumnUserID) + db.Timetravel(call.Took(ctx))). PlaceholderFormat(sq.Dollar), func(row *sql.Row) (*Session, string, error) { session := new(Session) @@ -241,6 +244,7 @@ func prepareSessionQuery(ctx context.Context, db prepareDatabase) (sq.SelectBuil userCheckedAt sql.NullTime loginName sql.NullString displayName sql.NullString + userResourceOwner sql.NullString passwordCheckedAt sql.NullTime intentCheckedAt sql.NullTime passkeyCheckedAt sql.NullTime @@ -262,6 +266,7 @@ func prepareSessionQuery(ctx context.Context, db prepareDatabase) (sq.SelectBuil &userCheckedAt, &loginName, &displayName, + &userResourceOwner, &passwordCheckedAt, &intentCheckedAt, &passkeyCheckedAt, @@ -281,6 +286,7 @@ func prepareSessionQuery(ctx context.Context, db prepareDatabase) (sq.SelectBuil session.UserFactor.UserCheckedAt = userCheckedAt.Time session.UserFactor.LoginName = loginName.String session.UserFactor.DisplayName = displayName.String + session.UserFactor.ResourceOwner = userResourceOwner.String session.PasswordFactor.PasswordCheckedAt = passwordCheckedAt.Time session.IntentFactor.IntentCheckedAt = intentCheckedAt.Time session.PasskeyFactor.PasskeyCheckedAt = passkeyCheckedAt.Time @@ -304,6 +310,7 @@ func prepareSessionsQuery(ctx context.Context, db prepareDatabase) (sq.SelectBui SessionColumnUserCheckedAt.identifier(), LoginNameNameCol.identifier(), HumanDisplayNameCol.identifier(), + UserResourceOwnerCol.identifier(), SessionColumnPasswordCheckedAt.identifier(), SessionColumnIntentCheckedAt.identifier(), SessionColumnPasskeyCheckedAt.identifier(), @@ -311,7 +318,8 @@ func prepareSessionsQuery(ctx context.Context, db prepareDatabase) (sq.SelectBui countColumn.identifier(), ).From(sessionsTable.identifier()). LeftJoin(join(LoginNameUserIDCol, SessionColumnUserID)). - LeftJoin(join(HumanUserIDCol, SessionColumnUserID) + db.Timetravel(call.Took(ctx))). + LeftJoin(join(HumanUserIDCol, SessionColumnUserID)). + LeftJoin(join(UserIDCol, SessionColumnUserID) + db.Timetravel(call.Took(ctx))). PlaceholderFormat(sq.Dollar), func(rows *sql.Rows) (*Sessions, error) { sessions := &Sessions{Sessions: []*Session{}} @@ -323,6 +331,7 @@ func prepareSessionsQuery(ctx context.Context, db prepareDatabase) (sq.SelectBui userCheckedAt sql.NullTime loginName sql.NullString displayName sql.NullString + userResourceOwner sql.NullString passwordCheckedAt sql.NullTime intentCheckedAt sql.NullTime passkeyCheckedAt sql.NullTime @@ -343,6 +352,7 @@ func prepareSessionsQuery(ctx context.Context, db prepareDatabase) (sq.SelectBui &userCheckedAt, &loginName, &displayName, + &userResourceOwner, &passwordCheckedAt, &intentCheckedAt, &passkeyCheckedAt, @@ -358,6 +368,7 @@ func prepareSessionsQuery(ctx context.Context, db prepareDatabase) (sq.SelectBui session.UserFactor.UserCheckedAt = userCheckedAt.Time session.UserFactor.LoginName = loginName.String session.UserFactor.DisplayName = displayName.String + session.UserFactor.ResourceOwner = userResourceOwner.String session.PasswordFactor.PasswordCheckedAt = passwordCheckedAt.Time session.IntentFactor.IntentCheckedAt = intentCheckedAt.Time session.PasskeyFactor.PasskeyCheckedAt = passkeyCheckedAt.Time diff --git a/internal/query/sessions_test.go b/internal/query/sessions_test.go index 38662ead7..eed2ebafd 100644 --- a/internal/query/sessions_test.go +++ b/internal/query/sessions_test.go @@ -29,6 +29,7 @@ var ( ` projections.sessions3.user_checked_at,` + ` projections.login_names2.login_name,` + ` projections.users8_humans.display_name,` + + ` projections.users8.resource_owner,` + ` projections.sessions3.password_checked_at,` + ` projections.sessions3.intent_checked_at,` + ` projections.sessions3.passkey_checked_at,` + @@ -37,6 +38,7 @@ var ( ` FROM projections.sessions3` + ` LEFT JOIN projections.login_names2 ON projections.sessions3.user_id = projections.login_names2.user_id AND projections.sessions3.instance_id = projections.login_names2.instance_id` + ` LEFT JOIN projections.users8_humans ON projections.sessions3.user_id = projections.users8_humans.user_id AND projections.sessions3.instance_id = projections.users8_humans.instance_id` + + ` LEFT JOIN projections.users8 ON projections.sessions3.user_id = projections.users8.id AND projections.sessions3.instance_id = projections.users8.instance_id` + ` AS OF SYSTEM TIME '-1 ms'`) expectedSessionsQuery = regexp.QuoteMeta(`SELECT projections.sessions3.id,` + ` projections.sessions3.creation_date,` + @@ -50,6 +52,7 @@ var ( ` projections.sessions3.user_checked_at,` + ` projections.login_names2.login_name,` + ` projections.users8_humans.display_name,` + + ` projections.users8.resource_owner,` + ` projections.sessions3.password_checked_at,` + ` projections.sessions3.intent_checked_at,` + ` projections.sessions3.passkey_checked_at,` + @@ -58,6 +61,7 @@ var ( ` FROM projections.sessions3` + ` LEFT JOIN projections.login_names2 ON projections.sessions3.user_id = projections.login_names2.user_id AND projections.sessions3.instance_id = projections.login_names2.instance_id` + ` LEFT JOIN projections.users8_humans ON projections.sessions3.user_id = projections.users8_humans.user_id AND projections.sessions3.instance_id = projections.users8_humans.instance_id` + + ` LEFT JOIN projections.users8 ON projections.sessions3.user_id = projections.users8.id AND projections.sessions3.instance_id = projections.users8.instance_id` + ` AS OF SYSTEM TIME '-1 ms'`) sessionCols = []string{ @@ -73,6 +77,7 @@ var ( "user_checked_at", "login_name", "display_name", + "user_resource_owner", "password_checked_at", "intent_checked_at", "passkey_checked_at", @@ -93,6 +98,7 @@ var ( "user_checked_at", "login_name", "display_name", + "user_resource_owner", "password_checked_at", "intent_checked_at", "passkey_checked_at", @@ -145,6 +151,7 @@ func Test_SessionsPrepare(t *testing.T) { testNow, "login-name", "display-name", + "resourceOwner", testNow, testNow, testNow, @@ -172,6 +179,7 @@ func Test_SessionsPrepare(t *testing.T) { UserCheckedAt: testNow, LoginName: "login-name", DisplayName: "display-name", + ResourceOwner: "resourceOwner", }, PasswordFactor: SessionPasswordFactor{ PasswordCheckedAt: testNow, @@ -210,6 +218,7 @@ func Test_SessionsPrepare(t *testing.T) { testNow, "login-name", "display-name", + "resourceOwner", testNow, testNow, testNow, @@ -228,6 +237,7 @@ func Test_SessionsPrepare(t *testing.T) { testNow, "login-name2", "display-name2", + "resourceOwner", testNow, testNow, testNow, @@ -255,6 +265,7 @@ func Test_SessionsPrepare(t *testing.T) { UserCheckedAt: testNow, LoginName: "login-name", DisplayName: "display-name", + ResourceOwner: "resourceOwner", }, PasswordFactor: SessionPasswordFactor{ PasswordCheckedAt: testNow, @@ -283,6 +294,7 @@ func Test_SessionsPrepare(t *testing.T) { UserCheckedAt: testNow, LoginName: "login-name2", DisplayName: "display-name2", + ResourceOwner: "resourceOwner", }, PasswordFactor: SessionPasswordFactor{ PasswordCheckedAt: testNow, @@ -374,6 +386,7 @@ func Test_SessionPrepare(t *testing.T) { testNow, "login-name", "display-name", + "resourceOwner", testNow, testNow, testNow, @@ -396,6 +409,7 @@ func Test_SessionPrepare(t *testing.T) { UserCheckedAt: testNow, LoginName: "login-name", DisplayName: "display-name", + ResourceOwner: "resourceOwner", }, PasswordFactor: SessionPasswordFactor{ PasswordCheckedAt: testNow, diff --git a/internal/query/user_auth_method.go b/internal/query/user_auth_method.go index dde1a5a38..46697c61d 100644 --- a/internal/query/user_auth_method.go +++ b/internal/query/user_auth_method.go @@ -79,6 +79,12 @@ var ( name: "count", table: userIDPsCountTable, } + + forceMFATable = loginPolicyTable.setAlias("auth_methods_force_mfa") + forceMFAInstanceID = LoginPolicyColumnInstanceID.setTable(forceMFATable) + forceMFAOrgID = LoginPolicyColumnOrgID.setTable(forceMFATable) + forceMFAIsDefault = LoginPolicyColumnIsDefault.setTable(forceMFATable) + forceMFAForce = LoginPolicyColumnForceMFA.setTable(forceMFATable) ) type AuthMethods struct { @@ -170,6 +176,36 @@ func (q *Queries) ListActiveUserAuthMethodTypes(ctx context.Context, userID stri return userAuthMethodTypes, err } +func (q *Queries) ListUserAuthMethodTypesRequired(ctx context.Context, userID string, withOwnerRemoved bool) (userAuthMethodTypes []domain.UserAuthMethodType, forceMFA bool, err error) { + ctxData := authz.GetCtxData(ctx) + if ctxData.UserID != userID { + if err := q.checkPermission(ctx, domain.PermissionUserRead, ctxData.OrgID, userID); err != nil { + return nil, false, err + } + } + ctx, span := tracing.NewSpan(ctx) + defer func() { span.EndWithError(err) }() + + query, scan := prepareUserAuthMethodTypesRequiredQuery(ctx, q.client) + eq := sq.Eq{ + UserIDCol.identifier(): userID, + UserInstanceIDCol.identifier(): authz.GetInstance(ctx).InstanceID(), + } + if !withOwnerRemoved { + eq[UserOwnerRemovedCol.identifier()] = false + } + stmt, args, err := query.Where(eq).ToSql() + if err != nil { + return nil, false, errors.ThrowInvalidArgument(err, "QUERY-E5ut4", "Errors.Query.InvalidRequest") + } + + rows, err := q.client.QueryContext(ctx, stmt, args...) + if err != nil || rows.Err() != nil { + return nil, false, errors.ThrowInternal(err, "QUERY-Dun75", "Errors.Internal") + } + return scan(rows) +} + func NewUserAuthMethodUserIDSearchQuery(value string) (SearchQuery, error) { return NewTextQuery(UserAuthMethodColumnUserID, value, TextEquals) } @@ -311,26 +347,11 @@ func prepareUserAuthMethodsQuery(ctx context.Context, db prepareDatabase) (sq.Se } func prepareActiveUserAuthMethodTypesQuery(ctx context.Context, db prepareDatabase) (sq.SelectBuilder, func(*sql.Rows) (*AuthMethodTypes, error)) { - authMethodsQuery, authMethodsArgs, err := sq.Select( - "DISTINCT("+authMethodTypeTypes.identifier()+")", - authMethodTypeUserID.identifier(), - authMethodTypeInstanceID.identifier()). - From(authMethodTypeTable.identifier()). - Where(sq.Eq{authMethodTypeState.identifier(): domain.MFAStateReady}). - ToSql() + authMethodsQuery, authMethodsArgs, err := prepareAuthMethodQuery() if err != nil { return sq.SelectBuilder{}, nil } - idpsQuery, _, err := sq.Select( - userIDPsCountUserID.identifier(), - userIDPsCountInstanceID.identifier(), - "COUNT("+userIDPsCountUserID.identifier()+") AS "+userIDPsCountCount.name). - From(userIDPsCountTable.identifier()). - GroupBy( - userIDPsCountUserID.identifier(), - userIDPsCountInstanceID.identifier(), - ). - ToSql() + idpsQuery, err := prepareAuthMethodsIDPsQuery() if err != nil { return sq.SelectBuilder{}, nil } @@ -386,3 +407,106 @@ func prepareActiveUserAuthMethodTypesQuery(ctx context.Context, db prepareDataba }, nil } } + +func prepareUserAuthMethodTypesRequiredQuery(ctx context.Context, db prepareDatabase) (sq.SelectBuilder, func(*sql.Rows) ([]domain.UserAuthMethodType, bool, error)) { + loginPolicyQuery, err := prepareAuthMethodsForceMFAQuery() + if err != nil { + return sq.SelectBuilder{}, nil + } + authMethodsQuery, authMethodsArgs, err := prepareAuthMethodQuery() + if err != nil { + return sq.SelectBuilder{}, nil + } + idpsQuery, err := prepareAuthMethodsIDPsQuery() + if err != nil { + return sq.SelectBuilder{}, nil + } + return sq.Select( + NotifyPasswordSetCol.identifier(), + authMethodTypeTypes.identifier(), + userIDPsCountCount.identifier(), + forceMFAForce.identifier()). + From(userTable.identifier()). + LeftJoin(join(NotifyUserIDCol, UserIDCol)). + LeftJoin("("+authMethodsQuery+") AS "+authMethodTypeTable.alias+" ON "+ + authMethodTypeUserID.identifier()+" = "+UserIDCol.identifier()+" AND "+ + authMethodTypeInstanceID.identifier()+" = "+UserInstanceIDCol.identifier(), + authMethodsArgs...). + LeftJoin("(" + idpsQuery + ") AS " + userIDPsCountTable.alias + " ON " + + userIDPsCountUserID.identifier() + " = " + UserIDCol.identifier() + " AND " + + userIDPsCountInstanceID.identifier() + " = " + UserInstanceIDCol.identifier()). + LeftJoin("(" + loginPolicyQuery + ") AS " + forceMFATable.alias + " ON " + + "(" + forceMFAOrgID.identifier() + " = " + UserInstanceIDCol.identifier() + " OR " + forceMFAOrgID.identifier() + " = " + UserResourceOwnerCol.identifier() + ") AND " + + forceMFAInstanceID.identifier() + " = " + UserInstanceIDCol.identifier() + db.Timetravel(call.Took(ctx))). + PlaceholderFormat(sq.Dollar), + func(rows *sql.Rows) ([]domain.UserAuthMethodType, bool, error) { + userAuthMethodTypes := make([]domain.UserAuthMethodType, 0) + var passwordSet sql.NullBool + var idp sql.NullInt64 + var forceMFA sql.NullBool + for rows.Next() { + var authMethodType sql.NullInt16 + err := rows.Scan( + &passwordSet, + &authMethodType, + &idp, + &forceMFA, + ) + if err != nil { + return nil, false, err + } + if authMethodType.Valid { + userAuthMethodTypes = append(userAuthMethodTypes, domain.UserAuthMethodType(authMethodType.Int16)) + } + } + if passwordSet.Valid && passwordSet.Bool { + userAuthMethodTypes = append(userAuthMethodTypes, domain.UserAuthMethodTypePassword) + } + if idp.Valid && idp.Int64 > 0 { + logging.Error("IDP", idp.Int64) + userAuthMethodTypes = append(userAuthMethodTypes, domain.UserAuthMethodTypeIDP) + } + + if err := rows.Close(); err != nil { + return nil, false, errors.ThrowInternal(err, "QUERY-W4zje", "Errors.Query.CloseRows") + } + + return userAuthMethodTypes, forceMFA.Bool, nil + } +} + +func prepareAuthMethodsIDPsQuery() (string, error) { + idpsQuery, _, err := sq.Select( + userIDPsCountUserID.identifier(), + userIDPsCountInstanceID.identifier(), + "COUNT("+userIDPsCountUserID.identifier()+") AS "+userIDPsCountCount.name). + From(userIDPsCountTable.identifier()). + GroupBy( + userIDPsCountUserID.identifier(), + userIDPsCountInstanceID.identifier(), + ). + ToSql() + return idpsQuery, err +} + +func prepareAuthMethodQuery() (string, []interface{}, error) { + return sq.Select( + "DISTINCT("+authMethodTypeTypes.identifier()+")", + authMethodTypeUserID.identifier(), + authMethodTypeInstanceID.identifier()). + From(authMethodTypeTable.identifier()). + Where(sq.Eq{authMethodTypeState.identifier(): domain.MFAStateReady}). + ToSql() +} + +func prepareAuthMethodsForceMFAQuery() (string, error) { + loginPolicyQuery, _, err := sq.Select( + forceMFAForce.identifier(), + forceMFAInstanceID.identifier(), + forceMFAOrgID.identifier(), + ). + From(forceMFATable.identifier()). + OrderBy(forceMFAIsDefault.identifier()). + ToSql() + return loginPolicyQuery, err +} diff --git a/internal/query/user_auth_method_test.go b/internal/query/user_auth_method_test.go index da86a02aa..01f6c90df 100644 --- a/internal/query/user_auth_method_test.go +++ b/internal/query/user_auth_method_test.go @@ -1,6 +1,7 @@ package query import ( + "context" "database/sql" "database/sql/driver" "errors" @@ -8,6 +9,8 @@ import ( "regexp" "testing" + sq "github.com/Masterminds/squirrel" + "github.com/zitadel/zitadel/internal/domain" ) @@ -53,6 +56,28 @@ var ( "method_type", "idps_count", } + prepareAuthMethodTypesRequiredStmt = `SELECT projections.users8_notifications.password_set,` + + ` auth_method_types.method_type,` + + ` user_idps_count.count,` + + ` auth_methods_force_mfa.force_mfa` + + ` FROM projections.users8` + + ` LEFT JOIN projections.users8_notifications ON projections.users8.id = projections.users8_notifications.user_id AND projections.users8.instance_id = projections.users8_notifications.instance_id` + + ` LEFT JOIN (SELECT DISTINCT(auth_method_types.method_type), auth_method_types.user_id, auth_method_types.instance_id FROM projections.user_auth_methods4 AS auth_method_types` + + ` WHERE auth_method_types.state = $1) AS auth_method_types` + + ` ON auth_method_types.user_id = projections.users8.id AND auth_method_types.instance_id = projections.users8.instance_id` + + ` LEFT JOIN (SELECT user_idps_count.user_id, user_idps_count.instance_id, COUNT(user_idps_count.user_id) AS count FROM projections.idp_user_links3 AS user_idps_count` + + ` GROUP BY user_idps_count.user_id, user_idps_count.instance_id) AS user_idps_count` + + ` ON user_idps_count.user_id = projections.users8.id AND user_idps_count.instance_id = projections.users8.instance_id` + + ` LEFT JOIN (SELECT auth_methods_force_mfa.force_mfa, auth_methods_force_mfa.instance_id, auth_methods_force_mfa.aggregate_id FROM projections.login_policies4 AS auth_methods_force_mfa ORDER BY auth_methods_force_mfa.is_default) AS auth_methods_force_mfa` + + ` ON (auth_methods_force_mfa.aggregate_id = projections.users8.instance_id OR auth_methods_force_mfa.aggregate_id = projections.users8.resource_owner) AND auth_methods_force_mfa.instance_id = projections.users8.instance_id` + + ` AS OF SYSTEM TIME '-1 ms +` + prepareAuthMethodTypesRequiredCols = []string{ + "password_set", + "method_type", + "idps_count", + "force_mfa", + } ) func Test_UserAuthMethodPrepares(t *testing.T) { @@ -288,6 +313,131 @@ func Test_UserAuthMethodPrepares(t *testing.T) { }, object: nil, }, + { + name: "prepareUserAuthMethodTypesRequiredQuery no result", + prepare: func(ctx context.Context, db prepareDatabase) (sq.SelectBuilder, func(*sql.Rows) (*testUserAuthMethodTypesRequired, error)) { + builder, scan := prepareUserAuthMethodTypesRequiredQuery(ctx, db) + return builder, func(rows *sql.Rows) (*testUserAuthMethodTypesRequired, error) { + authMethods, forceMFA, err := scan(rows) + if err != nil { + return nil, err + } + return &testUserAuthMethodTypesRequired{authMethods: authMethods, forceMFA: forceMFA}, nil + } + }, + want: want{ + sqlExpectations: mockQueries( + regexp.QuoteMeta(prepareAuthMethodTypesRequiredStmt), + nil, + nil, + ), + }, + object: &testUserAuthMethodTypesRequired{authMethods: []domain.UserAuthMethodType{}, forceMFA: false}, + }, + { + name: "prepareUserAuthMethodTypesRequiredQuery one second factor", + prepare: func(ctx context.Context, db prepareDatabase) (sq.SelectBuilder, func(*sql.Rows) (*testUserAuthMethodTypesRequired, error)) { + builder, scan := prepareUserAuthMethodTypesRequiredQuery(ctx, db) + return builder, func(rows *sql.Rows) (*testUserAuthMethodTypesRequired, error) { + authMethods, forceMFA, err := scan(rows) + if err != nil { + return nil, err + } + return &testUserAuthMethodTypesRequired{authMethods: authMethods, forceMFA: forceMFA}, nil + } + }, + want: want{ + sqlExpectations: mockQueries( + regexp.QuoteMeta(prepareAuthMethodTypesRequiredStmt), + prepareAuthMethodTypesRequiredCols, + [][]driver.Value{ + { + true, + domain.UserAuthMethodTypePasswordless, + 1, + true, + }, + }, + ), + }, + object: &testUserAuthMethodTypesRequired{ + authMethods: []domain.UserAuthMethodType{ + domain.UserAuthMethodTypePasswordless, + domain.UserAuthMethodTypePassword, + domain.UserAuthMethodTypeIDP, + }, + forceMFA: true, + }, + }, + { + name: "prepareUserAuthMethodTypesRequiredQuery multiple second factors", + prepare: func(ctx context.Context, db prepareDatabase) (sq.SelectBuilder, func(*sql.Rows) (*testUserAuthMethodTypesRequired, error)) { + builder, scan := prepareUserAuthMethodTypesRequiredQuery(ctx, db) + return builder, func(rows *sql.Rows) (*testUserAuthMethodTypesRequired, error) { + authMethods, forceMFA, err := scan(rows) + if err != nil { + return nil, err + } + return &testUserAuthMethodTypesRequired{authMethods: authMethods, forceMFA: forceMFA}, nil + } + }, + want: want{ + sqlExpectations: mockQueries( + regexp.QuoteMeta(prepareAuthMethodTypesRequiredStmt), + prepareAuthMethodTypesRequiredCols, + [][]driver.Value{ + { + true, + domain.UserAuthMethodTypePasswordless, + 1, + true, + }, + { + true, + domain.UserAuthMethodTypeOTP, + 1, + true, + }, + }, + ), + }, + + object: &testUserAuthMethodTypesRequired{ + authMethods: []domain.UserAuthMethodType{ + domain.UserAuthMethodTypePasswordless, + domain.UserAuthMethodTypeOTP, + domain.UserAuthMethodTypePassword, + domain.UserAuthMethodTypeIDP, + }, + forceMFA: true, + }, + }, + { + name: "prepareUserAuthMethodTypesRequiredQuery sql err", + prepare: func(ctx context.Context, db prepareDatabase) (sq.SelectBuilder, func(*sql.Rows) (*testUserAuthMethodTypesRequired, error)) { + builder, scan := prepareUserAuthMethodTypesRequiredQuery(ctx, db) + return builder, func(rows *sql.Rows) (*testUserAuthMethodTypesRequired, error) { + authMethods, forceMFA, err := scan(rows) + if err != nil { + return nil, err + } + return &testUserAuthMethodTypesRequired{authMethods: authMethods, forceMFA: forceMFA}, nil + } + }, + want: want{ + sqlExpectations: mockQueryErr( + regexp.QuoteMeta(prepareAuthMethodTypesRequiredStmt), + sql.ErrConnDone, + ), + err: func(err error) (error, bool) { + if !errors.Is(err, sql.ErrConnDone) { + return fmt.Errorf("err should be sql.ErrConnDone got: %w", err), false + } + return nil, true + }, + }, + object: nil, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -295,3 +445,9 @@ func Test_UserAuthMethodPrepares(t *testing.T) { }) } } + +// testUserAuthMethodTypesRequired is required as assetPrepare is only able to return a single object from scan +type testUserAuthMethodTypesRequired struct { + authMethods []domain.UserAuthMethodType + forceMFA bool +} diff --git a/internal/static/i18n/bg.yaml b/internal/static/i18n/bg.yaml index 15f6d41a8..959c5fbbf 100644 --- a/internal/static/i18n/bg.yaml +++ b/internal/static/i18n/bg.yaml @@ -514,6 +514,9 @@ Errors: WrongLoginClient: Auth Request, създаден от друг клиент за влизане OIDCSession: RefreshTokenInvalid: Токенът за опресняване е невалиден + Token: + Invalid: Токенът е невалиден + Expired: Токенът е изтекъл AggregateTypes: action: Действие diff --git a/internal/static/i18n/de.yaml b/internal/static/i18n/de.yaml index cc053aaa0..dbf851c29 100644 --- a/internal/static/i18n/de.yaml +++ b/internal/static/i18n/de.yaml @@ -496,6 +496,10 @@ Errors: WrongLoginClient: Auth Request wurde von einem anderen Login-Client erstellt OIDCSession: RefreshTokenInvalid: Refresh Token ist ungültig + Token: + Invalid: Token ist ungültig + Expired: Token ist abgelaufen + AggregateTypes: action: Action instance: Instanz diff --git a/internal/static/i18n/en.yaml b/internal/static/i18n/en.yaml index 30e14168f..15b60ae4a 100644 --- a/internal/static/i18n/en.yaml +++ b/internal/static/i18n/en.yaml @@ -496,6 +496,9 @@ Errors: WrongLoginClient: Auth Request created by other login client OIDCSession: RefreshTokenInvalid: Refresh Token is invalid + Token: + Invalid: Token is invalid + Expired: Token is expired AggregateTypes: action: Action diff --git a/internal/static/i18n/es.yaml b/internal/static/i18n/es.yaml index 63b803068..eaf7ff8a1 100644 --- a/internal/static/i18n/es.yaml +++ b/internal/static/i18n/es.yaml @@ -496,6 +496,9 @@ Errors: WrongLoginClient: Auth Request creado por otro cliente de inicio de sesión OIDCSession: RefreshTokenInvalid: El token de refresco no es válido + Token: + Invalid: El token no es válido + Expired: El token ha caducado AggregateTypes: action: Acción diff --git a/internal/static/i18n/fr.yaml b/internal/static/i18n/fr.yaml index 71b4fa684..640c9c786 100644 --- a/internal/static/i18n/fr.yaml +++ b/internal/static/i18n/fr.yaml @@ -496,6 +496,9 @@ Errors: WrongLoginClient: Auth Request créé par un autre client de connexion OIDCSession: RefreshTokenInvalid: Le jeton de rafraîchissement n'est pas valide + Token: + Invalid: Le jeton n'est pas valide + Expired: Le jeton est expiré AggregateTypes: action: Action diff --git a/internal/static/i18n/it.yaml b/internal/static/i18n/it.yaml index 5011db450..4e1e13c61 100644 --- a/internal/static/i18n/it.yaml +++ b/internal/static/i18n/it.yaml @@ -496,6 +496,9 @@ Errors: WrongLoginClient: Auth Request creato da un altro client di accesso OIDCSession: RefreshTokenInvalid: Refresh Token non è valido + Token: + Invalid: Token non è valido + Expired: Token è scaduto AggregateTypes: action: Azione diff --git a/internal/static/i18n/ja.yaml b/internal/static/i18n/ja.yaml index f3ef77c96..659d805f3 100644 --- a/internal/static/i18n/ja.yaml +++ b/internal/static/i18n/ja.yaml @@ -485,6 +485,9 @@ Errors: WrongLoginClient: 他のログインクライアントによって作成された AuthRequest OIDCSession: RefreshTokenInvalid: 無効なリフレッシュトークンです + Token: + Invalid: トークンが無効です + Expired: トークンの有効期限が切れている AggregateTypes: action: アクション diff --git a/internal/static/i18n/mk.yaml b/internal/static/i18n/mk.yaml index 5b32170b8..2ea8933bc 100644 --- a/internal/static/i18n/mk.yaml +++ b/internal/static/i18n/mk.yaml @@ -490,6 +490,15 @@ Errors: TokenCreationFailed: Неуспешно креирање на токен InvalidToken: Токенот за намера е невалиден OtherUser: Намерата е за друг корисник + AuthRequest: + AlreadyExists: Барањето за автентикација веќе постои + NotExisting: Барањето за автентикација не постои + WrongLoginClient: Барањето за автификација беше креирано од друг клиент за најавување + OIDCSession: + RefreshTokenInvalid: Токенот за освежување е неважечки + Token: + Invalid: токенот е неважечки + Expired: токенот е истечен AggregateTypes: action: Акција diff --git a/internal/static/i18n/pl.yaml b/internal/static/i18n/pl.yaml index 304283258..4501cc684 100644 --- a/internal/static/i18n/pl.yaml +++ b/internal/static/i18n/pl.yaml @@ -496,6 +496,9 @@ Errors: WrongLoginClient: Auth Request utworzony przez innego klienta logowania OIDCSession: RefreshTokenInvalid: Refresh Token jest nieprawidłowy + Token: + Invalid: Token jest nieprawidłowy + Expired: Token wygasł AggregateTypes: action: Działanie diff --git a/internal/static/i18n/zh.yaml b/internal/static/i18n/zh.yaml index e4e29151a..21ba558c4 100644 --- a/internal/static/i18n/zh.yaml +++ b/internal/static/i18n/zh.yaml @@ -496,6 +496,9 @@ Errors: WrongLoginClient: 其他登录客户端创建的AuthRequest OIDCSession: RefreshTokenInvalid: Refresh Token 无效 + Token: + Invalid: 令牌无效 + Expired: 令牌已过期 AggregateTypes: action: 动作 diff --git a/proto/zitadel/session/v2alpha/session.proto b/proto/zitadel/session/v2alpha/session.proto index b3e11a443..e6e69ca50 100644 --- a/proto/zitadel/session/v2alpha/session.proto +++ b/proto/zitadel/session/v2alpha/session.proto @@ -74,6 +74,11 @@ message UserFactor { description: "\"display name of the checked user\""; } ]; + string organisation_id = 5 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "\"organisation id of the checked user\""; + } + ]; } message PasswordFactor {