From 33584c213c90d29e88b9bcb009d7af53493b032d Mon Sep 17 00:00:00 2001 From: Lakhan Samani Date: Sun, 12 Jul 2026 08:48:03 +0530 Subject: [PATCH] feat(meta): expose per-method MFA availability on public meta - add is_totp_mfa_enabled, is_email_otp_mfa_enabled, is_sms_otp_mfa_enabled, is_webauthn_enabled to the public Meta type (GraphQL + proto) - derive OTP/TOTP flags with the same per-method gating login enforces; webauthn is always-on (no operator flag) - lets the hosted login app render only the MFA methods the instance supports --- gen/go/authorizer/v1/types.pb.go | 73 +++++-- gen/openapi/authorizer.swagger.json | 12 ++ internal/graph/generated/generated.go | 244 ++++++++++++++++++++++++ internal/graph/model/models_gen.go | 4 + internal/graph/schema.graphqls | 8 + internal/grpcsrv/handlers/authorizer.go | 4 + internal/integration_tests/meta_test.go | 63 ++++++ internal/service/meta.go | 5 + proto/authorizer/v1/types.proto | 4 + 9 files changed, 403 insertions(+), 14 deletions(-) diff --git a/gen/go/authorizer/v1/types.pb.go b/gen/go/authorizer/v1/types.pb.go index 22a27632e..ded81f1b6 100644 --- a/gen/go/authorizer/v1/types.pb.go +++ b/gen/go/authorizer/v1/types.pb.go @@ -693,6 +693,10 @@ type Meta struct { IsMobileBasicAuthenticationEnabled bool `protobuf:"varint,19,opt,name=is_mobile_basic_authentication_enabled,json=isMobileBasicAuthenticationEnabled,proto3" json:"is_mobile_basic_authentication_enabled,omitempty"` IsPhoneVerificationEnabled bool `protobuf:"varint,20,opt,name=is_phone_verification_enabled,json=isPhoneVerificationEnabled,proto3" json:"is_phone_verification_enabled,omitempty"` IsOrgDiscoveryEnabled bool `protobuf:"varint,21,opt,name=is_org_discovery_enabled,json=isOrgDiscoveryEnabled,proto3" json:"is_org_discovery_enabled,omitempty"` + IsTotpMfaEnabled bool `protobuf:"varint,22,opt,name=is_totp_mfa_enabled,json=isTotpMfaEnabled,proto3" json:"is_totp_mfa_enabled,omitempty"` + IsEmailOtpMfaEnabled bool `protobuf:"varint,23,opt,name=is_email_otp_mfa_enabled,json=isEmailOtpMfaEnabled,proto3" json:"is_email_otp_mfa_enabled,omitempty"` + IsSmsOtpMfaEnabled bool `protobuf:"varint,24,opt,name=is_sms_otp_mfa_enabled,json=isSmsOtpMfaEnabled,proto3" json:"is_sms_otp_mfa_enabled,omitempty"` + IsWebauthnEnabled bool `protobuf:"varint,25,opt,name=is_webauthn_enabled,json=isWebauthnEnabled,proto3" json:"is_webauthn_enabled,omitempty"` } func (x *Meta) Reset() { @@ -872,6 +876,34 @@ func (x *Meta) GetIsOrgDiscoveryEnabled() bool { return false } +func (x *Meta) GetIsTotpMfaEnabled() bool { + if x != nil { + return x.IsTotpMfaEnabled + } + return false +} + +func (x *Meta) GetIsEmailOtpMfaEnabled() bool { + if x != nil { + return x.IsEmailOtpMfaEnabled + } + return false +} + +func (x *Meta) GetIsSmsOtpMfaEnabled() bool { + if x != nil { + return x.IsSmsOtpMfaEnabled + } + return false +} + +func (x *Meta) GetIsWebauthnEnabled() bool { + if x != nil { + return x.IsWebauthnEnabled + } + return false +} + var File_authorizer_v1_types_proto protoreflect.FileDescriptor var file_authorizer_v1_types_proto_rawDesc = []byte{ @@ -990,7 +1022,7 @@ var file_authorizer_v1_types_proto_rawDesc = []byte{ 0x0a, 0x06, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x12, 0x18, 0x0a, 0x07, 0x61, 0x6c, 0x6c, 0x6f, 0x77, 0x65, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x61, 0x6c, 0x6c, 0x6f, 0x77, 0x65, 0x64, - 0x22, 0xb5, 0x09, 0x0a, 0x04, 0x4d, 0x65, 0x74, 0x61, 0x12, 0x18, 0x0a, 0x07, 0x76, 0x65, 0x72, + 0x22, 0x80, 0x0b, 0x0a, 0x04, 0x4d, 0x65, 0x74, 0x61, 0x12, 0x18, 0x0a, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x1b, 0x0a, 0x09, 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x49, 0x64, @@ -1065,19 +1097,32 @@ var file_authorizer_v1_types_proto_rawDesc = []byte{ 0x37, 0x0a, 0x18, 0x69, 0x73, 0x5f, 0x6f, 0x72, 0x67, 0x5f, 0x64, 0x69, 0x73, 0x63, 0x6f, 0x76, 0x65, 0x72, 0x79, 0x5f, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x15, 0x20, 0x01, 0x28, 0x08, 0x52, 0x15, 0x69, 0x73, 0x4f, 0x72, 0x67, 0x44, 0x69, 0x73, 0x63, 0x6f, 0x76, 0x65, 0x72, - 0x79, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x42, 0xbb, 0x01, 0x0a, 0x11, 0x63, 0x6f, 0x6d, - 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x42, 0x0a, - 0x54, 0x79, 0x70, 0x65, 0x73, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x50, 0x01, 0x5a, 0x45, 0x67, 0x69, - 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, - 0x7a, 0x65, 0x72, 0x64, 0x65, 0x76, 0x2f, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, - 0x72, 0x2f, 0x67, 0x65, 0x6e, 0x2f, 0x67, 0x6f, 0x2f, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, - 0x7a, 0x65, 0x72, 0x2f, 0x76, 0x31, 0x3b, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, - 0x72, 0x76, 0x31, 0xa2, 0x02, 0x03, 0x41, 0x58, 0x58, 0xaa, 0x02, 0x0d, 0x41, 0x75, 0x74, 0x68, - 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x56, 0x31, 0xca, 0x02, 0x0d, 0x41, 0x75, 0x74, 0x68, - 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x5c, 0x56, 0x31, 0xe2, 0x02, 0x19, 0x41, 0x75, 0x74, 0x68, - 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x5c, 0x56, 0x31, 0x5c, 0x47, 0x50, 0x42, 0x4d, 0x65, 0x74, - 0x61, 0x64, 0x61, 0x74, 0x61, 0xea, 0x02, 0x0e, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, - 0x65, 0x72, 0x3a, 0x3a, 0x56, 0x31, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, + 0x79, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x2d, 0x0a, 0x13, 0x69, 0x73, 0x5f, 0x74, + 0x6f, 0x74, 0x70, 0x5f, 0x6d, 0x66, 0x61, 0x5f, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, + 0x16, 0x20, 0x01, 0x28, 0x08, 0x52, 0x10, 0x69, 0x73, 0x54, 0x6f, 0x74, 0x70, 0x4d, 0x66, 0x61, + 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x36, 0x0a, 0x18, 0x69, 0x73, 0x5f, 0x65, 0x6d, + 0x61, 0x69, 0x6c, 0x5f, 0x6f, 0x74, 0x70, 0x5f, 0x6d, 0x66, 0x61, 0x5f, 0x65, 0x6e, 0x61, 0x62, + 0x6c, 0x65, 0x64, 0x18, 0x17, 0x20, 0x01, 0x28, 0x08, 0x52, 0x14, 0x69, 0x73, 0x45, 0x6d, 0x61, + 0x69, 0x6c, 0x4f, 0x74, 0x70, 0x4d, 0x66, 0x61, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, + 0x32, 0x0a, 0x16, 0x69, 0x73, 0x5f, 0x73, 0x6d, 0x73, 0x5f, 0x6f, 0x74, 0x70, 0x5f, 0x6d, 0x66, + 0x61, 0x5f, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x18, 0x20, 0x01, 0x28, 0x08, 0x52, + 0x12, 0x69, 0x73, 0x53, 0x6d, 0x73, 0x4f, 0x74, 0x70, 0x4d, 0x66, 0x61, 0x45, 0x6e, 0x61, 0x62, + 0x6c, 0x65, 0x64, 0x12, 0x2e, 0x0a, 0x13, 0x69, 0x73, 0x5f, 0x77, 0x65, 0x62, 0x61, 0x75, 0x74, + 0x68, 0x6e, 0x5f, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x19, 0x20, 0x01, 0x28, 0x08, + 0x52, 0x11, 0x69, 0x73, 0x57, 0x65, 0x62, 0x61, 0x75, 0x74, 0x68, 0x6e, 0x45, 0x6e, 0x61, 0x62, + 0x6c, 0x65, 0x64, 0x42, 0xbb, 0x01, 0x0a, 0x11, 0x63, 0x6f, 0x6d, 0x2e, 0x61, 0x75, 0x74, 0x68, + 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x42, 0x0a, 0x54, 0x79, 0x70, 0x65, 0x73, + 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x50, 0x01, 0x5a, 0x45, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, + 0x63, 0x6f, 0x6d, 0x2f, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x64, 0x65, + 0x76, 0x2f, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2f, 0x67, 0x65, 0x6e, + 0x2f, 0x67, 0x6f, 0x2f, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2f, 0x76, + 0x31, 0x3b, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x76, 0x31, 0xa2, 0x02, + 0x03, 0x41, 0x58, 0x58, 0xaa, 0x02, 0x0d, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, + 0x72, 0x2e, 0x56, 0x31, 0xca, 0x02, 0x0d, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, + 0x72, 0x5c, 0x56, 0x31, 0xe2, 0x02, 0x19, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, + 0x72, 0x5c, 0x56, 0x31, 0x5c, 0x47, 0x50, 0x42, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, + 0xea, 0x02, 0x0e, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x3a, 0x3a, 0x56, + 0x31, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, } var ( diff --git a/gen/openapi/authorizer.swagger.json b/gen/openapi/authorizer.swagger.json index 06dc2f7d0..ae9e4ef3f 100644 --- a/gen/openapi/authorizer.swagger.json +++ b/gen/openapi/authorizer.swagger.json @@ -2141,6 +2141,18 @@ }, "is_org_discovery_enabled": { "type": "boolean" + }, + "is_totp_mfa_enabled": { + "type": "boolean" + }, + "is_email_otp_mfa_enabled": { + "type": "boolean" + }, + "is_sms_otp_mfa_enabled": { + "type": "boolean" + }, + "is_webauthn_enabled": { + "type": "boolean" } }, "description": "Meta mirrors the GraphQL Meta type — server feature flags + provider\navailability, returned by the Meta query." diff --git a/internal/graph/generated/generated.go b/internal/graph/generated/generated.go index 5d52c31b4..9a5442e28 100644 --- a/internal/graph/generated/generated.go +++ b/internal/graph/generated/generated.go @@ -261,6 +261,7 @@ type ComplexityRoot struct { IsAppleLoginEnabled func(childComplexity int) int IsBasicAuthenticationEnabled func(childComplexity int) int IsDiscordLoginEnabled func(childComplexity int) int + IsEmailOtpMfaEnabled func(childComplexity int) int IsEmailVerificationEnabled func(childComplexity int) int IsFacebookLoginEnabled func(childComplexity int) int IsGithubLoginEnabled func(childComplexity int) int @@ -274,9 +275,12 @@ type ComplexityRoot struct { IsPhoneVerificationEnabled func(childComplexity int) int IsRobloxLoginEnabled func(childComplexity int) int IsSignUpEnabled func(childComplexity int) int + IsSmsOtpMfaEnabled func(childComplexity int) int IsStrongPasswordEnabled func(childComplexity int) int + IsTotpMfaEnabled func(childComplexity int) int IsTwitchLoginEnabled func(childComplexity int) int IsTwitterLoginEnabled func(childComplexity int) int + IsWebauthnEnabled func(childComplexity int) int Version func(childComplexity int) int } @@ -1797,6 +1801,13 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return e.complexity.Meta.IsDiscordLoginEnabled(childComplexity), true + case "Meta.is_email_otp_mfa_enabled": + if e.complexity.Meta.IsEmailOtpMfaEnabled == nil { + break + } + + return e.complexity.Meta.IsEmailOtpMfaEnabled(childComplexity), true + case "Meta.is_email_verification_enabled": if e.complexity.Meta.IsEmailVerificationEnabled == nil { break @@ -1888,6 +1899,13 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return e.complexity.Meta.IsSignUpEnabled(childComplexity), true + case "Meta.is_sms_otp_mfa_enabled": + if e.complexity.Meta.IsSmsOtpMfaEnabled == nil { + break + } + + return e.complexity.Meta.IsSmsOtpMfaEnabled(childComplexity), true + case "Meta.is_strong_password_enabled": if e.complexity.Meta.IsStrongPasswordEnabled == nil { break @@ -1895,6 +1913,13 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return e.complexity.Meta.IsStrongPasswordEnabled(childComplexity), true + case "Meta.is_totp_mfa_enabled": + if e.complexity.Meta.IsTotpMfaEnabled == nil { + break + } + + return e.complexity.Meta.IsTotpMfaEnabled(childComplexity), true + case "Meta.is_twitch_login_enabled": if e.complexity.Meta.IsTwitchLoginEnabled == nil { break @@ -1909,6 +1934,13 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return e.complexity.Meta.IsTwitterLoginEnabled(childComplexity), true + case "Meta.is_webauthn_enabled": + if e.complexity.Meta.IsWebauthnEnabled == nil { + break + } + + return e.complexity.Meta.IsWebauthnEnabled(childComplexity), true + case "Meta.version": if e.complexity.Meta.Version == nil { break @@ -4356,6 +4388,14 @@ type Meta { is_mobile_basic_authentication_enabled: Boolean! is_phone_verification_enabled: Boolean! is_org_discovery_enabled: Boolean! + # is_totp_mfa_enabled indicates TOTP authenticator MFA is available (EnableMFA && EnableTOTPLogin). + is_totp_mfa_enabled: Boolean! + # is_email_otp_mfa_enabled indicates email OTP MFA is available (EnableMFA && EnableEmailOTP && email service configured). + is_email_otp_mfa_enabled: Boolean! + # is_sms_otp_mfa_enabled indicates SMS OTP MFA is available (EnableMFA && EnableSMSOTP && SMS service configured). + is_sms_otp_mfa_enabled: Boolean! + # is_webauthn_enabled indicates WebAuthn/passkey enrollment is available (always on; no operator flag). + is_webauthn_enabled: Boolean! } # AdminMeta is admin-only configuration metadata exposed via the _admin_meta @@ -15650,6 +15690,182 @@ func (ec *executionContext) fieldContext_Meta_is_org_discovery_enabled(_ context return fc, nil } +func (ec *executionContext) _Meta_is_totp_mfa_enabled(ctx context.Context, field graphql.CollectedField, obj *model.Meta) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_Meta_is_totp_mfa_enabled(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.IsTotpMfaEnabled, 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_totp_mfa_enabled(_ 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) _Meta_is_email_otp_mfa_enabled(ctx context.Context, field graphql.CollectedField, obj *model.Meta) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_Meta_is_email_otp_mfa_enabled(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.IsEmailOtpMfaEnabled, 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_email_otp_mfa_enabled(_ 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) _Meta_is_sms_otp_mfa_enabled(ctx context.Context, field graphql.CollectedField, obj *model.Meta) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_Meta_is_sms_otp_mfa_enabled(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.IsSmsOtpMfaEnabled, 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_sms_otp_mfa_enabled(_ 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) _Meta_is_webauthn_enabled(ctx context.Context, field graphql.CollectedField, obj *model.Meta) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_Meta_is_webauthn_enabled(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.IsWebauthnEnabled, 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_webauthn_enabled(_ 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 { @@ -22567,6 +22783,14 @@ func (ec *executionContext) fieldContext_Query_meta(_ context.Context, field gra return ec.fieldContext_Meta_is_phone_verification_enabled(ctx, field) case "is_org_discovery_enabled": return ec.fieldContext_Meta_is_org_discovery_enabled(ctx, field) + case "is_totp_mfa_enabled": + return ec.fieldContext_Meta_is_totp_mfa_enabled(ctx, field) + case "is_email_otp_mfa_enabled": + return ec.fieldContext_Meta_is_email_otp_mfa_enabled(ctx, field) + case "is_sms_otp_mfa_enabled": + return ec.fieldContext_Meta_is_sms_otp_mfa_enabled(ctx, field) + case "is_webauthn_enabled": + return ec.fieldContext_Meta_is_webauthn_enabled(ctx, field) } return nil, fmt.Errorf("no field named %q was found under type Meta", field.Name) }, @@ -36603,6 +36827,26 @@ func (ec *executionContext) _Meta(ctx context.Context, sel ast.SelectionSet, obj if out.Values[i] == graphql.Null { out.Invalids++ } + case "is_totp_mfa_enabled": + out.Values[i] = ec._Meta_is_totp_mfa_enabled(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "is_email_otp_mfa_enabled": + out.Values[i] = ec._Meta_is_email_otp_mfa_enabled(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "is_sms_otp_mfa_enabled": + out.Values[i] = ec._Meta_is_sms_otp_mfa_enabled(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "is_webauthn_enabled": + out.Values[i] = ec._Meta_is_webauthn_enabled(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } default: panic("unknown field " + strconv.Quote(field.Name)) } diff --git a/internal/graph/model/models_gen.go b/internal/graph/model/models_gen.go index 12862a3cd..a45cec141 100644 --- a/internal/graph/model/models_gen.go +++ b/internal/graph/model/models_gen.go @@ -466,6 +466,10 @@ type Meta struct { IsMobileBasicAuthenticationEnabled bool `json:"is_mobile_basic_authentication_enabled"` IsPhoneVerificationEnabled bool `json:"is_phone_verification_enabled"` IsOrgDiscoveryEnabled bool `json:"is_org_discovery_enabled"` + IsTotpMfaEnabled bool `json:"is_totp_mfa_enabled"` + IsEmailOtpMfaEnabled bool `json:"is_email_otp_mfa_enabled"` + IsSmsOtpMfaEnabled bool `json:"is_sms_otp_mfa_enabled"` + IsWebauthnEnabled bool `json:"is_webauthn_enabled"` } type MobileLoginRequest struct { diff --git a/internal/graph/schema.graphqls b/internal/graph/schema.graphqls index 7b488d44c..64f27524e 100644 --- a/internal/graph/schema.graphqls +++ b/internal/graph/schema.graphqls @@ -34,6 +34,14 @@ type Meta { is_mobile_basic_authentication_enabled: Boolean! is_phone_verification_enabled: Boolean! is_org_discovery_enabled: Boolean! + # is_totp_mfa_enabled indicates TOTP authenticator MFA is available (EnableMFA && EnableTOTPLogin). + is_totp_mfa_enabled: Boolean! + # is_email_otp_mfa_enabled indicates email OTP MFA is available (EnableMFA && EnableEmailOTP && email service configured). + is_email_otp_mfa_enabled: Boolean! + # is_sms_otp_mfa_enabled indicates SMS OTP MFA is available (EnableMFA && EnableSMSOTP && SMS service configured). + is_sms_otp_mfa_enabled: Boolean! + # is_webauthn_enabled indicates WebAuthn/passkey enrollment is available (always on; no operator flag). + is_webauthn_enabled: Boolean! } # AdminMeta is admin-only configuration metadata exposed via the _admin_meta diff --git a/internal/grpcsrv/handlers/authorizer.go b/internal/grpcsrv/handlers/authorizer.go index c9994d860..3f9977bd9 100644 --- a/internal/grpcsrv/handlers/authorizer.go +++ b/internal/grpcsrv/handlers/authorizer.go @@ -401,5 +401,9 @@ func (h *AuthorizerHandler) Meta(ctx context.Context, _ *authorizerv1.MetaReques IsMobileBasicAuthenticationEnabled: m.IsMobileBasicAuthenticationEnabled, IsPhoneVerificationEnabled: m.IsPhoneVerificationEnabled, IsOrgDiscoveryEnabled: m.IsOrgDiscoveryEnabled, + IsTotpMfaEnabled: m.IsTotpMfaEnabled, + IsEmailOtpMfaEnabled: m.IsEmailOtpMfaEnabled, + IsSmsOtpMfaEnabled: m.IsSmsOtpMfaEnabled, + IsWebauthnEnabled: m.IsWebauthnEnabled, }, nil } diff --git a/internal/integration_tests/meta_test.go b/internal/integration_tests/meta_test.go index 4ac02415f..fa0e6396d 100644 --- a/internal/integration_tests/meta_test.go +++ b/internal/integration_tests/meta_test.go @@ -45,4 +45,67 @@ func TestMeta(t *testing.T) { assert.NotNil(t, meta) assert.False(t, meta.IsBasicAuthenticationEnabled) }) + + 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) + require.NotNil(t, meta) + // Default config has MFA disabled, so all OTP/TOTP methods are unavailable. + assert.False(t, meta.IsTotpMfaEnabled) + assert.False(t, meta.IsEmailOtpMfaEnabled) + assert.False(t, meta.IsSmsOtpMfaEnabled) + // WebAuthn/passkey ships always-on. + assert.True(t, meta.IsWebauthnEnabled) + }) + + t.Run("should enable TOTP MFA when MFA and TOTP login are on", func(t *testing.T) { + cfg2 := getTestConfig() + cfg2.EnableMFA = true + cfg2.EnableTOTPLogin = true + ts2 := initTestSetup(t, cfg2) + _, ctx2 := createContext(ts2) + + meta, err := ts2.GraphQLProvider.Meta(ctx2) + require.NoError(t, err) + require.NotNil(t, meta) + assert.True(t, meta.IsTotpMfaEnabled) + // Email/SMS OTP still require their own service + flag. + assert.False(t, meta.IsEmailOtpMfaEnabled) + assert.False(t, meta.IsSmsOtpMfaEnabled) + assert.True(t, meta.IsWebauthnEnabled) + }) + + t.Run("should gate email/SMS OTP MFA on service availability", func(t *testing.T) { + cfg2 := getTestConfig() + cfg2.EnableMFA = true + cfg2.EnableEmailOTP = true + cfg2.EnableSMSOTP = true + cfg2.IsEmailServiceEnabled = true + cfg2.IsSMSServiceEnabled = true + ts2 := initTestSetup(t, cfg2) + _, ctx2 := createContext(ts2) + + meta, err := ts2.GraphQLProvider.Meta(ctx2) + require.NoError(t, err) + require.NotNil(t, meta) + assert.True(t, meta.IsEmailOtpMfaEnabled) + assert.True(t, meta.IsSmsOtpMfaEnabled) + assert.False(t, meta.IsTotpMfaEnabled) + + // OTP flags flip off when the underlying service is unavailable, even with MFA on. + cfg3 := getTestConfig() + cfg3.EnableMFA = true + cfg3.EnableEmailOTP = true + cfg3.EnableSMSOTP = true + cfg3.IsEmailServiceEnabled = false + cfg3.IsSMSServiceEnabled = false + ts3 := initTestSetup(t, cfg3) + _, ctx3 := createContext(ts3) + + meta2, err := ts3.GraphQLProvider.Meta(ctx3) + require.NoError(t, err) + require.NotNil(t, meta2) + assert.False(t, meta2.IsEmailOtpMfaEnabled) + assert.False(t, meta2.IsSmsOtpMfaEnabled) + }) } diff --git a/internal/service/meta.go b/internal/service/meta.go index 288fa3963..04f2f0d16 100644 --- a/internal/service/meta.go +++ b/internal/service/meta.go @@ -32,5 +32,10 @@ func (p *provider) Meta(ctx context.Context, meta RequestMetadata) (*model.Meta, IsMobileBasicAuthenticationEnabled: c.EnableMobileBasicAuthentication, IsPhoneVerificationEnabled: c.EnablePhoneVerification, IsOrgDiscoveryEnabled: c.EnableOrgDiscovery, + IsTotpMfaEnabled: c.EnableMFA && c.EnableTOTPLogin, + IsEmailOtpMfaEnabled: c.EnableMFA && c.EnableEmailOTP && c.IsEmailServiceEnabled, + IsSmsOtpMfaEnabled: c.EnableMFA && c.EnableSMSOTP && c.IsSMSServiceEnabled, + // WebAuthn/passkey ships always-on with no operator flag. + IsWebauthnEnabled: true, }, nil, nil } diff --git a/proto/authorizer/v1/types.proto b/proto/authorizer/v1/types.proto index b7380fcfc..2af9a995f 100644 --- a/proto/authorizer/v1/types.proto +++ b/proto/authorizer/v1/types.proto @@ -119,4 +119,8 @@ message Meta { bool is_mobile_basic_authentication_enabled = 19; bool is_phone_verification_enabled = 20; bool is_org_discovery_enabled = 21; + bool is_totp_mfa_enabled = 22; + bool is_email_otp_mfa_enabled = 23; + bool is_sms_otp_mfa_enabled = 24; + bool is_webauthn_enabled = 25; }