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 {