diff --git a/.gitignore b/.gitignore index 42d6bc02..7b2d662a 100644 --- a/.gitignore +++ b/.gitignore @@ -23,4 +23,5 @@ certs/ node_modules/* *.code-workspace .claude/worktrees -docs/superpowers \ No newline at end of file +docs/superpowers +.worktrees/ diff --git a/internal/graph/generated/generated.go b/internal/graph/generated/generated.go index 993c39e0..a188a9f2 100644 --- a/internal/graph/generated/generated.go +++ b/internal/graph/generated/generated.go @@ -73,18 +73,20 @@ type ComplexityRoot struct { } AuthResponse struct { - AccessToken func(childComplexity int) int - AuthenticatorRecoveryCodes func(childComplexity int) int - AuthenticatorScannerImage func(childComplexity int) int - AuthenticatorSecret func(childComplexity int) int - ExpiresIn func(childComplexity int) int - IDToken func(childComplexity int) int - Message func(childComplexity int) int - RefreshToken func(childComplexity int) int - ShouldShowEmailOtpScreen func(childComplexity int) int - ShouldShowMobileOtpScreen func(childComplexity int) int - ShouldShowTotpScreen func(childComplexity int) int - User func(childComplexity int) int + AccessToken func(childComplexity int) int + AuthenticatorRecoveryCodes func(childComplexity int) int + AuthenticatorScannerImage func(childComplexity int) int + AuthenticatorSecret func(childComplexity int) int + ExpiresIn func(childComplexity int) int + IDToken func(childComplexity int) int + Message func(childComplexity int) int + RefreshToken func(childComplexity int) int + ShouldOfferMfaSetup func(childComplexity int) int + ShouldOfferWebauthnMfaVerify func(childComplexity int) int + ShouldShowEmailOtpScreen func(childComplexity int) int + ShouldShowMobileOtpScreen func(childComplexity int) int + ShouldShowTotpScreen func(childComplexity int) int + User func(childComplexity int) int } CheckPermissionsResponse struct { @@ -269,6 +271,7 @@ type ComplexityRoot struct { IsGoogleLoginEnabled func(childComplexity int) int IsLinkedinLoginEnabled func(childComplexity int) int IsMagicLinkLoginEnabled func(childComplexity int) int + IsMfaEnforced func(childComplexity int) int IsMicrosoftLoginEnabled func(childComplexity int) int IsMobileBasicAuthenticationEnabled func(childComplexity int) int IsMultiFactorAuthEnabled func(childComplexity int) int @@ -333,6 +336,7 @@ type ComplexityRoot struct { RotateClientSecret func(childComplexity int, params model.ClientRequest) int RotateScimToken func(childComplexity int, params model.ScimEndpointRequest) int Signup func(childComplexity int, params model.SignUpRequest) int + SkipMfaSetup func(childComplexity int) int TestEndpoint func(childComplexity int, params model.TestEndpointRequest) int UpdateClient func(childComplexity int, params model.UpdateClientRequest) int UpdateEmailTemplate func(childComplexity int, params model.UpdateEmailTemplateRequest) int @@ -539,6 +543,7 @@ type ComplexityRoot struct { FamilyName func(childComplexity int) int Gender func(childComplexity int) int GivenName func(childComplexity int) int + HasSkippedMfaSetupAt func(childComplexity int) int ID func(childComplexity int) int IsMultiFactorAuthEnabled func(childComplexity int) int MiddleName func(childComplexity int) int @@ -659,6 +664,7 @@ type MutationResolver interface { Revoke(ctx context.Context, params model.OAuthRevokeRequest) (*model.Response, error) VerifyOtp(ctx context.Context, params model.VerifyOTPRequest) (*model.AuthResponse, error) ResendOtp(ctx context.Context, params model.ResendOTPRequest) (*model.Response, error) + SkipMfaSetup(ctx context.Context) (*model.Response, error) WebauthnRegistrationOptions(ctx context.Context, email *string) (*model.WebauthnRegistrationOptionsResponse, error) WebauthnRegistrationVerify(ctx context.Context, params model.WebauthnRegistrationVerifyRequest) (*model.Response, error) WebauthnLoginOptions(ctx context.Context, email *string) (*model.WebauthnLoginOptionsResponse, error) @@ -944,6 +950,20 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return e.complexity.AuthResponse.RefreshToken(childComplexity), true + case "AuthResponse.should_offer_mfa_setup": + if e.complexity.AuthResponse.ShouldOfferMfaSetup == nil { + break + } + + return e.complexity.AuthResponse.ShouldOfferMfaSetup(childComplexity), true + + case "AuthResponse.should_offer_webauthn_mfa_verify": + if e.complexity.AuthResponse.ShouldOfferWebauthnMfaVerify == nil { + break + } + + return e.complexity.AuthResponse.ShouldOfferWebauthnMfaVerify(childComplexity), true + case "AuthResponse.should_show_email_otp_screen": if e.complexity.AuthResponse.ShouldShowEmailOtpScreen == nil { break @@ -1861,6 +1881,13 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return e.complexity.Meta.IsMagicLinkLoginEnabled(childComplexity), true + case "Meta.is_mfa_enforced": + if e.complexity.Meta.IsMfaEnforced == nil { + break + } + + return e.complexity.Meta.IsMfaEnforced(childComplexity), true + case "Meta.is_microsoft_login_enabled": if e.complexity.Meta.IsMicrosoftLoginEnabled == nil { break @@ -2503,6 +2530,13 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return e.complexity.Mutation.Signup(childComplexity, args["params"].(model.SignUpRequest)), true + case "Mutation.skip_mfa_setup": + if e.complexity.Mutation.SkipMfaSetup == nil { + break + } + + return e.complexity.Mutation.SkipMfaSetup(childComplexity), true + case "Mutation._test_endpoint": if e.complexity.Mutation.TestEndpoint == nil { break @@ -3781,6 +3815,13 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return e.complexity.User.GivenName(childComplexity), true + case "User.has_skipped_mfa_setup_at": + if e.complexity.User.HasSkippedMfaSetupAt == nil { + break + } + + return e.complexity.User.HasSkippedMfaSetupAt(childComplexity), true + case "User.id": if e.complexity.User.ID == nil { break @@ -4428,6 +4469,13 @@ type Meta { is_sms_otp_mfa_enabled: Boolean! # is_webauthn_enabled indicates WebAuthn/passkey enrollment is available (always on; no operator flag). is_webauthn_enabled: Boolean! + # is_mfa_enforced mirrors EnforceMFA — when true, the frontend must not + # offer a standalone passkey primary-login path (it would bypass the org's + # two-factor requirement); passkey should only be offered as a second + # factor after password/social login. The server enforces this + # independently in webauthn_login_verify regardless of what the frontend + # shows. + is_mfa_enforced: Boolean! } # AdminMeta is admin-only configuration metadata exposed via the _admin_meta @@ -4466,6 +4514,9 @@ type User { updated_at: Int64 revoked_timestamp: Int64 is_multi_factor_auth_enabled: Boolean + # has_skipped_mfa_setup_at is set once the user explicitly skips the + # optional MFA setup prompt shown at login. Null means never skipped. + has_skipped_mfa_setup_at: Int64 app_data: Map } @@ -4501,6 +4552,19 @@ type AuthResponse { should_show_email_otp_screen: Boolean should_show_mobile_otp_screen: Boolean should_show_totp_screen: Boolean + # should_offer_webauthn_mfa_verify is true when the authenticated-with- + # password user has a registered passkey and MFA verification (not + # enrollment) is required before a token is issued. The frontend should + # offer a "verify with your passkey" action — calling the existing scoped + # webauthn_login_options(email)/webauthn_login_verify mutations — in + # addition to (or instead of) should_show_totp_screen's code-entry form. + should_offer_webauthn_mfa_verify: Boolean + # should_offer_mfa_setup is true when MFA is available but not enforced, + # the user hasn't enrolled, and they haven't skipped setup before. Unlike + # should_show_totp_screen, access_token is ALREADY populated alongside + # this flag — the frontend should log the user in and separately offer + # (not force) MFA setup, e.g. via a dismissible hub with a Skip action. + should_offer_mfa_setup: Boolean access_token: String id_token: String refresh_token: String @@ -5703,6 +5767,10 @@ type Mutation { revoke(params: OAuthRevokeRequest!): Response! verify_otp(params: VerifyOTPRequest!): AuthResponse! resend_otp(params: ResendOTPRequest!): Response! + # skip_mfa_setup records that the authenticated caller explicitly declined + # the optional MFA setup prompt. Fails with FAILED_PRECONDITION if MFA is + # organization-enforced (enforce-mfa) — enforcement is never skippable. + skip_mfa_setup: Response! # WebAuthn / passkey self-service ceremonies (no admin ` + "`" + `_` + "`" + ` prefix). webauthn_registration_options( email: String @@ -9422,6 +9490,88 @@ func (ec *executionContext) fieldContext_AuthResponse_should_show_totp_screen(_ return fc, nil } +func (ec *executionContext) _AuthResponse_should_offer_webauthn_mfa_verify(ctx context.Context, field graphql.CollectedField, obj *model.AuthResponse) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_AuthResponse_should_offer_webauthn_mfa_verify(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) { + ctx = rctx // use context from middleware stack in children + return obj.ShouldOfferWebauthnMfaVerify, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*bool) + fc.Result = res + return ec.marshalOBoolean2ᚖbool(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_AuthResponse_should_offer_webauthn_mfa_verify(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "AuthResponse", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Boolean does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _AuthResponse_should_offer_mfa_setup(ctx context.Context, field graphql.CollectedField, obj *model.AuthResponse) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_AuthResponse_should_offer_mfa_setup(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) { + ctx = rctx // use context from middleware stack in children + return obj.ShouldOfferMfaSetup, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*bool) + fc.Result = res + return ec.marshalOBoolean2ᚖbool(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_AuthResponse_should_offer_mfa_setup(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "AuthResponse", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Boolean does not have child fields") + }, + } + return fc, nil +} + func (ec *executionContext) _AuthResponse_access_token(ctx context.Context, field graphql.CollectedField, obj *model.AuthResponse) (ret graphql.Marshaler) { fc, err := ec.fieldContext_AuthResponse_access_token(ctx, field) if err != nil { @@ -9660,6 +9810,8 @@ func (ec *executionContext) fieldContext_AuthResponse_user(_ context.Context, fi return ec.fieldContext_User_revoked_timestamp(ctx, field) case "is_multi_factor_auth_enabled": return ec.fieldContext_User_is_multi_factor_auth_enabled(ctx, field) + case "has_skipped_mfa_setup_at": + return ec.fieldContext_User_has_skipped_mfa_setup_at(ctx, field) case "app_data": return ec.fieldContext_User_app_data(ctx, field) } @@ -14705,6 +14857,8 @@ func (ec *executionContext) fieldContext_InviteMembersResponse_Users(_ context.C return ec.fieldContext_User_revoked_timestamp(ctx, field) case "is_multi_factor_auth_enabled": return ec.fieldContext_User_is_multi_factor_auth_enabled(ctx, field) + case "has_skipped_mfa_setup_at": + return ec.fieldContext_User_has_skipped_mfa_setup_at(ctx, field) case "app_data": return ec.fieldContext_User_app_data(ctx, field) } @@ -15952,6 +16106,50 @@ func (ec *executionContext) fieldContext_Meta_is_webauthn_enabled(_ context.Cont return fc, nil } +func (ec *executionContext) _Meta_is_mfa_enforced(ctx context.Context, field graphql.CollectedField, obj *model.Meta) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_Meta_is_mfa_enforced(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) { + ctx = rctx // use context from middleware stack in children + return obj.IsMfaEnforced, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(bool) + fc.Result = res + return ec.marshalNBoolean2bool(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_Meta_is_mfa_enforced(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Meta", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Boolean does not have child fields") + }, + } + return fc, nil +} + func (ec *executionContext) _Mutation_signup(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { fc, err := ec.fieldContext_Mutation_signup(ctx, field) if err != nil { @@ -15999,6 +16197,10 @@ func (ec *executionContext) fieldContext_Mutation_signup(ctx context.Context, fi return ec.fieldContext_AuthResponse_should_show_mobile_otp_screen(ctx, field) case "should_show_totp_screen": return ec.fieldContext_AuthResponse_should_show_totp_screen(ctx, field) + case "should_offer_webauthn_mfa_verify": + return ec.fieldContext_AuthResponse_should_offer_webauthn_mfa_verify(ctx, field) + case "should_offer_mfa_setup": + return ec.fieldContext_AuthResponse_should_offer_mfa_setup(ctx, field) case "access_token": return ec.fieldContext_AuthResponse_access_token(ctx, field) case "id_token": @@ -16080,6 +16282,10 @@ func (ec *executionContext) fieldContext_Mutation_mobile_signup(ctx context.Cont return ec.fieldContext_AuthResponse_should_show_mobile_otp_screen(ctx, field) case "should_show_totp_screen": return ec.fieldContext_AuthResponse_should_show_totp_screen(ctx, field) + case "should_offer_webauthn_mfa_verify": + return ec.fieldContext_AuthResponse_should_offer_webauthn_mfa_verify(ctx, field) + case "should_offer_mfa_setup": + return ec.fieldContext_AuthResponse_should_offer_mfa_setup(ctx, field) case "access_token": return ec.fieldContext_AuthResponse_access_token(ctx, field) case "id_token": @@ -16161,6 +16367,10 @@ func (ec *executionContext) fieldContext_Mutation_login(ctx context.Context, fie return ec.fieldContext_AuthResponse_should_show_mobile_otp_screen(ctx, field) case "should_show_totp_screen": return ec.fieldContext_AuthResponse_should_show_totp_screen(ctx, field) + case "should_offer_webauthn_mfa_verify": + return ec.fieldContext_AuthResponse_should_offer_webauthn_mfa_verify(ctx, field) + case "should_offer_mfa_setup": + return ec.fieldContext_AuthResponse_should_offer_mfa_setup(ctx, field) case "access_token": return ec.fieldContext_AuthResponse_access_token(ctx, field) case "id_token": @@ -16242,6 +16452,10 @@ func (ec *executionContext) fieldContext_Mutation_mobile_login(ctx context.Conte return ec.fieldContext_AuthResponse_should_show_mobile_otp_screen(ctx, field) case "should_show_totp_screen": return ec.fieldContext_AuthResponse_should_show_totp_screen(ctx, field) + case "should_offer_webauthn_mfa_verify": + return ec.fieldContext_AuthResponse_should_offer_webauthn_mfa_verify(ctx, field) + case "should_offer_mfa_setup": + return ec.fieldContext_AuthResponse_should_offer_mfa_setup(ctx, field) case "access_token": return ec.fieldContext_AuthResponse_access_token(ctx, field) case "id_token": @@ -16489,6 +16703,10 @@ func (ec *executionContext) fieldContext_Mutation_verify_email(ctx context.Conte return ec.fieldContext_AuthResponse_should_show_mobile_otp_screen(ctx, field) case "should_show_totp_screen": return ec.fieldContext_AuthResponse_should_show_totp_screen(ctx, field) + case "should_offer_webauthn_mfa_verify": + return ec.fieldContext_AuthResponse_should_offer_webauthn_mfa_verify(ctx, field) + case "should_offer_mfa_setup": + return ec.fieldContext_AuthResponse_should_offer_mfa_setup(ctx, field) case "access_token": return ec.fieldContext_AuthResponse_access_token(ctx, field) case "id_token": @@ -16808,6 +17026,10 @@ func (ec *executionContext) fieldContext_Mutation_verify_otp(ctx context.Context return ec.fieldContext_AuthResponse_should_show_mobile_otp_screen(ctx, field) case "should_show_totp_screen": return ec.fieldContext_AuthResponse_should_show_totp_screen(ctx, field) + case "should_offer_webauthn_mfa_verify": + return ec.fieldContext_AuthResponse_should_offer_webauthn_mfa_verify(ctx, field) + case "should_offer_mfa_setup": + return ec.fieldContext_AuthResponse_should_offer_mfa_setup(ctx, field) case "access_token": return ec.fieldContext_AuthResponse_access_token(ctx, field) case "id_token": @@ -16901,6 +17123,54 @@ func (ec *executionContext) fieldContext_Mutation_resend_otp(ctx context.Context return fc, nil } +func (ec *executionContext) _Mutation_skip_mfa_setup(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_Mutation_skip_mfa_setup(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) { + ctx = rctx // use context from middleware stack in children + return ec.resolvers.Mutation().SkipMfaSetup(rctx) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(*model.Response) + fc.Result = res + return ec.marshalNResponse2ᚖgithubᚗcomᚋauthorizerdevᚋauthorizerᚋinternalᚋgraphᚋmodelᚐResponse(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_Mutation_skip_mfa_setup(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Mutation", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "message": + return ec.fieldContext_Response_message(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type Response", field.Name) + }, + } + return fc, nil +} + func (ec *executionContext) _Mutation_webauthn_registration_options(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { fc, err := ec.fieldContext_Mutation_webauthn_registration_options(ctx, field) if err != nil { @@ -17125,6 +17395,10 @@ func (ec *executionContext) fieldContext_Mutation_webauthn_login_verify(ctx cont return ec.fieldContext_AuthResponse_should_show_mobile_otp_screen(ctx, field) case "should_show_totp_screen": return ec.fieldContext_AuthResponse_should_show_totp_screen(ctx, field) + case "should_offer_webauthn_mfa_verify": + return ec.fieldContext_AuthResponse_should_offer_webauthn_mfa_verify(ctx, field) + case "should_offer_mfa_setup": + return ec.fieldContext_AuthResponse_should_offer_mfa_setup(ctx, field) case "access_token": return ec.fieldContext_AuthResponse_access_token(ctx, field) case "id_token": @@ -17402,6 +17676,8 @@ func (ec *executionContext) fieldContext_Mutation__update_user(ctx context.Conte return ec.fieldContext_User_revoked_timestamp(ctx, field) case "is_multi_factor_auth_enabled": return ec.fieldContext_User_is_multi_factor_auth_enabled(ctx, field) + case "has_skipped_mfa_setup_at": + return ec.fieldContext_User_has_skipped_mfa_setup_at(ctx, field) case "app_data": return ec.fieldContext_User_app_data(ctx, field) } @@ -23012,6 +23288,8 @@ func (ec *executionContext) fieldContext_Query_meta(_ context.Context, field gra return ec.fieldContext_Meta_is_sms_otp_mfa_enabled(ctx, field) case "is_webauthn_enabled": return ec.fieldContext_Meta_is_webauthn_enabled(ctx, field) + case "is_mfa_enforced": + return ec.fieldContext_Meta_is_mfa_enforced(ctx, field) } return nil, fmt.Errorf("no field named %q was found under type Meta", field.Name) }, @@ -23066,6 +23344,10 @@ func (ec *executionContext) fieldContext_Query_session(ctx context.Context, fiel return ec.fieldContext_AuthResponse_should_show_mobile_otp_screen(ctx, field) case "should_show_totp_screen": return ec.fieldContext_AuthResponse_should_show_totp_screen(ctx, field) + case "should_offer_webauthn_mfa_verify": + return ec.fieldContext_AuthResponse_should_offer_webauthn_mfa_verify(ctx, field) + case "should_offer_mfa_setup": + return ec.fieldContext_AuthResponse_should_offer_mfa_setup(ctx, field) case "access_token": return ec.fieldContext_AuthResponse_access_token(ctx, field) case "id_token": @@ -23177,6 +23459,8 @@ func (ec *executionContext) fieldContext_Query_profile(_ context.Context, field return ec.fieldContext_User_revoked_timestamp(ctx, field) case "is_multi_factor_auth_enabled": return ec.fieldContext_User_is_multi_factor_auth_enabled(ctx, field) + case "has_skipped_mfa_setup_at": + return ec.fieldContext_User_has_skipped_mfa_setup_at(ctx, field) case "app_data": return ec.fieldContext_User_app_data(ctx, field) } @@ -23504,6 +23788,8 @@ func (ec *executionContext) fieldContext_Query__user(ctx context.Context, field return ec.fieldContext_User_revoked_timestamp(ctx, field) case "is_multi_factor_auth_enabled": return ec.fieldContext_User_is_multi_factor_auth_enabled(ctx, field) + case "has_skipped_mfa_setup_at": + return ec.fieldContext_User_has_skipped_mfa_setup_at(ctx, field) case "app_data": return ec.fieldContext_User_app_data(ctx, field) } @@ -27448,6 +27734,47 @@ func (ec *executionContext) fieldContext_User_is_multi_factor_auth_enabled(_ con return fc, nil } +func (ec *executionContext) _User_has_skipped_mfa_setup_at(ctx context.Context, field graphql.CollectedField, obj *model.User) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_User_has_skipped_mfa_setup_at(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) { + ctx = rctx // use context from middleware stack in children + return obj.HasSkippedMfaSetupAt, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*int64) + fc.Result = res + return ec.marshalOInt642ᚖint64(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_User_has_skipped_mfa_setup_at(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "User", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Int64 does not have child fields") + }, + } + return fc, nil +} + func (ec *executionContext) _User_app_data(ctx context.Context, field graphql.CollectedField, obj *model.User) (ret graphql.Marshaler) { fc, err := ec.fieldContext_User_app_data(ctx, field) if err != nil { @@ -27826,6 +28153,8 @@ func (ec *executionContext) fieldContext_Users_users(_ context.Context, field gr return ec.fieldContext_User_revoked_timestamp(ctx, field) case "is_multi_factor_auth_enabled": return ec.fieldContext_User_is_multi_factor_auth_enabled(ctx, field) + case "has_skipped_mfa_setup_at": + return ec.fieldContext_User_has_skipped_mfa_setup_at(ctx, field) case "app_data": return ec.fieldContext_User_app_data(ctx, field) } @@ -28041,6 +28370,8 @@ func (ec *executionContext) fieldContext_ValidateSessionResponse_user(_ context. return ec.fieldContext_User_revoked_timestamp(ctx, field) case "is_multi_factor_auth_enabled": return ec.fieldContext_User_is_multi_factor_auth_enabled(ctx, field) + case "has_skipped_mfa_setup_at": + return ec.fieldContext_User_has_skipped_mfa_setup_at(ctx, field) case "app_data": return ec.fieldContext_User_app_data(ctx, field) } @@ -35900,6 +36231,10 @@ func (ec *executionContext) _AuthResponse(ctx context.Context, sel ast.Selection out.Values[i] = ec._AuthResponse_should_show_mobile_otp_screen(ctx, field, obj) case "should_show_totp_screen": out.Values[i] = ec._AuthResponse_should_show_totp_screen(ctx, field, obj) + case "should_offer_webauthn_mfa_verify": + out.Values[i] = ec._AuthResponse_should_offer_webauthn_mfa_verify(ctx, field, obj) + case "should_offer_mfa_setup": + out.Values[i] = ec._AuthResponse_should_offer_mfa_setup(ctx, field, obj) case "access_token": out.Values[i] = ec._AuthResponse_access_token(ctx, field, obj) case "id_token": @@ -37075,6 +37410,11 @@ func (ec *executionContext) _Meta(ctx context.Context, sel ast.SelectionSet, obj if out.Values[i] == graphql.Null { out.Invalids++ } + case "is_mfa_enforced": + out.Values[i] = ec._Meta_is_mfa_enforced(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } default: panic("unknown field " + strconv.Quote(field.Name)) } @@ -37215,6 +37555,13 @@ func (ec *executionContext) _Mutation(ctx context.Context, sel ast.SelectionSet) if out.Values[i] == graphql.Null { out.Invalids++ } + case "skip_mfa_setup": + out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { + return ec._Mutation_skip_mfa_setup(ctx, field) + }) + if out.Values[i] == graphql.Null { + out.Invalids++ + } case "webauthn_registration_options": out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { return ec._Mutation_webauthn_registration_options(ctx, field) @@ -39406,6 +39753,8 @@ func (ec *executionContext) _User(ctx context.Context, sel ast.SelectionSet, obj out.Values[i] = ec._User_revoked_timestamp(ctx, field, obj) case "is_multi_factor_auth_enabled": out.Values[i] = ec._User_is_multi_factor_auth_enabled(ctx, field, obj) + case "has_skipped_mfa_setup_at": + out.Values[i] = ec._User_has_skipped_mfa_setup_at(ctx, field, obj) case "app_data": out.Values[i] = ec._User_app_data(ctx, field, obj) default: diff --git a/internal/graph/model/models_gen.go b/internal/graph/model/models_gen.go index b3be90a3..4c9ee7aa 100644 --- a/internal/graph/model/models_gen.go +++ b/internal/graph/model/models_gen.go @@ -78,18 +78,20 @@ type AuditLogs struct { } type AuthResponse struct { - Message string `json:"message"` - ShouldShowEmailOtpScreen *bool `json:"should_show_email_otp_screen,omitempty"` - ShouldShowMobileOtpScreen *bool `json:"should_show_mobile_otp_screen,omitempty"` - ShouldShowTotpScreen *bool `json:"should_show_totp_screen,omitempty"` - AccessToken *string `json:"access_token,omitempty"` - IDToken *string `json:"id_token,omitempty"` - RefreshToken *string `json:"refresh_token,omitempty"` - ExpiresIn *int64 `json:"expires_in,omitempty"` - User *User `json:"user,omitempty"` - AuthenticatorScannerImage *string `json:"authenticator_scanner_image,omitempty"` - AuthenticatorSecret *string `json:"authenticator_secret,omitempty"` - AuthenticatorRecoveryCodes []*string `json:"authenticator_recovery_codes,omitempty"` + Message string `json:"message"` + ShouldShowEmailOtpScreen *bool `json:"should_show_email_otp_screen,omitempty"` + ShouldShowMobileOtpScreen *bool `json:"should_show_mobile_otp_screen,omitempty"` + ShouldShowTotpScreen *bool `json:"should_show_totp_screen,omitempty"` + ShouldOfferWebauthnMfaVerify *bool `json:"should_offer_webauthn_mfa_verify,omitempty"` + ShouldOfferMfaSetup *bool `json:"should_offer_mfa_setup,omitempty"` + AccessToken *string `json:"access_token,omitempty"` + IDToken *string `json:"id_token,omitempty"` + RefreshToken *string `json:"refresh_token,omitempty"` + ExpiresIn *int64 `json:"expires_in,omitempty"` + User *User `json:"user,omitempty"` + AuthenticatorScannerImage *string `json:"authenticator_scanner_image,omitempty"` + AuthenticatorSecret *string `json:"authenticator_secret,omitempty"` + AuthenticatorRecoveryCodes []*string `json:"authenticator_recovery_codes,omitempty"` } type CheckPermissionsInput struct { @@ -471,6 +473,7 @@ type Meta struct { IsEmailOtpMfaEnabled bool `json:"is_email_otp_mfa_enabled"` IsSmsOtpMfaEnabled bool `json:"is_sms_otp_mfa_enabled"` IsWebauthnEnabled bool `json:"is_webauthn_enabled"` + IsMfaEnforced bool `json:"is_mfa_enforced"` } type MobileLoginRequest struct { @@ -942,6 +945,7 @@ type User struct { UpdatedAt *int64 `json:"updated_at,omitempty"` RevokedTimestamp *int64 `json:"revoked_timestamp,omitempty"` IsMultiFactorAuthEnabled *bool `json:"is_multi_factor_auth_enabled,omitempty"` + HasSkippedMfaSetupAt *int64 `json:"has_skipped_mfa_setup_at,omitempty"` AppData map[string]any `json:"app_data,omitempty"` } diff --git a/internal/graph/schema.graphqls b/internal/graph/schema.graphqls index 5af5f478..8c7f3d42 100644 --- a/internal/graph/schema.graphqls +++ b/internal/graph/schema.graphqls @@ -42,6 +42,13 @@ type Meta { is_sms_otp_mfa_enabled: Boolean! # is_webauthn_enabled indicates WebAuthn/passkey enrollment is available (always on; no operator flag). is_webauthn_enabled: Boolean! + # is_mfa_enforced mirrors EnforceMFA — when true, the frontend must not + # offer a standalone passkey primary-login path (it would bypass the org's + # two-factor requirement); passkey should only be offered as a second + # factor after password/social login. The server enforces this + # independently in webauthn_login_verify regardless of what the frontend + # shows. + is_mfa_enforced: Boolean! } # AdminMeta is admin-only configuration metadata exposed via the _admin_meta @@ -80,6 +87,9 @@ type User { updated_at: Int64 revoked_timestamp: Int64 is_multi_factor_auth_enabled: Boolean + # has_skipped_mfa_setup_at is set once the user explicitly skips the + # optional MFA setup prompt shown at login. Null means never skipped. + has_skipped_mfa_setup_at: Int64 app_data: Map } @@ -115,6 +125,19 @@ type AuthResponse { should_show_email_otp_screen: Boolean should_show_mobile_otp_screen: Boolean should_show_totp_screen: Boolean + # should_offer_webauthn_mfa_verify is true when the authenticated-with- + # password user has a registered passkey and MFA verification (not + # enrollment) is required before a token is issued. The frontend should + # offer a "verify with your passkey" action — calling the existing scoped + # webauthn_login_options(email)/webauthn_login_verify mutations — in + # addition to (or instead of) should_show_totp_screen's code-entry form. + should_offer_webauthn_mfa_verify: Boolean + # should_offer_mfa_setup is true when MFA is available but not enforced, + # the user hasn't enrolled, and they haven't skipped setup before. Unlike + # should_show_totp_screen, access_token is ALREADY populated alongside + # this flag — the frontend should log the user in and separately offer + # (not force) MFA setup, e.g. via a dismissible hub with a Skip action. + should_offer_mfa_setup: Boolean access_token: String id_token: String refresh_token: String @@ -1317,6 +1340,10 @@ type Mutation { revoke(params: OAuthRevokeRequest!): Response! verify_otp(params: VerifyOTPRequest!): AuthResponse! resend_otp(params: ResendOTPRequest!): Response! + # skip_mfa_setup records that the authenticated caller explicitly declined + # the optional MFA setup prompt. Fails with FAILED_PRECONDITION if MFA is + # organization-enforced (enforce-mfa) — enforcement is never skippable. + skip_mfa_setup: Response! # WebAuthn / passkey self-service ceremonies (no admin `_` prefix). webauthn_registration_options( email: String diff --git a/internal/graph/schema.resolvers.go b/internal/graph/schema.resolvers.go index 461713ae..fba97056 100644 --- a/internal/graph/schema.resolvers.go +++ b/internal/graph/schema.resolvers.go @@ -82,6 +82,11 @@ func (r *mutationResolver) ResendOtp(ctx context.Context, params model.ResendOTP return r.GraphQLProvider.ResendOTP(ctx, ¶ms) } +// SkipMfaSetup is the resolver for the skip_mfa_setup field. +func (r *mutationResolver) SkipMfaSetup(ctx context.Context) (*model.Response, error) { + return r.GraphQLProvider.SkipMFASetup(ctx) +} + // WebauthnRegistrationOptions is the resolver for the webauthn_registration_options field. func (r *mutationResolver) WebauthnRegistrationOptions(ctx context.Context, email *string) (*model.WebauthnRegistrationOptionsResponse, error) { return r.GraphQLProvider.WebauthnRegistrationOptions(ctx, email) diff --git a/internal/graphql/provider.go b/internal/graphql/provider.go index 75edd491..11d7ea71 100644 --- a/internal/graphql/provider.go +++ b/internal/graphql/provider.go @@ -96,6 +96,9 @@ type Provider interface { // DeactivateAccount is the method to deactivate account. // Permissions: authorized user DeactivateAccount(ctx context.Context) (*model.Response, error) + // SkipMFASetup is the method to skip optional MFA setup. + // Permissions: authorized user + SkipMFASetup(ctx context.Context) (*model.Response, error) // DeleteEmailTemplate is the method to delete email template. // Permissions: authorizer:admin DeleteEmailTemplate(ctx context.Context, params *model.DeleteEmailTemplateRequest) (*model.Response, error) diff --git a/internal/graphql/skip_mfa_setup.go b/internal/graphql/skip_mfa_setup.go new file mode 100644 index 00000000..8e15a13c --- /dev/null +++ b/internal/graphql/skip_mfa_setup.go @@ -0,0 +1,26 @@ +// internal/graphql/skip_mfa_setup.go +package graphql + +import ( + "context" + + "github.com/authorizerdev/authorizer/internal/graph/model" + "github.com/authorizerdev/authorizer/internal/metrics" + "github.com/authorizerdev/authorizer/internal/service" + "github.com/authorizerdev/authorizer/internal/utils" +) + +func (g *graphqlProvider) SkipMFASetup(ctx context.Context) (*model.Response, error) { + gc, err := utils.GinContextFromContext(ctx) + if err != nil { + g.Log.Debug().Err(err).Msg("failed to get gin context") + metrics.RecordSecurityEvent(metrics.SecurityEventGinContextMissing, "graphql") + return nil, err + } + res, side, err := g.ServiceProvider.SkipMFASetup(ctx, service.MetaFromGin(gc)) + if err != nil { + return nil, err + } + service.ApplyToGin(gc, side) + return res, nil +} diff --git a/internal/integration_tests/meta_test.go b/internal/integration_tests/meta_test.go index fa0e6396..8fd49809 100644 --- a/internal/integration_tests/meta_test.go +++ b/internal/integration_tests/meta_test.go @@ -46,6 +46,24 @@ func TestMeta(t *testing.T) { assert.False(t, meta.IsBasicAuthenticationEnabled) }) + t.Run("should reflect enforced MFA", func(t *testing.T) { + cfg2 := getTestConfig() + cfg2.EnforceMFA = true + ts2 := initTestSetup(t, cfg2) + _, ctx2 := createContext(ts2) + + meta, err := ts2.GraphQLProvider.Meta(ctx2) + require.NoError(t, err) + assert.NotNil(t, meta) + assert.True(t, meta.IsMfaEnforced) + }) + + t.Run("should reflect non-enforced MFA by default", func(t *testing.T) { + meta, err := ts.GraphQLProvider.Meta(ctx) + require.NoError(t, err) + assert.False(t, meta.IsMfaEnforced) + }) + t.Run("should expose per-method MFA availability with default config", func(t *testing.T) { meta, err := ts.GraphQLProvider.Meta(ctx) require.NoError(t, err) diff --git a/internal/integration_tests/mfa_gate_login_test.go b/internal/integration_tests/mfa_gate_login_test.go new file mode 100644 index 00000000..146c6d44 --- /dev/null +++ b/internal/integration_tests/mfa_gate_login_test.go @@ -0,0 +1,228 @@ +package integration_tests + +import ( + "context" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/authorizerdev/authorizer/internal/constants" + "github.com/authorizerdev/authorizer/internal/graph/model" + "github.com/authorizerdev/authorizer/internal/refs" + "github.com/authorizerdev/authorizer/internal/storage/schemas" +) + +// TestLoginMFAGateTokenWithholding is the regression guard for the security +// property described in mfa_gate.go and wired into login.go's TOTP branch: +// mfaGateBlockVerify and mfaGateBlockEnroll must NEVER reach the code path +// that sets AccessToken on the login response, while mfaGateNone, +// mfaGateOfferSetup and mfaGateSkippedSetup must all fall through to normal +// token issuance. +// +// mfa_gate_test.go already covers resolveMFAGate's pure decision table in +// isolation. This test drives the same 5 outcomes through the real +// login.go switch (via GraphQLProvider.Login, a thin wrapper around +// service.Provider.Login) with a user/config combination engineered to land +// on exactly one outcome, and asserts on AccessToken directly. A future edit +// that removes a `return` from the mfaGateBlockVerify/mfaGateBlockEnroll +// cases — or that lets one of them fall through — would compile and pass +// TestResolveMFAGate unchanged, but would fail here because AccessToken +// stops being empty. +func TestLoginMFAGateTokenWithholding(t *testing.T) { + const password = "Password@123" + + // signUpUser creates a fresh, auto-verified basic-auth user (email + // verification is off in getTestConfig) via the real SignUp path, so the + // stored password hash is one login.go's bcrypt check actually accepts. + signUpUser := func(t *testing.T, ts *testSetup, ctx context.Context) *schemas.User { + t.Helper() + email := "mfa_gate_" + uuid.NewString() + "@authorizer.dev" + _, err := ts.GraphQLProvider.SignUp(ctx, &model.SignUpRequest{ + Email: &email, Password: password, ConfirmPassword: password, + }) + require.NoError(t, err) + user, err := ts.StorageProvider.GetUserByEmail(ctx, email) + require.NoError(t, err) + return user + } + + // addVerifiedAuthenticator gives the user a completed TOTP authenticator, + // the condition login.go reads as authenticatorVerified=true. + addVerifiedAuthenticator := func(t *testing.T, ts *testSetup, ctx context.Context, userID string) { + t.Helper() + now := time.Now().Unix() + _, err := ts.StorageProvider.AddAuthenticator(ctx, &schemas.Authenticator{ + UserID: userID, + Method: constants.EnvKeyTOTPAuthenticator, + Secret: "dummy-secret-for-gate-test", + VerifiedAt: &now, + }) + require.NoError(t, err) + } + + // addWebauthnCredential gives the user a registered passkey, the + // condition login.go reads (via ListWebauthnCredentialsByUserID) as an + // alternative authenticatorVerified=true source alongside TOTP. + addWebauthnCredential := func(t *testing.T, ts *testSetup, ctx context.Context, userID string) { + t.Helper() + _, err := ts.StorageProvider.AddWebauthnCredential(ctx, &schemas.WebauthnCredential{ + UserID: userID, + CredentialID: uuid.NewString(), + PublicKey: "dummy-public-key-for-gate-test", + }) + require.NoError(t, err) + } + + t.Run("mfaGateBlockVerify withholds the token", func(t *testing.T) { + cfg := getTestConfig() + cfg.EnableMFA = true + cfg.EnableTOTPLogin = true + ts := initTestSetup(t, cfg) + _, ctx := createContext(ts) + + user := signUpUser(t, ts, ctx) + user.IsMultiFactorAuthEnabled = refs.NewBoolRef(true) + user, err := ts.StorageProvider.UpdateUser(ctx, user) + require.NoError(t, err) + addVerifiedAuthenticator(t, ts, ctx, user.ID) + + res, err := ts.GraphQLProvider.Login(ctx, &model.LoginRequest{Email: user.Email, Password: password}) + require.NoError(t, err) + require.NotNil(t, res) + assert.Nil(t, res.AccessToken, "a user with a verified authenticator must not receive a token before verifying it") + assert.True(t, refs.BoolValue(res.ShouldShowTotpScreen)) + assert.False(t, refs.BoolValue(res.ShouldOfferWebauthnMfaVerify), "a TOTP-only user must not be offered a passkey verify option they never registered") + }) + + t.Run("mfaGateBlockVerify offers passkey verify for a passkey-only user", func(t *testing.T) { + cfg := getTestConfig() + cfg.EnableMFA = true + cfg.EnableTOTPLogin = true + ts := initTestSetup(t, cfg) + _, ctx := createContext(ts) + + user := signUpUser(t, ts, ctx) + user.IsMultiFactorAuthEnabled = refs.NewBoolRef(true) + user, err := ts.StorageProvider.UpdateUser(ctx, user) + require.NoError(t, err) + addWebauthnCredential(t, ts, ctx, user.ID) + // No TOTP authenticator enrolled — passkey is this user's only factor. + + res, err := ts.GraphQLProvider.Login(ctx, &model.LoginRequest{Email: user.Email, Password: password}) + require.NoError(t, err) + require.NotNil(t, res) + assert.Nil(t, res.AccessToken, "a user with a registered passkey must not receive a token before verifying it") + assert.False(t, refs.BoolValue(res.ShouldShowTotpScreen), "must not force a TOTP screen on a user who never enrolled TOTP") + assert.True(t, refs.BoolValue(res.ShouldOfferWebauthnMfaVerify)) + }) + + t.Run("mfaGateBlockVerify offers both methods for a dual-enrolled user", func(t *testing.T) { + cfg := getTestConfig() + cfg.EnableMFA = true + cfg.EnableTOTPLogin = true + ts := initTestSetup(t, cfg) + _, ctx := createContext(ts) + + user := signUpUser(t, ts, ctx) + user.IsMultiFactorAuthEnabled = refs.NewBoolRef(true) + user, err := ts.StorageProvider.UpdateUser(ctx, user) + require.NoError(t, err) + addVerifiedAuthenticator(t, ts, ctx, user.ID) + addWebauthnCredential(t, ts, ctx, user.ID) + + res, err := ts.GraphQLProvider.Login(ctx, &model.LoginRequest{Email: user.Email, Password: password}) + require.NoError(t, err) + require.NotNil(t, res) + assert.Nil(t, res.AccessToken) + assert.True(t, refs.BoolValue(res.ShouldShowTotpScreen)) + assert.True(t, refs.BoolValue(res.ShouldOfferWebauthnMfaVerify)) + }) + + t.Run("mfaGateBlockEnroll withholds the token", func(t *testing.T) { + cfg := getTestConfig() + cfg.EnableMFA = true + cfg.EnableTOTPLogin = true + cfg.EnforceMFA = true + ts := initTestSetup(t, cfg) + _, ctx := createContext(ts) + + user := signUpUser(t, ts, ctx) + user.IsMultiFactorAuthEnabled = refs.NewBoolRef(true) + user, err := ts.StorageProvider.UpdateUser(ctx, user) + require.NoError(t, err) + // No authenticator enrolled yet: enforceMFA must force enrollment. + + res, err := ts.GraphQLProvider.Login(ctx, &model.LoginRequest{Email: user.Email, Password: password}) + require.NoError(t, err) + require.NotNil(t, res) + assert.Nil(t, res.AccessToken, "an org-enforced user who hasn't finished enrollment must not receive a token") + assert.True(t, refs.BoolValue(res.ShouldShowTotpScreen)) + assert.NotNil(t, res.AuthenticatorSecret, "block-enroll must hand back a fresh enrollment payload") + }) + + t.Run("mfaGateOfferSetup issues a token and offers setup", func(t *testing.T) { + cfg := getTestConfig() + cfg.EnableMFA = true + cfg.EnableTOTPLogin = true + ts := initTestSetup(t, cfg) + _, ctx := createContext(ts) + + user := signUpUser(t, ts, ctx) + user.IsMultiFactorAuthEnabled = refs.NewBoolRef(true) + user, err := ts.StorageProvider.UpdateUser(ctx, user) + require.NoError(t, err) + // Not enrolled, MFA not enforced, never skipped before. + + res, err := ts.GraphQLProvider.Login(ctx, &model.LoginRequest{Email: user.Email, Password: password}) + require.NoError(t, err) + require.NotNil(t, res) + assert.NotNil(t, res.AccessToken, "optional MFA must not block login") + assert.NotEmpty(t, *res.AccessToken) + assert.True(t, refs.BoolValue(res.ShouldOfferMfaSetup)) + assert.NotNil(t, res.AuthenticatorSecret) + }) + + t.Run("mfaGateSkippedSetup issues a token quietly", func(t *testing.T) { + cfg := getTestConfig() + cfg.EnableMFA = true + cfg.EnableTOTPLogin = true + ts := initTestSetup(t, cfg) + _, ctx := createContext(ts) + + user := signUpUser(t, ts, ctx) + skippedAt := time.Now().Unix() + user.IsMultiFactorAuthEnabled = refs.NewBoolRef(true) + user.HasSkippedMFASetupAt = &skippedAt + user, err := ts.StorageProvider.UpdateUser(ctx, user) + require.NoError(t, err) + + res, err := ts.GraphQLProvider.Login(ctx, &model.LoginRequest{Email: user.Email, Password: password}) + require.NoError(t, err) + require.NotNil(t, res) + assert.NotNil(t, res.AccessToken, "a user who already skipped setup must still be able to log in") + assert.NotEmpty(t, *res.AccessToken) + assert.False(t, refs.BoolValue(res.ShouldOfferMfaSetup), "must not nag a user who already skipped setup") + }) + + t.Run("mfaGateNone issues a token normally", func(t *testing.T) { + cfg := getTestConfig() + cfg.EnableMFA = true + cfg.EnableTOTPLogin = true + ts := initTestSetup(t, cfg) + _, ctx := createContext(ts) + + user := signUpUser(t, ts, ctx) + // IsMultiFactorAuthEnabled left false/unset: the gate is a no-op. + + res, err := ts.GraphQLProvider.Login(ctx, &model.LoginRequest{Email: user.Email, Password: password}) + require.NoError(t, err) + require.NotNil(t, res) + assert.NotNil(t, res.AccessToken) + assert.NotEmpty(t, *res.AccessToken) + assert.False(t, refs.BoolValue(res.ShouldShowTotpScreen)) + assert.False(t, refs.BoolValue(res.ShouldOfferMfaSetup)) + }) +} diff --git a/internal/integration_tests/skip_mfa_setup_test.go b/internal/integration_tests/skip_mfa_setup_test.go new file mode 100644 index 00000000..fbfb18dd --- /dev/null +++ b/internal/integration_tests/skip_mfa_setup_test.go @@ -0,0 +1,158 @@ +package integration_tests + +import ( + "errors" + "fmt" + "testing" + "time" + + "github.com/google/uuid" + "github.com/pquerna/otp/totp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/authorizerdev/authorizer/internal/constants" + "github.com/authorizerdev/authorizer/internal/graph/model" + "github.com/authorizerdev/authorizer/internal/refs" + "github.com/authorizerdev/authorizer/internal/service" + "github.com/authorizerdev/authorizer/internal/storage/schemas" +) + +// TestSkipMFASetup covers the security-relevant behaviors of the +// skip_mfa_setup mutation: +// - with a valid token and MFA optional, it records HasSkippedMFASetupAt +// and a subsequent login no longer offers setup (should_offer_mfa_setup +// is false). +// - with EnforceMFA=true it is rejected with KindFailedPrecondition even +// though the caller is authenticated — enforcement is never skippable, +// and this must be re-checked server-side regardless of what the +// client believes the gate state to be. +// - with no credentials at all, it is rejected with KindUnauthenticated in +// both EnforceMFA states — proving authentication is checked before +// EnforceMFA, so the response code never leaks org-wide MFA enforcement +// to an anonymous caller. +func TestSkipMFASetup(t *testing.T) { + const password = "Password@123" + + t.Run("skips setup when MFA is optional and quiets a later login", func(t *testing.T) { + cfg := getTestConfig() + cfg.EnableMFA = true + cfg.EnableTOTPLogin = true + ts := initTestSetup(t, cfg) + _, ctx := createContext(ts) + + email := "skip_mfa_" + uuid.NewString() + "@authorizer.dev" + _, err := ts.GraphQLProvider.SignUp(ctx, &model.SignUpRequest{ + Email: &email, Password: password, ConfirmPassword: password, + }) + require.NoError(t, err) + user, err := ts.StorageProvider.GetUserByEmail(ctx, email) + require.NoError(t, err) + user.IsMultiFactorAuthEnabled = refs.NewBoolRef(true) + _, err = ts.StorageProvider.UpdateUser(ctx, user) + require.NoError(t, err) + + loginRes, err := ts.GraphQLProvider.Login(ctx, &model.LoginRequest{Email: &email, Password: password}) + require.NoError(t, err) + require.NotNil(t, loginRes.AccessToken) + assert.True(t, refs.BoolValue(loginRes.ShouldOfferMfaSetup), "first login with optional MFA and no prior enrollment/skip must offer setup") + + ts.GinContext.Request.Header.Set("Authorization", "Bearer "+*loginRes.AccessToken) + skipRes, err := ts.GraphQLProvider.SkipMFASetup(ctx) + require.NoError(t, err) + require.NotNil(t, skipRes) + assert.Equal(t, "MFA setup skipped", skipRes.Message) + + updated, err := ts.StorageProvider.GetUserByEmail(ctx, email) + require.NoError(t, err) + assert.NotNil(t, updated.HasSkippedMFASetupAt, "skip_mfa_setup must persist HasSkippedMFASetupAt") + + ts.GinContext.Request.Header.Set("Authorization", "") + secondLogin, err := ts.GraphQLProvider.Login(ctx, &model.LoginRequest{Email: &email, Password: password}) + require.NoError(t, err) + require.NotNil(t, secondLogin.AccessToken) + assert.False(t, refs.BoolValue(secondLogin.ShouldOfferMfaSetup), "must not nag a user who already skipped setup") + }) + + t.Run("rejects with FailedPrecondition when MFA is enforced, even with a valid token", func(t *testing.T) { + cfg := getTestConfig() + cfg.EnableMFA = true + cfg.EnableTOTPLogin = true + cfg.EnforceMFA = true + ts := initTestSetup(t, cfg) + require.NotNil(t, ts.AuthenticatorProvider, "TOTP must be enabled for this test") + req, ctx := createContext(ts) + + // Mint a genuinely valid access token the same way a real enforced-MFA + // user would end up with one: complete TOTP enrollment and verify it + // via the real VerifyOTP path (mirrors + // TestVerifyOTPTOTPThroughService), rather than fabricating a token. + // This proves the EnforceMFA rejection below is a true server-side + // re-check on an authenticated caller, not an artifact of a missing + // or invalid token. + email := "skip_mfa_enforced_" + uuid.NewString() + "@authorizer.dev" + now := time.Now().Unix() + user, err := ts.StorageProvider.AddUser(ctx, &schemas.User{ + Email: refs.NewStringRef(email), + EmailVerifiedAt: &now, + SignupMethods: constants.AuthRecipeMethodBasicAuth, + IsMultiFactorAuthEnabled: refs.NewBoolRef(true), + }) + require.NoError(t, err) + + authConfig, err := ts.AuthenticatorProvider.Generate(ctx, user.ID) + require.NoError(t, err) + require.NotEmpty(t, authConfig.Secret) + + mfaSession := uuid.NewString() + require.NoError(t, ts.MemoryStoreProvider.SetMfaSession(user.ID, mfaSession, time.Now().Add(5*time.Minute).Unix())) + req.Header.Set("Cookie", fmt.Sprintf("%s=%s", constants.MfaCookieName+"_session", mfaSession)) + + code, err := totp.GenerateCode(authConfig.Secret, time.Now()) + require.NoError(t, err) + verifyRes, err := ts.GraphQLProvider.VerifyOTP(ctx, &model.VerifyOTPRequest{ + Email: &email, + Otp: code, + IsTotp: refs.NewBoolRef(true), + }) + require.NoError(t, err) + require.NotNil(t, verifyRes) + require.NotEmpty(t, verifyRes.AccessToken, "a valid TOTP passcode must mint a real access token") + + ts.GinContext.Request.Header.Set("Authorization", "Bearer "+*verifyRes.AccessToken) + skipRes, err := ts.GraphQLProvider.SkipMFASetup(ctx) + require.Error(t, err) + assert.Nil(t, skipRes) + + var svcErr *service.Error + require.True(t, errors.As(err, &svcErr), "expected a *service.Error, got %T: %v", err, err) + assert.Equal(t, service.KindFailedPrecondition, svcErr.Kind, "EnforceMFA must reject with FailedPrecondition, not Unauthenticated or any other kind") + }) + + // An unauthenticated caller (no Authorization header, no session cookie) + // must get Unauthenticated regardless of EnforceMFA. Authentication is + // checked before EnforceMFA in SkipMFASetup precisely so the response + // code never leaks whether MFA is org-enforced to a caller who hasn't + // proven who they are. Covering both EnforceMFA states proves the + // enforced case no longer leaks FailedPrecondition to an anonymous caller. + for _, enforceMFA := range []bool{false, true} { + t.Run(fmt.Sprintf("rejects with Unauthenticated when caller has no credentials (EnforceMFA=%v)", enforceMFA), func(t *testing.T) { + cfg := getTestConfig() + cfg.EnableMFA = true + cfg.EnableTOTPLogin = true + cfg.EnforceMFA = enforceMFA + ts := initTestSetup(t, cfg) + // createContext builds a fresh request with no Authorization + // header and no cookies set — a genuinely credential-less caller. + _, ctx := createContext(ts) + + skipRes, err := ts.GraphQLProvider.SkipMFASetup(ctx) + require.Error(t, err) + assert.Nil(t, skipRes) + + var svcErr *service.Error + require.True(t, errors.As(err, &svcErr), "expected a *service.Error, got %T: %v", err, err) + assert.Equal(t, service.KindUnauthenticated, svcErr.Kind, "a caller with no credentials must get Unauthenticated regardless of EnforceMFA") + }) + } +} diff --git a/internal/integration_tests/webauthn_enforce_mfa_test.go b/internal/integration_tests/webauthn_enforce_mfa_test.go new file mode 100644 index 00000000..9f7cf31f --- /dev/null +++ b/internal/integration_tests/webauthn_enforce_mfa_test.go @@ -0,0 +1,155 @@ +package integration_tests + +import ( + "testing" + "time" + + "github.com/descope/virtualwebauthn" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/authorizerdev/authorizer/internal/constants" + "github.com/authorizerdev/authorizer/internal/graph/model" + "github.com/authorizerdev/authorizer/internal/refs" + "github.com/authorizerdev/authorizer/internal/storage/schemas" +) + +// registerPasskeyForNewUser signs up a fresh verified user, registers one +// passkey via a simulated ceremony, and returns everything a login-time +// assertion needs. Mirrors the setup in webauthn_test.go's +// TestWebauthnPasskeyRegistrationAndLogin. +func registerPasskeyForNewUser(t *testing.T, ts *testSetup) (*schemas.User, virtualwebauthn.RelyingParty, virtualwebauthn.Authenticator, virtualwebauthn.Credential) { + t.Helper() + req, ctx := createContext(ts) + rp := testRelyingParty(t, ts) + credential := virtualwebauthn.NewCredential(virtualwebauthn.KeyTypeEC2) + + email := "enforce_mfa_" + uuid.New().String() + "@authorizer.dev" + password := "Password@123" + signupRes, err := ts.GraphQLProvider.SignUp(ctx, &model.SignUpRequest{ + Email: &email, Password: password, ConfirmPassword: password, + }) + require.NoError(t, err) + require.NotNil(t, signupRes.AccessToken) + req.Header.Set("Authorization", "Bearer "+*signupRes.AccessToken) + + optRes, err := ts.GraphQLProvider.WebauthnRegistrationOptions(ctx, nil) + require.NoError(t, err) + attOpts, err := virtualwebauthn.ParseAttestationOptions(optRes.Options) + require.NoError(t, err) + authenticator := virtualwebauthn.NewAuthenticatorWithOptions(virtualwebauthn.AuthenticatorOptions{ + UserHandle: []byte(attOpts.UserID), + }) + authenticator.AddCredential(credential) + attResp := virtualwebauthn.CreateAttestationResponse(rp, authenticator, credential, *attOpts) + _, err = ts.GraphQLProvider.WebauthnRegistrationVerify(ctx, &model.WebauthnRegistrationVerifyRequest{Credential: attResp}) + require.NoError(t, err) + + user, err := ts.StorageProvider.GetUserByEmail(ctx, email) + require.NoError(t, err) + req.Header.Del("Authorization") + return user, rp, authenticator, credential +} + +func assertPasskeyLogin(t *testing.T, ts *testSetup, rp virtualwebauthn.RelyingParty, authenticator virtualwebauthn.Authenticator, credential virtualwebauthn.Credential) (*model.AuthResponse, error) { + t.Helper() + _, ctx := createContext(ts) + optRes, err := ts.GraphQLProvider.WebauthnLoginOptions(ctx, nil) + require.NoError(t, err) + assertOpts, err := virtualwebauthn.ParseAssertionOptions(optRes.Options) + require.NoError(t, err) + assertResp := virtualwebauthn.CreateAssertionResponse(rp, authenticator, credential, *assertOpts) + return ts.GraphQLProvider.WebauthnLoginVerify(ctx, &model.WebauthnLoginVerifyRequest{Credential: assertResp}) +} + +func TestWebauthnLoginVerifyEnforceMFA(t *testing.T) { + t.Run("EnforceMFA=false — unchanged, issues token", func(t *testing.T) { + cfg := getTestConfig() + ts := initTestSetup(t, cfg) + user, rp, authenticator, credential := registerPasskeyForNewUser(t, ts) + user.IsMultiFactorAuthEnabled = refs.NewBoolRef(true) + _, err := ts.StorageProvider.UpdateUser(t.Context(), user) + require.NoError(t, err) + + authRes, err := assertPasskeyLogin(t, ts, rp, authenticator, credential) + require.NoError(t, err) + require.NotNil(t, authRes.AccessToken, "EnforceMFA=false must not block passkey login") + }) + + t.Run("EnforceMFA=true, user MFA not individually enabled — unaffected", func(t *testing.T) { + cfg := getTestConfig() + cfg.EnforceMFA = true + ts := initTestSetup(t, cfg) + user, rp, authenticator, credential := registerPasskeyForNewUser(t, ts) + // signup.go force-enables IsMultiFactorAuthEnabled for every new user + // while EnforceMFA is on, so a freshly signed-up user can't land here + // with it unset. Explicitly turn it back off — mirrors an admin + // disabling MFA for one account (admin_users.go allows this even + // under EnforceMFA) — to exercise the gate's own precondition. + user.IsMultiFactorAuthEnabled = refs.NewBoolRef(false) + _, err := ts.StorageProvider.UpdateUser(t.Context(), user) + require.NoError(t, err) + + authRes, err := assertPasskeyLogin(t, ts, rp, authenticator, credential) + require.NoError(t, err) + require.NotNil(t, authRes.AccessToken) + }) + + t.Run("EnforceMFA=true, TOTP verified — blocks token, offers totp screen", func(t *testing.T) { + cfg := getTestConfig() + cfg.EnforceMFA = true + cfg.EnableTOTPLogin = true + ts := initTestSetup(t, cfg) + user, rp, authenticator, credential := registerPasskeyForNewUser(t, ts) + user.IsMultiFactorAuthEnabled = refs.NewBoolRef(true) + _, err := ts.StorageProvider.UpdateUser(t.Context(), user) + require.NoError(t, err) + now := time.Now().Unix() + _, err = ts.StorageProvider.AddAuthenticator(t.Context(), &schemas.Authenticator{ + UserID: user.ID, Method: constants.EnvKeyTOTPAuthenticator, + Secret: "dummy-secret", VerifiedAt: &now, + }) + require.NoError(t, err) + + authRes, err := assertPasskeyLogin(t, ts, rp, authenticator, credential) + require.NoError(t, err) + require.NotNil(t, authRes) + assert.Nil(t, authRes.AccessToken, "a user with verified TOTP must not get a token straight off a passkey login when MFA is enforced") + assert.True(t, refs.BoolValue(authRes.ShouldShowTotpScreen)) + assert.Nil(t, authRes.AuthenticatorSecret, "already-enrolled path must not hand back a fresh enrollment payload") + }) + + t.Run("EnforceMFA=true, TOTP not enrolled — blocks token, returns enrollment payload", func(t *testing.T) { + cfg := getTestConfig() + cfg.EnforceMFA = true + cfg.EnableTOTPLogin = true + ts := initTestSetup(t, cfg) + user, rp, authenticator, credential := registerPasskeyForNewUser(t, ts) + user.IsMultiFactorAuthEnabled = refs.NewBoolRef(true) + _, err := ts.StorageProvider.UpdateUser(t.Context(), user) + require.NoError(t, err) + + authRes, err := assertPasskeyLogin(t, ts, rp, authenticator, credential) + require.NoError(t, err) + require.NotNil(t, authRes) + assert.Nil(t, authRes.AccessToken) + assert.True(t, refs.BoolValue(authRes.ShouldShowTotpScreen)) + assert.NotNil(t, authRes.AuthenticatorSecret, "not-yet-enrolled path must hand back a fresh TOTP enrollment payload") + }) + + t.Run("EnforceMFA=true, TOTP disabled server-wide — refuses passkey login entirely", func(t *testing.T) { + cfg := getTestConfig() + cfg.EnforceMFA = true + cfg.EnableTOTPLogin = false + ts := initTestSetup(t, cfg) + user, rp, authenticator, credential := registerPasskeyForNewUser(t, ts) + user.IsMultiFactorAuthEnabled = refs.NewBoolRef(true) + _, err := ts.StorageProvider.UpdateUser(t.Context(), user) + require.NoError(t, err) + + authRes, err := assertPasskeyLogin(t, ts, rp, authenticator, credential) + require.Error(t, err, "must refuse rather than silently issue a token with no compatible second factor available") + assert.Nil(t, authRes) + }) +} diff --git a/internal/service/login.go b/internal/service/login.go index e547e52d..b19eb237 100644 --- a/internal/service/login.go +++ b/internal/service/login.go @@ -31,6 +31,22 @@ import ( // ops visibility. const loginGenericErrMsg = "invalid credentials" +// setMFASession arms a short-lived MFA session (memory-store entry + cookie) +// proving the caller already completed a first authentication factor for +// userID. verify_otp and the scoped webauthn_login_options/_verify flow both +// require this session before they'll act. Shared by Login's TOTP branch and +// WebauthnLoginVerify's EnforceMFA gate. +func (p *provider) setMFASession(meta RequestMetadata, side *ResponseSideEffects, userID string, expiresAt int64) error { + mfaSession := uuid.NewString() + if err := p.MemoryStoreProvider.SetMfaSession(userID, mfaSession, expiresAt); err != nil { + return err + } + for _, c := range cookie.BuildMfaSessionCookies(meta.HostURL, mfaSession, p.Config.AppCookieSecure) { + side.AddCookie(c) + } + return nil +} + // loginDummyBcryptHash is a precomputed bcrypt hash used to equalise the // response time of the user-not-found path with the real password verification // path. Without this, an attacker can distinguish "no such user" from "wrong @@ -52,6 +68,34 @@ func loginPerformDummyPasswordCheck(password string) { _ = bcrypt.CompareHashAndPassword(loginDummyBcryptHash, []byte(password)) } +// totpEnrollment is a freshly generated (unverified) TOTP enrollment +// payload, shared by both the mfaGateBlockEnroll (forced) and +// mfaGateOfferSetup (optional) paths of the TOTP MFA branch below. +type totpEnrollment struct { + ScannerImage string + Secret string + RecoveryCodes []*string +} + +// generateTOTPEnrollment generates a new TOTP secret/QR/recovery-codes for +// userID. Extracted so the TOTP MFA branch doesn't duplicate this call across +// its "block until enrolled" and "offer setup" cases. +func (p *provider) generateTOTPEnrollment(ctx context.Context, userID string) (*totpEnrollment, error) { + authConfig, err := p.AuthenticatorProvider.Generate(ctx, userID) + if err != nil { + return nil, err + } + recoveryCodes := []*string{} + for _, code := range authConfig.RecoveryCodes { + recoveryCodes = append(recoveryCodes, refs.NewStringRef(code)) + } + return &totpEnrollment{ + ScannerImage: authConfig.ScannerImage, + Secret: authConfig.Secret, + RecoveryCodes: recoveryCodes, + }, nil +} + // Login authenticates a user with email or phone number (not both). // Transport-agnostic port of graphqlProvider.Login. // @@ -135,18 +179,6 @@ func (p *provider) Login(ctx context.Context, meta RequestMetadata, params *mode otpData.Otp = otp return otpData, nil } - setOTPMFaSession := func(expiresAt int64) error { - mfaSession := uuid.NewString() - err = p.MemoryStoreProvider.SetMfaSession(user.ID, mfaSession, expiresAt) - if err != nil { - log.Debug().Msg("Failed to set mfa session") - return err - } - for _, c := range cookie.BuildMfaSessionCookies(meta.HostURL, mfaSession, p.Config.AppCookieSecure) { - side.AddCookie(c) - } - return nil - } if isEmailLogin { if !strings.Contains(user.SignupMethods, constants.AuthRecipeMethodBasicAuth) { log.Debug().Str("reason", "wrong_signup_method").Msg("login failed") @@ -184,7 +216,7 @@ func (p *provider) Login(ctx context.Context, meta RequestMetadata, params *mode log.Debug().Msg("Failed to generate otp") return nil, nil, err } - if err := setOTPMFaSession(expiresAt); err != nil { + if err := p.setMFASession(meta, side, user.ID, expiresAt); err != nil { log.Debug().Msg("Failed to set mfa session") return nil, nil, err } @@ -225,7 +257,7 @@ func (p *provider) Login(ctx context.Context, meta RequestMetadata, params *mode log.Debug().Msg("Failed to generate otp") return nil, nil, err } - if err := setOTPMFaSession(expiresAt); err != nil { + if err := p.setMFASession(meta, side, user.ID, expiresAt); err != nil { log.Debug().Msg("Failed to set mfa session") return nil, nil, err } @@ -298,7 +330,7 @@ func (p *provider) Login(ctx context.Context, meta RequestMetadata, params *mode log.Debug().Msg("Failed to generate otp") return nil, nil, err } - if err := setOTPMFaSession(expiresAt); err != nil { + if err := p.setMFASession(meta, side, user.ID, expiresAt); err != nil { log.Debug().Msg("Failed to set mfa session") return nil, nil, err } @@ -327,7 +359,7 @@ func (p *provider) Login(ctx context.Context, meta RequestMetadata, params *mode log.Debug().Msg("Failed to generate otp") return nil, nil, err } - if err := setOTPMFaSession(expiresAt); err != nil { + if err := p.setMFASession(meta, side, user.ID, expiresAt); err != nil { log.Debug().Msg("Failed to set mfa session") return nil, nil, err } @@ -347,41 +379,69 @@ func (p *provider) Login(ctx context.Context, meta RequestMetadata, params *mode }, side, nil } // If mfa enabled and also totp enabled - if refs.BoolValue(user.IsMultiFactorAuthEnabled) && isMFAEnabled && isTOTPLoginEnabled { - expiresAt := time.Now().Add(3 * time.Minute).Unix() - if err := setOTPMFaSession(expiresAt); err != nil { - log.Debug().Msg("Failed to set mfa session") - return nil, nil, err - } - authenticator, err := p.StorageProvider.GetAuthenticatorDetailsByUserId(ctx, user.ID, constants.EnvKeyTOTPAuthenticator) - if err != nil || authenticator == nil || authenticator.VerifiedAt == nil { - // generate totp - // Generate a base64 URL and initiate the registration for TOTP - authConfig, err := p.AuthenticatorProvider.Generate(ctx, user.ID) + if isMFAEnabled && isTOTPLoginEnabled { + authenticator, authErr := p.StorageProvider.GetAuthenticatorDetailsByUserId(ctx, user.ID, constants.EnvKeyTOTPAuthenticator) + totpVerified := authErr == nil && authenticator != nil && authenticator.VerifiedAt != nil + // A WebAuthn credential registered for ANY purpose (passwordless + // primary login or explicit MFA setup — there is no `purpose` field) + // counts as a verified second factor. Ignore a list error rather than + // failing login on it: treat "couldn't check" the same as "found + // none," matching how a missing TOTP authenticator row is handled. + webauthnCreds, _ := p.StorageProvider.ListWebauthnCredentialsByUserID(ctx, user.ID) + hasWebauthnCredential := len(webauthnCreds) > 0 + authenticatorVerified := totpVerified || hasWebauthnCredential + gate := resolveMFAGate( + refs.BoolValue(user.IsMultiFactorAuthEnabled), + p.Config.EnforceMFA, + authenticatorVerified, + user.HasSkippedMFASetupAt != nil, + ) + switch gate { + case mfaGateBlockVerify: + expiresAt := time.Now().Add(3 * time.Minute).Unix() + if err := p.setMFASession(meta, side, user.ID, expiresAt); err != nil { + log.Debug().Msg("Failed to set mfa session") + return nil, nil, err + } + res := &model.AuthResponse{Message: `Proceed to mfa verification`} + if totpVerified { + res.ShouldShowTotpScreen = refs.NewBoolRef(true) + } + if hasWebauthnCredential { + res.ShouldOfferWebauthnMfaVerify = refs.NewBoolRef(true) + } + return res, side, nil + case mfaGateBlockEnroll: + expiresAt := time.Now().Add(3 * time.Minute).Unix() + if err := p.setMFASession(meta, side, user.ID, expiresAt); err != nil { + log.Debug().Msg("Failed to set mfa session") + return nil, nil, err + } + enrollment, err := p.generateTOTPEnrollment(ctx, user.ID) if err != nil { log.Debug().Msg("Failed to generate totp") return nil, nil, err } - recoveryCodes := []*string{} - for _, code := range authConfig.RecoveryCodes { - recoveryCodes = append(recoveryCodes, refs.NewStringRef(code)) - } - // when user is first time registering for totp - res := &model.AuthResponse{ + return &model.AuthResponse{ Message: `Proceed to totp verification screen`, ShouldShowTotpScreen: refs.NewBoolRef(true), - AuthenticatorScannerImage: refs.NewStringRef(authConfig.ScannerImage), - AuthenticatorSecret: refs.NewStringRef(authConfig.Secret), - AuthenticatorRecoveryCodes: recoveryCodes, - } - return res, side, nil - } else { - //when user is already register for totp - res := &model.AuthResponse{ - Message: `Proceed to totp screen`, - ShouldShowTotpScreen: refs.NewBoolRef(true), + AuthenticatorScannerImage: refs.NewStringRef(enrollment.ScannerImage), + AuthenticatorSecret: refs.NewStringRef(enrollment.Secret), + AuthenticatorRecoveryCodes: enrollment.RecoveryCodes, + }, side, nil + case mfaGateOfferSetup: + enrollment, err := p.generateTOTPEnrollment(ctx, user.ID) + if err != nil { + log.Debug().Msg("Failed to generate totp for optional setup") + return nil, nil, err } - return res, side, nil + // Falls through to normal token issuance below, with the offer + // flag and enrollment payload attached after CreateAuthToken. + side.PendingTOTPOffer = enrollment + case mfaGateSkippedSetup: + side.OfferMFASetupQuiet = true + case mfaGateNone: + // fall through, nothing to do } } @@ -457,6 +517,12 @@ func (p *provider) Login(ctx context.Context, meta RequestMetadata, params *mode ExpiresIn: &expiresIn, User: user.AsAPIUser(), } + if side.PendingTOTPOffer != nil { + res.ShouldOfferMfaSetup = refs.NewBoolRef(true) + res.AuthenticatorScannerImage = refs.NewStringRef(side.PendingTOTPOffer.ScannerImage) + res.AuthenticatorSecret = refs.NewStringRef(side.PendingTOTPOffer.Secret) + res.AuthenticatorRecoveryCodes = side.PendingTOTPOffer.RecoveryCodes + } for _, c := range cookie.BuildSessionCookies(meta.HostURL, authToken.FingerPrintHash, p.Config.AppCookieSecure, cookie.ParseSameSite(p.Config.AppCookieSameSite)) { side.AddCookie(c) diff --git a/internal/service/meta.go b/internal/service/meta.go index 04f2f0d1..4f73b143 100644 --- a/internal/service/meta.go +++ b/internal/service/meta.go @@ -37,5 +37,6 @@ func (p *provider) Meta(ctx context.Context, meta RequestMetadata) (*model.Meta, IsSmsOtpMfaEnabled: c.EnableMFA && c.EnableSMSOTP && c.IsSMSServiceEnabled, // WebAuthn/passkey ships always-on with no operator flag. IsWebauthnEnabled: true, + IsMfaEnforced: c.EnforceMFA, }, nil, nil } diff --git a/internal/service/mfa_gate.go b/internal/service/mfa_gate.go new file mode 100644 index 00000000..eef151d2 --- /dev/null +++ b/internal/service/mfa_gate.go @@ -0,0 +1,57 @@ +// internal/service/mfa_gate.go +package service + +// mfaGateDecision is what login.go should do once it knows a user has MFA +// available. See resolveMFAGate for the truth table. +type mfaGateDecision int + +const ( + // mfaGateNone: user has no MFA to worry about. Issue the token normally. + mfaGateNone mfaGateDecision = iota + // mfaGateBlockVerify: user has a verified/completed MFA method already. + // Withhold the token until they verify it. Never skippable — this is the + // user's own opted-in second factor. + mfaGateBlockVerify + // mfaGateBlockEnroll: MFA is org-enforced and this user hasn't finished + // enrollment yet. Withhold the token until enrollment is completed. + // Never skippable. + mfaGateBlockEnroll + // mfaGateOfferSetup: MFA is available but not enforced, the user hasn't + // enrolled, and they've never skipped before. Issue the token now AND + // tell the frontend to offer (not force) MFA setup. + mfaGateOfferSetup + // mfaGateSkippedSetup: same as mfaGateOfferSetup but the user has already + // chosen Skip in the past. Issue the token, don't nag. + mfaGateSkippedSetup +) + +// resolveMFAGate decides what login.go does for a user whose +// IsMultiFactorAuthEnabled flag might be set. Only called when the caller +// has already confirmed MFA is available on this server at all +// (Config.EnableMFA) — see login.go call sites. +// +// - userMFAEnabled: schemas.User.IsMultiFactorAuthEnabled +// - enforceMFA: Config.EnforceMFA (org-wide mandate — absolute, never +// bypassed by hasSkippedSetup) +// - authenticatorVerified: true when the user has a completed/verified MFA +// method already (e.g. a verified TOTP authenticator) — their own opted-in +// second factor, always required once true, regardless of enforceMFA or +// hasSkippedSetup +// - hasSkippedSetup: schemas.User.HasSkippedMFASetupAt != nil +func resolveMFAGate(userMFAEnabled, enforceMFA, authenticatorVerified, hasSkippedSetup bool) mfaGateDecision { + if !userMFAEnabled { + return mfaGateNone + } + if authenticatorVerified { + // The user's own completed second factor. Always required, never + // skippable, regardless of current enforcement policy. + return mfaGateBlockVerify + } + if enforceMFA { + return mfaGateBlockEnroll + } + if hasSkippedSetup { + return mfaGateSkippedSetup + } + return mfaGateOfferSetup +} diff --git a/internal/service/mfa_gate_test.go b/internal/service/mfa_gate_test.go new file mode 100644 index 00000000..41d096c7 --- /dev/null +++ b/internal/service/mfa_gate_test.go @@ -0,0 +1,33 @@ +// internal/service/mfa_gate_test.go +package service + +import "testing" + +func TestResolveMFAGate(t *testing.T) { + cases := []struct { + name string + userMFAEnabled bool + enforceMFA bool + authenticatorVerified bool + hasSkippedSetup bool + want mfaGateDecision + }{ + {"mfa off for user", false, false, false, false, mfaGateNone}, + {"mfa off for user, enforced anyway (inconsistent state defends safe)", false, true, false, false, mfaGateNone}, + {"enforced, not yet enrolled", true, true, false, false, mfaGateBlockEnroll}, + {"enforced, already verified", true, true, true, false, mfaGateBlockVerify}, + {"enforced, skip flag present but ignored", true, true, false, true, mfaGateBlockEnroll}, + {"optional, already verified -> still verify every time", true, false, true, false, mfaGateBlockVerify}, + {"optional, already verified, skip flag stale -> still verify", true, false, true, true, mfaGateBlockVerify}, + {"optional, not enrolled, never skipped -> offer", true, false, false, false, mfaGateOfferSetup}, + {"optional, not enrolled, already skipped -> quiet login", true, false, false, true, mfaGateSkippedSetup}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got := resolveMFAGate(c.userMFAEnabled, c.enforceMFA, c.authenticatorVerified, c.hasSkippedSetup) + if got != c.want { + t.Errorf("resolveMFAGate(%v,%v,%v,%v) = %v, want %v", c.userMFAEnabled, c.enforceMFA, c.authenticatorVerified, c.hasSkippedSetup, got, c.want) + } + }) + } +} diff --git a/internal/service/provider.go b/internal/service/provider.go index 875e07c3..60d81804 100644 --- a/internal/service/provider.go +++ b/internal/service/provider.go @@ -100,6 +100,10 @@ type Provider interface { // and drops all of their sessions. Requires auth. DeactivateAccount(ctx context.Context, meta RequestMetadata) (*model.Response, *ResponseSideEffects, error) + // SkipMFASetup records that the authenticated caller declined optional + // MFA setup. Fails if MFA is org-enforced. + SkipMFASetup(ctx context.Context, meta RequestMetadata) (*model.Response, *ResponseSideEffects, error) + // ResendVerifyEmail re-issues a pending email-verification link. Public — // response is generic to avoid account enumeration. ResendVerifyEmail(ctx context.Context, meta RequestMetadata, params *model.ResendVerifyEmailRequest) (*model.Response, *ResponseSideEffects, error) diff --git a/internal/service/sideeffects.go b/internal/service/sideeffects.go index bd197dd2..621779e1 100644 --- a/internal/service/sideeffects.go +++ b/internal/service/sideeffects.go @@ -65,6 +65,15 @@ type ResponseSideEffects struct { // SameSite, and MaxAge fields are honored as set; the transport adds them // verbatim (gin: gc.SetSameSite + gc.SetCookie; net/http: http.SetCookie). Cookies []*http.Cookie + + // PendingTOTPOffer carries a freshly generated (unverified) TOTP + // enrollment payload to attach to the successful AuthResponse when the + // MFA gate decided to OFFER (not force) setup. Nil otherwise. + PendingTOTPOffer *totpEnrollment + // OfferMFASetupQuiet is true when the MFA gate decided the user already + // skipped setup before — no enrollment payload, no offer flag, just a + // normal login. + OfferMFASetupQuiet bool } // AddCookie appends a cookie to the side-effects. Convenience over manual diff --git a/internal/service/skip_mfa_setup.go b/internal/service/skip_mfa_setup.go new file mode 100644 index 00000000..604dfbbb --- /dev/null +++ b/internal/service/skip_mfa_setup.go @@ -0,0 +1,50 @@ +// internal/service/skip_mfa_setup.go +package service + +import ( + "context" + "time" + + "github.com/authorizerdev/authorizer/internal/graph/model" +) + +// SkipMFASetup records that the authenticated caller explicitly declined the +// optional MFA setup prompt shown at login. Never allowed when MFA is +// org-enforced — that path never offers a skip in the first place +// (resolveMFAGate never returns mfaGateOfferSetup when EnforceMFA is true), +// but this is re-checked here server-side so a client can never forge the +// request to bypass enforcement. +// +// Permissions: authenticated user (bearer token or session cookie). +func (p *provider) SkipMFASetup(ctx context.Context, meta RequestMetadata) (*model.Response, *ResponseSideEffects, error) { + log := p.Log.With().Str("func", "SkipMFASetup").Logger() + + // Authentication is checked before EnforceMFA so the response code never + // leaks org-wide MFA enforcement to a caller with no valid token/session + // (an unauthenticated caller always gets Unauthenticated, regardless of + // EnforceMFA). EnforceMFA is still re-checked below, before any state + // mutation, so HasSkippedMFASetupAt is never set while it is true. + tokenData, err := p.callerTokenData(ctx, meta) + if err != nil || tokenData == nil || tokenData.UserID == "" { + log.Debug().Err(err).Msg("Failed to get user id from session or access token") + return nil, nil, Unauthenticated("unauthorized") + } + + if p.Config.EnforceMFA { + log.Debug().Msg("Cannot skip MFA setup as it is enforced") + return nil, nil, FailedPrecondition("cannot skip multi factor authentication setup as it is enforced by organization") + } + + user, err := p.StorageProvider.GetUserByID(ctx, tokenData.UserID) + if err != nil { + log.Debug().Err(err).Msg("Failed to get user by id") + return nil, nil, err + } + now := time.Now().Unix() + user.HasSkippedMFASetupAt = &now + if _, err := p.StorageProvider.UpdateUser(ctx, user); err != nil { + log.Debug().Err(err).Msg("Failed to update user") + return nil, nil, err + } + return &model.Response{Message: "MFA setup skipped"}, nil, nil +} diff --git a/internal/service/webauthn.go b/internal/service/webauthn.go index 29eff95a..6c01b00a 100644 --- a/internal/service/webauthn.go +++ b/internal/service/webauthn.go @@ -3,6 +3,7 @@ package service import ( "context" "strings" + "time" "github.com/authorizerdev/authorizer/internal/audit" "github.com/authorizerdev/authorizer/internal/constants" @@ -154,6 +155,43 @@ func (p *provider) WebauthnLoginVerify(ctx context.Context, meta RequestMetadata return nil, nil, FailedPrecondition("email is not verified. please verify your email before signing in with a passkey") } + // EnforceMFA is absolute and applies to passkey primary login exactly + // like it applies to password login: a passkey may not silently satisfy + // an org's two-factor requirement. This does not claim a passkey is + // itself insufficient as a factor - it only prevents passkey login from + // becoming an unintended bypass of a policy the org explicitly turned on. + if p.Config.EnforceMFA && refs.BoolValue(user.IsMultiFactorAuthEnabled) { + if !p.Config.EnableTOTPLogin { + log.Debug().Msg("EnforceMFA is on but no compatible second factor is configured for passkey login") + return nil, nil, FailedPrecondition("multi-factor authentication is required but no compatible verification method is available for passkey sign-in; please sign in with your password instead") + } + authenticator, authErr := p.StorageProvider.GetAuthenticatorDetailsByUserId(ctx, user.ID, constants.EnvKeyTOTPAuthenticator) + totpVerified := authErr == nil && authenticator != nil && authenticator.VerifiedAt != nil + expiresAt := time.Now().Add(3 * time.Minute).Unix() + if err := p.setMFASession(meta, side, user.ID, expiresAt); err != nil { + log.Debug().Msg("Failed to set mfa session") + return nil, nil, err + } + if totpVerified { + return &model.AuthResponse{ + Message: `Proceed to mfa verification`, + ShouldShowTotpScreen: refs.NewBoolRef(true), + }, side, nil + } + enrollment, err := p.generateTOTPEnrollment(ctx, user.ID) + if err != nil { + log.Debug().Msg("Failed to generate totp") + return nil, nil, err + } + return &model.AuthResponse{ + Message: `Proceed to totp verification screen`, + ShouldShowTotpScreen: refs.NewBoolRef(true), + AuthenticatorScannerImage: refs.NewStringRef(enrollment.ScannerImage), + AuthenticatorSecret: refs.NewStringRef(enrollment.Secret), + AuthenticatorRecoveryCodes: enrollment.RecoveryCodes, + }, side, nil + } + p.AuditProvider.LogEvent(audit.Event{ Action: constants.AuditLoginSuccessEvent, Protocol: meta.Protocol, ActorID: user.ID, diff --git a/internal/storage/db/cassandradb/provider.go b/internal/storage/db/cassandradb/provider.go index 012f7dc5..99604820 100644 --- a/internal/storage/db/cassandradb/provider.go +++ b/internal/storage/db/cassandradb/provider.go @@ -182,6 +182,13 @@ func NewProvider(cfg *config.Config, deps *Dependencies) (*provider, error) { deps.Log.Debug().Err(err).Msg("Failed to alter table as is_multi_factor_auth_enabled column exists") // continue } + // add has_skipped_mfa_setup_at on users table + userHasSkippedMFASetupAtAlterQuery := fmt.Sprintf(`ALTER TABLE %s.%s ADD has_skipped_mfa_setup_at bigint`, KeySpace, schemas.Collections.User) + err = session.Query(userHasSkippedMFASetupAtAlterQuery).Exec() + if err != nil { + deps.Log.Debug().Err(err).Msg("Failed to alter table as has_skipped_mfa_setup_at column exists") + // continue + } // add external_id and is_active on users table (SCIM provisioning) userExternalIDAlterQuery := fmt.Sprintf(`ALTER TABLE %s.%s ADD external_id text`, KeySpace, schemas.Collections.User) err = session.Query(userExternalIDAlterQuery).Exec() diff --git a/internal/storage/db/cassandradb/user.go b/internal/storage/db/cassandradb/user.go index bc276587..1d5d253a 100644 --- a/internal/storage/db/cassandradb/user.go +++ b/internal/storage/db/cassandradb/user.go @@ -153,13 +153,13 @@ func (p *provider) DeleteUser(ctx context.Context, user *schemas.User) error { func (p *provider) ListUsers(ctx context.Context, pagination *model.Pagination, query string) ([]*schemas.User, *model.Pagination, error) { responseUsers := []*schemas.User{} paginationClone := pagination - const columns = "id, email, email_verified_at, password, signup_methods, given_name, family_name, middle_name, nickname, birthdate, phone_number, phone_number_verified_at, picture, roles, revoked_timestamp, is_multi_factor_auth_enabled, app_data, external_id, is_active, created_at, updated_at" + const columns = "id, email, email_verified_at, password, signup_methods, given_name, family_name, middle_name, nickname, birthdate, phone_number, phone_number_verified_at, picture, roles, revoked_timestamp, is_multi_factor_auth_enabled, has_skipped_mfa_setup_at, app_data, external_id, is_active, created_at, updated_at" scanUser := func(scanner gocql.Scanner) (*schemas.User, error) { var user schemas.User err := scanner.Scan(&user.ID, &user.Email, &user.EmailVerifiedAt, &user.Password, &user.SignupMethods, &user.GivenName, &user.FamilyName, &user.MiddleName, &user.Nickname, &user.Birthdate, &user.PhoneNumber, &user.PhoneNumberVerifiedAt, &user.Picture, &user.Roles, &user.RevokedTimestamp, &user.IsMultiFactorAuthEnabled, - &user.AppData, &user.ExternalID, &user.IsActive, &user.CreatedAt, &user.UpdatedAt) + &user.HasSkippedMFASetupAt, &user.AppData, &user.ExternalID, &user.IsActive, &user.CreatedAt, &user.UpdatedAt) return &user, err } @@ -224,8 +224,8 @@ func (p *provider) ListUsers(ctx context.Context, pagination *model.Pagination, // GetUserByEmail to get user information from database using email address func (p *provider) GetUserByEmail(ctx context.Context, email string) (*schemas.User, error) { var user schemas.User - query := fmt.Sprintf("SELECT id, email, email_verified_at, password, signup_methods, given_name, family_name, middle_name, nickname, birthdate, phone_number, phone_number_verified_at, picture, roles, revoked_timestamp, is_multi_factor_auth_enabled, app_data, external_id, is_active, created_at, updated_at FROM %s WHERE email = ? LIMIT 1 ALLOW FILTERING", KeySpace+"."+schemas.Collections.User) - err := p.db.Query(query, email).Consistency(gocql.One).Scan(&user.ID, &user.Email, &user.EmailVerifiedAt, &user.Password, &user.SignupMethods, &user.GivenName, &user.FamilyName, &user.MiddleName, &user.Nickname, &user.Birthdate, &user.PhoneNumber, &user.PhoneNumberVerifiedAt, &user.Picture, &user.Roles, &user.RevokedTimestamp, &user.IsMultiFactorAuthEnabled, &user.AppData, &user.ExternalID, &user.IsActive, &user.CreatedAt, &user.UpdatedAt) + query := fmt.Sprintf("SELECT id, email, email_verified_at, password, signup_methods, given_name, family_name, middle_name, nickname, birthdate, phone_number, phone_number_verified_at, picture, roles, revoked_timestamp, is_multi_factor_auth_enabled, has_skipped_mfa_setup_at, app_data, external_id, is_active, created_at, updated_at FROM %s WHERE email = ? LIMIT 1 ALLOW FILTERING", KeySpace+"."+schemas.Collections.User) + err := p.db.Query(query, email).Consistency(gocql.One).Scan(&user.ID, &user.Email, &user.EmailVerifiedAt, &user.Password, &user.SignupMethods, &user.GivenName, &user.FamilyName, &user.MiddleName, &user.Nickname, &user.Birthdate, &user.PhoneNumber, &user.PhoneNumberVerifiedAt, &user.Picture, &user.Roles, &user.RevokedTimestamp, &user.IsMultiFactorAuthEnabled, &user.HasSkippedMFASetupAt, &user.AppData, &user.ExternalID, &user.IsActive, &user.CreatedAt, &user.UpdatedAt) if err != nil { return nil, err } @@ -235,8 +235,8 @@ func (p *provider) GetUserByEmail(ctx context.Context, email string) (*schemas.U // GetUserByID to get user information from database using user ID func (p *provider) GetUserByID(ctx context.Context, id string) (*schemas.User, error) { var user schemas.User - query := fmt.Sprintf("SELECT id, email, email_verified_at, password, signup_methods, given_name, family_name, middle_name, nickname, birthdate, phone_number, phone_number_verified_at, picture, roles, revoked_timestamp, is_multi_factor_auth_enabled, app_data, external_id, is_active, created_at, updated_at FROM %s WHERE id = ? LIMIT 1", KeySpace+"."+schemas.Collections.User) - err := p.db.Query(query, id).Consistency(gocql.One).Scan(&user.ID, &user.Email, &user.EmailVerifiedAt, &user.Password, &user.SignupMethods, &user.GivenName, &user.FamilyName, &user.MiddleName, &user.Nickname, &user.Birthdate, &user.PhoneNumber, &user.PhoneNumberVerifiedAt, &user.Picture, &user.Roles, &user.RevokedTimestamp, &user.IsMultiFactorAuthEnabled, &user.AppData, &user.ExternalID, &user.IsActive, &user.CreatedAt, &user.UpdatedAt) + query := fmt.Sprintf("SELECT id, email, email_verified_at, password, signup_methods, given_name, family_name, middle_name, nickname, birthdate, phone_number, phone_number_verified_at, picture, roles, revoked_timestamp, is_multi_factor_auth_enabled, has_skipped_mfa_setup_at, app_data, external_id, is_active, created_at, updated_at FROM %s WHERE id = ? LIMIT 1", KeySpace+"."+schemas.Collections.User) + err := p.db.Query(query, id).Consistency(gocql.One).Scan(&user.ID, &user.Email, &user.EmailVerifiedAt, &user.Password, &user.SignupMethods, &user.GivenName, &user.FamilyName, &user.MiddleName, &user.Nickname, &user.Birthdate, &user.PhoneNumber, &user.PhoneNumberVerifiedAt, &user.Picture, &user.Roles, &user.RevokedTimestamp, &user.IsMultiFactorAuthEnabled, &user.HasSkippedMFASetupAt, &user.AppData, &user.ExternalID, &user.IsActive, &user.CreatedAt, &user.UpdatedAt) if err != nil { return nil, err } @@ -313,8 +313,8 @@ func (p *provider) UpdateUsers(ctx context.Context, data map[string]interface{}, // GetUserByPhoneNumber to get user information from database using phone number func (p *provider) GetUserByPhoneNumber(ctx context.Context, phoneNumber string) (*schemas.User, error) { var user schemas.User - query := fmt.Sprintf("SELECT id, email, email_verified_at, password, signup_methods, given_name, family_name, middle_name, nickname, birthdate, phone_number, phone_number_verified_at, picture, roles, revoked_timestamp, is_multi_factor_auth_enabled, app_data, external_id, is_active, created_at, updated_at FROM %s WHERE phone_number = ? LIMIT 1 ALLOW FILTERING", KeySpace+"."+schemas.Collections.User) - err := p.db.Query(query, phoneNumber).Consistency(gocql.One).Scan(&user.ID, &user.Email, &user.EmailVerifiedAt, &user.Password, &user.SignupMethods, &user.GivenName, &user.FamilyName, &user.MiddleName, &user.Nickname, &user.Birthdate, &user.PhoneNumber, &user.PhoneNumberVerifiedAt, &user.Picture, &user.Roles, &user.RevokedTimestamp, &user.IsMultiFactorAuthEnabled, &user.AppData, &user.ExternalID, &user.IsActive, &user.CreatedAt, &user.UpdatedAt) + query := fmt.Sprintf("SELECT id, email, email_verified_at, password, signup_methods, given_name, family_name, middle_name, nickname, birthdate, phone_number, phone_number_verified_at, picture, roles, revoked_timestamp, is_multi_factor_auth_enabled, has_skipped_mfa_setup_at, app_data, external_id, is_active, created_at, updated_at FROM %s WHERE phone_number = ? LIMIT 1 ALLOW FILTERING", KeySpace+"."+schemas.Collections.User) + err := p.db.Query(query, phoneNumber).Consistency(gocql.One).Scan(&user.ID, &user.Email, &user.EmailVerifiedAt, &user.Password, &user.SignupMethods, &user.GivenName, &user.FamilyName, &user.MiddleName, &user.Nickname, &user.Birthdate, &user.PhoneNumber, &user.PhoneNumberVerifiedAt, &user.Picture, &user.Roles, &user.RevokedTimestamp, &user.IsMultiFactorAuthEnabled, &user.HasSkippedMFASetupAt, &user.AppData, &user.ExternalID, &user.IsActive, &user.CreatedAt, &user.UpdatedAt) if err != nil { return nil, err } @@ -325,8 +325,8 @@ func (p *provider) GetUserByPhoneNumber(ctx context.Context, phoneNumber string) // external ID. The lookup key is the composite ":". func (p *provider) GetUserByExternalID(ctx context.Context, orgID, externalID string) (*schemas.User, error) { var user schemas.User - query := fmt.Sprintf("SELECT id, email, email_verified_at, password, signup_methods, given_name, family_name, middle_name, nickname, birthdate, phone_number, phone_number_verified_at, picture, roles, revoked_timestamp, is_multi_factor_auth_enabled, app_data, external_id, is_active, created_at, updated_at FROM %s WHERE external_id = ? LIMIT 1 ALLOW FILTERING", KeySpace+"."+schemas.Collections.User) - err := p.db.Query(query, orgID+":"+externalID).Consistency(gocql.One).Scan(&user.ID, &user.Email, &user.EmailVerifiedAt, &user.Password, &user.SignupMethods, &user.GivenName, &user.FamilyName, &user.MiddleName, &user.Nickname, &user.Birthdate, &user.PhoneNumber, &user.PhoneNumberVerifiedAt, &user.Picture, &user.Roles, &user.RevokedTimestamp, &user.IsMultiFactorAuthEnabled, &user.AppData, &user.ExternalID, &user.IsActive, &user.CreatedAt, &user.UpdatedAt) + query := fmt.Sprintf("SELECT id, email, email_verified_at, password, signup_methods, given_name, family_name, middle_name, nickname, birthdate, phone_number, phone_number_verified_at, picture, roles, revoked_timestamp, is_multi_factor_auth_enabled, has_skipped_mfa_setup_at, app_data, external_id, is_active, created_at, updated_at FROM %s WHERE external_id = ? LIMIT 1 ALLOW FILTERING", KeySpace+"."+schemas.Collections.User) + err := p.db.Query(query, orgID+":"+externalID).Consistency(gocql.One).Scan(&user.ID, &user.Email, &user.EmailVerifiedAt, &user.Password, &user.SignupMethods, &user.GivenName, &user.FamilyName, &user.MiddleName, &user.Nickname, &user.Birthdate, &user.PhoneNumber, &user.PhoneNumberVerifiedAt, &user.Picture, &user.Roles, &user.RevokedTimestamp, &user.IsMultiFactorAuthEnabled, &user.HasSkippedMFASetupAt, &user.AppData, &user.ExternalID, &user.IsActive, &user.CreatedAt, &user.UpdatedAt) if err != nil { return nil, err } diff --git a/internal/storage/db/couchbase/user.go b/internal/storage/db/couchbase/user.go index f876cfbf..609fef72 100644 --- a/internal/storage/db/couchbase/user.go +++ b/internal/storage/db/couchbase/user.go @@ -122,7 +122,7 @@ func (p *provider) ListUsers(ctx context.Context, pagination *model.Pagination, params = append(params, "%"+strings.ToLower(q)+"%") } - userQuery := fmt.Sprintf("SELECT _id, email, email_verified_at, `password`, signup_methods, given_name, family_name, middle_name, nickname, birthdate, phone_number, phone_number_verified_at, picture, `roles`, revoked_timestamp, is_multi_factor_auth_enabled, app_data, is_active, external_id, created_at, updated_at FROM %s.%s%s ORDER BY id OFFSET $%d LIMIT $%d", p.scopeName, schemas.Collections.User, whereClause, len(params)+1, len(params)+2) + userQuery := fmt.Sprintf("SELECT _id, email, email_verified_at, `password`, signup_methods, given_name, family_name, middle_name, nickname, birthdate, phone_number, phone_number_verified_at, picture, `roles`, revoked_timestamp, is_multi_factor_auth_enabled, has_skipped_mfa_setup_at, app_data, is_active, external_id, created_at, updated_at FROM %s.%s%s ORDER BY id OFFSET $%d LIMIT $%d", p.scopeName, schemas.Collections.User, whereClause, len(params)+1, len(params)+2) queryResult, err := p.db.Query(userQuery, &gocb.QueryOptions{ ScanConsistency: gocb.QueryScanConsistencyRequestPlus, Context: ctx, @@ -155,7 +155,7 @@ func (p *provider) ListUsers(ctx context.Context, pagination *model.Pagination, // GetUserByEmail to get user information from database using email address func (p *provider) GetUserByEmail(ctx context.Context, email string) (*schemas.User, error) { - query := fmt.Sprintf("SELECT _id, email, email_verified_at, `password`, signup_methods, given_name, family_name, middle_name, nickname, birthdate, phone_number, phone_number_verified_at, picture, `roles`, revoked_timestamp, is_multi_factor_auth_enabled, app_data, is_active, external_id, created_at, updated_at FROM %s.%s WHERE email = $1 LIMIT 1", p.scopeName, schemas.Collections.User) + query := fmt.Sprintf("SELECT _id, email, email_verified_at, `password`, signup_methods, given_name, family_name, middle_name, nickname, birthdate, phone_number, phone_number_verified_at, picture, `roles`, revoked_timestamp, is_multi_factor_auth_enabled, has_skipped_mfa_setup_at, app_data, is_active, external_id, created_at, updated_at FROM %s.%s WHERE email = $1 LIMIT 1", p.scopeName, schemas.Collections.User) q, err := p.db.Query(query, &gocb.QueryOptions{ ScanConsistency: gocb.QueryScanConsistencyRequestPlus, Context: ctx, @@ -179,7 +179,7 @@ func (p *provider) GetUserByEmail(ctx context.Context, email string) (*schemas.U // org-namespaced external ID. external_id is stored as ":" // so IdP identifiers never collide across organizations. func (p *provider) GetUserByExternalID(ctx context.Context, orgID, externalID string) (*schemas.User, error) { - query := fmt.Sprintf("SELECT _id, email, email_verified_at, `password`, signup_methods, given_name, family_name, middle_name, nickname, birthdate, phone_number, phone_number_verified_at, picture, `roles`, revoked_timestamp, is_multi_factor_auth_enabled, app_data, is_active, external_id, created_at, updated_at FROM %s.%s WHERE external_id = $1 LIMIT 1", p.scopeName, schemas.Collections.User) + query := fmt.Sprintf("SELECT _id, email, email_verified_at, `password`, signup_methods, given_name, family_name, middle_name, nickname, birthdate, phone_number, phone_number_verified_at, picture, `roles`, revoked_timestamp, is_multi_factor_auth_enabled, has_skipped_mfa_setup_at, app_data, is_active, external_id, created_at, updated_at FROM %s.%s WHERE external_id = $1 LIMIT 1", p.scopeName, schemas.Collections.User) q, err := p.db.Query(query, &gocb.QueryOptions{ ScanConsistency: gocb.QueryScanConsistencyRequestPlus, Context: ctx, @@ -201,7 +201,7 @@ func (p *provider) GetUserByExternalID(ctx context.Context, orgID, externalID st // GetUserByID to get user information from database using user ID func (p *provider) GetUserByID(ctx context.Context, id string) (*schemas.User, error) { - query := fmt.Sprintf("SELECT _id, email, email_verified_at, `password`, signup_methods, given_name, family_name, middle_name, nickname, birthdate, phone_number, phone_number_verified_at, picture, `roles`, revoked_timestamp, is_multi_factor_auth_enabled, app_data, is_active, external_id, created_at, updated_at FROM %s.%s WHERE _id = $1 LIMIT 1", p.scopeName, schemas.Collections.User) + query := fmt.Sprintf("SELECT _id, email, email_verified_at, `password`, signup_methods, given_name, family_name, middle_name, nickname, birthdate, phone_number, phone_number_verified_at, picture, `roles`, revoked_timestamp, is_multi_factor_auth_enabled, has_skipped_mfa_setup_at, app_data, is_active, external_id, created_at, updated_at FROM %s.%s WHERE _id = $1 LIMIT 1", p.scopeName, schemas.Collections.User) q, err := p.db.Query(query, &gocb.QueryOptions{ ScanConsistency: gocb.QueryScanConsistencyRequestPlus, Context: ctx, @@ -257,7 +257,7 @@ func (p *provider) UpdateUsers(ctx context.Context, data map[string]interface{}, // GetUserByPhoneNumber to get user information from database using phone number func (p *provider) GetUserByPhoneNumber(ctx context.Context, phoneNumber string) (*schemas.User, error) { - query := fmt.Sprintf("SELECT _id, email, email_verified_at, `password`, signup_methods, given_name, family_name, middle_name, nickname, birthdate, phone_number, phone_number_verified_at, picture, `roles`, revoked_timestamp, is_multi_factor_auth_enabled, app_data, is_active, external_id, created_at, updated_at FROM %s.%s WHERE phone_number = $1 LIMIT 1", p.scopeName, schemas.Collections.User) + query := fmt.Sprintf("SELECT _id, email, email_verified_at, `password`, signup_methods, given_name, family_name, middle_name, nickname, birthdate, phone_number, phone_number_verified_at, picture, `roles`, revoked_timestamp, is_multi_factor_auth_enabled, has_skipped_mfa_setup_at, app_data, is_active, external_id, created_at, updated_at FROM %s.%s WHERE phone_number = $1 LIMIT 1", p.scopeName, schemas.Collections.User) q, err := p.db.Query(query, &gocb.QueryOptions{ ScanConsistency: gocb.QueryScanConsistencyRequestPlus, Context: ctx, diff --git a/internal/storage/db/dynamodb/user.go b/internal/storage/db/dynamodb/user.go index 1e28f4da..a12f09c7 100644 --- a/internal/storage/db/dynamodb/user.go +++ b/internal/storage/db/dynamodb/user.go @@ -112,6 +112,9 @@ func userDynamoRemoveAttrsIfNil(u *schemas.User) []string { if u.IsMultiFactorAuthEnabled == nil { remove = append(remove, "is_multi_factor_auth_enabled") } + if u.HasSkippedMFASetupAt == nil { + remove = append(remove, "has_skipped_mfa_setup_at") + } if u.AppData == nil { remove = append(remove, "app_data") } diff --git a/internal/storage/schemas/user.go b/internal/storage/schemas/user.go index ed99fcbe..554c4364 100644 --- a/internal/storage/schemas/user.go +++ b/internal/storage/schemas/user.go @@ -34,9 +34,13 @@ type User struct { Roles string `json:"roles" bson:"roles" cql:"roles" dynamo:"roles"` RevokedTimestamp *int64 `json:"revoked_timestamp" bson:"revoked_timestamp" cql:"revoked_timestamp" dynamo:"revoked_timestamp"` IsMultiFactorAuthEnabled *bool `json:"is_multi_factor_auth_enabled" bson:"is_multi_factor_auth_enabled" cql:"is_multi_factor_auth_enabled" dynamo:"is_multi_factor_auth_enabled"` - UpdatedAt int64 `json:"updated_at" bson:"updated_at" cql:"updated_at" dynamo:"updated_at"` - CreatedAt int64 `json:"created_at" bson:"created_at" cql:"created_at" dynamo:"created_at"` - AppData *string `json:"app_data" bson:"app_data" cql:"app_data" dynamo:"app_data"` + // HasSkippedMFASetupAt is set the moment a user explicitly skips the + // optional MFA setup prompt shown at login (never set when EnforceMFA is + // on — skip is not offered in that mode). Nil means "never skipped." + HasSkippedMFASetupAt *int64 `json:"has_skipped_mfa_setup_at" bson:"has_skipped_mfa_setup_at" cql:"has_skipped_mfa_setup_at" dynamo:"has_skipped_mfa_setup_at"` + UpdatedAt int64 `json:"updated_at" bson:"updated_at" cql:"updated_at" dynamo:"updated_at"` + CreatedAt int64 `json:"created_at" bson:"created_at" cql:"created_at" dynamo:"created_at"` + AppData *string `json:"app_data" bson:"app_data" cql:"app_data" dynamo:"app_data"` // ExternalID is the stable key an external IdP (SCIM/SSO) assigns to this // user. It is nullable — only IdP-provisioned users carry one. For SCIM it @@ -80,6 +84,7 @@ func (user *User) AsAPIUser() *model.User { Roles: strings.Split(user.Roles, ","), RevokedTimestamp: user.RevokedTimestamp, IsMultiFactorAuthEnabled: user.IsMultiFactorAuthEnabled, + HasSkippedMfaSetupAt: user.HasSkippedMFASetupAt, CreatedAt: refs.NewInt64Ref(user.CreatedAt), UpdatedAt: refs.NewInt64Ref(user.UpdatedAt), AppData: appDataMap, diff --git a/internal/storage/schemas/user_test.go b/internal/storage/schemas/user_test.go new file mode 100644 index 00000000..d866111c --- /dev/null +++ b/internal/storage/schemas/user_test.go @@ -0,0 +1,22 @@ +package schemas + +import ( + "testing" + + "github.com/authorizerdev/authorizer/internal/refs" + "github.com/stretchr/testify/assert" +) + +// TestAsAPIUserMapsHasSkippedMFASetupAt proves the one piece of real logic +// Task 1 adds: User.HasSkippedMFASetupAt (storage) must pass through to +// model.User.HasSkippedMfaSetupAt (API) unchanged, in both directions — +// nil ("never skipped") and set (explicit skip timestamp). +func TestAsAPIUserMapsHasSkippedMFASetupAt(t *testing.T) { + skippedAt := refs.NewInt64Ref(1700000000) + + user := &User{ID: "user-1", HasSkippedMFASetupAt: skippedAt} + assert.Equal(t, skippedAt, user.AsAPIUser().HasSkippedMfaSetupAt) + + neverSkipped := &User{ID: "user-2"} + assert.Nil(t, neverSkipped.AsAPIUser().HasSkippedMfaSetupAt) +}