diff --git a/Dockerfile b/Dockerfile index 2e95ce44..1f1097db 100644 --- a/Dockerfile +++ b/Dockerfile @@ -58,7 +58,8 @@ RUN cd web/app && npm run build && cd ../dashboard && npm run build FROM alpine:3.23.3 ARG ALPINE_EDGE_MAIN=https://dl-cdn.alpinelinux.org/alpine/edge/main -RUN apk add --no-cache -X "${ALPINE_EDGE_MAIN}" "busybox>=1.37.0-r31" +RUN apk add --no-cache -X "${ALPINE_EDGE_MAIN}" "busybox>=1.37.0-r31" && \ + apk upgrade --no-cache ARG TARGETARCH=amd64 diff --git a/Makefile b/Makefile index 310798fb..928d9eae 100644 --- a/Makefile +++ b/Makefile @@ -88,7 +88,7 @@ dev: --admin-secret=admin \ --client-id=kbyuFDidLLm280LIwVFiazOqjO3ty8KH \ --client-secret=60Op4HFM0I8ajz0WdiStAbziZ-VFQttXuxixHHs2R7r7-CW8GR79l-mmLqMhc-Sa \ - --allowed-origins=localhost:8080,localhost:8090,localhost:9091,localhost:5173 + --allowed-origins=localhost:8080,localhost:8090,localhost:9091,localhost:5173,localhost:5174 test: go clean --testcache && TEST_DBS="sqlite" $(GO_TEST_ALL) diff --git a/cmd/mcp.go b/cmd/mcp.go index e10a2adf..1d6bb082 100644 --- a/cmd/mcp.go +++ b/cmd/mcp.go @@ -100,6 +100,7 @@ func runMCP(_ *cobra.Command, _ []string) { tokenProvider, err := token.New(&rootArgs.config, &token.Dependencies{ Log: &log, MemoryStoreProvider: memoryStoreProvider, + StorageProvider: storageProvider, }) if err != nil { log.Fatal().Err(err).Msg("failed to create token provider") @@ -136,8 +137,9 @@ func runMCP(_ *cobra.Command, _ []string) { // Authenticator provider — required by the service layer's TOTP/MFA // verification flows (verify_otp, login). authenticatorProvider, err := authenticators.New(&rootArgs.config, &authenticators.Dependencies{ - Log: &log, - StorageProvider: storageProvider, + Log: &log, + StorageProvider: storageProvider, + MemoryStoreProvider: memoryStoreProvider, }) if err != nil { log.Fatal().Err(err).Msg("failed to create authenticator provider") diff --git a/cmd/root.go b/cmd/root.go index 998df2d3..862b247b 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -188,9 +188,10 @@ func init() { // the effective Enable* / EnableMFA values from these flags. f.BoolVar(&rootArgs.config.EnforceMFA, "enforce-mfa", false, "Enforce MFA for all users") f.BoolVar(&rootArgs.config.DisableTOTPLogin, "disable-totp-login", false, "Disable TOTP-based MFA (enabled by default)") + f.BoolVar(&rootArgs.config.DisableWebauthnMFA, "disable-webauthn-mfa", false, "Disable WebAuthn/passkey as an MFA factor (enabled by default); does not affect WebAuthn/passkey as a primary login method") f.BoolVar(&rootArgs.config.DisableEmailOTP, "disable-email-otp", false, "Disable email OTP MFA (enabled by default when email service is configured)") f.BoolVar(&rootArgs.config.DisableSMSOTP, "disable-sms-otp", false, "Disable SMS OTP MFA (enabled by default when SMS service is configured)") - f.BoolVar(&rootArgs.config.DisableMFA, "disable-mfa", false, "Globally disable MFA (TOTP/email/SMS OTP), overriding the per-method flags; does not affect WebAuthn/passkey") + f.BoolVar(&rootArgs.config.DisableMFA, "disable-mfa", false, "Globally disable MFA (TOTP/email/SMS OTP), overriding the per-method flags; does not affect WebAuthn/passkey as a primary login method") f.BoolVar(&rootArgs.config.EnableSignup, "enable-signup", true, "Enable signup") // Cookies flags @@ -449,15 +450,6 @@ func runRoot(c *cobra.Command, args []string) { // auth from this row. seedReservedClient(context.Background(), storageProvider, &rootArgs.config, &log) - // Authenticator provider - authenticatorProvider, err := authenticators.New(&rootArgs.config, &authenticators.Dependencies{ - Log: &log, - StorageProvider: storageProvider, - }) - if err != nil { - log.Fatal().Err(err).Msg("failed to create authenticator provider") - } - // Email provider emailProvider, err := email.New(&rootArgs.config, &email.Dependencies{ Log: &log, @@ -485,6 +477,17 @@ func runRoot(c *cobra.Command, args []string) { log.Fatal().Err(err).Msg("failed to create memory store provider") } + // Authenticator provider — depends on the memory store for the transient + // pending-secret used by safe TOTP re-enrollment (see totp.Generate). + authenticatorProvider, err := authenticators.New(&rootArgs.config, &authenticators.Dependencies{ + Log: &log, + StorageProvider: storageProvider, + MemoryStoreProvider: memoryStoreProvider, + }) + if err != nil { + log.Fatal().Err(err).Msg("failed to create authenticator provider") + } + // WebAuthn/passkey provider webAuthnProvider, err := webauthn.NewProvider(&webauthn.Dependencies{ Log: &log, @@ -535,6 +538,7 @@ func runRoot(c *cobra.Command, args []string) { tokenProvider, err := token.New(&rootArgs.config, &token.Dependencies{ Log: &log, MemoryStoreProvider: memoryStoreProvider, + StorageProvider: storageProvider, }) if err != nil { log.Fatal().Err(err).Msg("failed to create token provider") diff --git a/gen/go/authorizer/v1/admin.pb.go b/gen/go/authorizer/v1/admin.pb.go index 52e44640..858888e6 100644 --- a/gen/go/authorizer/v1/admin.pb.go +++ b/gen/go/authorizer/v1/admin.pb.go @@ -664,6 +664,11 @@ type UpdateUserRequest struct { Roles []string `protobuf:"bytes,13,rep,name=roles,proto3" json:"roles,omitempty"` IsMultiFactorAuthEnabled *bool `protobuf:"varint,14,opt,name=is_multi_factor_auth_enabled,json=isMultiFactorAuthEnabled,proto3,oneof" json:"is_multi_factor_auth_enabled,omitempty"` AppData *AppData `protobuf:"bytes,15,opt,name=app_data,json=appData,proto3" json:"app_data,omitempty"` + // Mirrors UpdateUserRequest.reset_mfa in GraphQL — see schema.graphqls' + // doc comment on that field for exact semantics (clears mfa_locked_at, + // is_multi_factor_auth_enabled, has_skipped_mfa_setup_at, deletes all + // authenticators/passkeys). + ResetMfa *bool `protobuf:"varint,16,opt,name=reset_mfa,json=resetMfa,proto3,oneof" json:"reset_mfa,omitempty"` } func (x *UpdateUserRequest) Reset() { @@ -801,6 +806,13 @@ func (x *UpdateUserRequest) GetAppData() *AppData { return nil } +func (x *UpdateUserRequest) GetResetMfa() bool { + if x != nil && x.ResetMfa != nil { + return *x.ResetMfa + } + return false +} + type UpdateUserResponse struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache @@ -5671,7 +5683,7 @@ var file_authorizer_v1_admin_proto_rawDesc = []byte{ 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x22, 0x37, 0x0a, 0x0c, 0x55, 0x73, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x27, 0x0a, 0x04, 0x75, 0x73, 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x13, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, - 0x2e, 0x76, 0x31, 0x2e, 0x55, 0x73, 0x65, 0x72, 0x52, 0x04, 0x75, 0x73, 0x65, 0x72, 0x22, 0x9c, + 0x2e, 0x76, 0x31, 0x2e, 0x55, 0x73, 0x65, 0x72, 0x52, 0x04, 0x75, 0x73, 0x65, 0x72, 0x22, 0xcc, 0x06, 0x0a, 0x11, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x55, 0x73, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x17, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x42, 0x07, 0xba, 0x48, 0x04, 0x72, 0x02, 0x10, 0x01, 0x52, 0x02, 0x69, 0x64, 0x12, 0x19, 0x0a, @@ -5709,19 +5721,22 @@ var file_authorizer_v1_admin_proto_rawDesc = []byte{ 0x12, 0x31, 0x0a, 0x08, 0x61, 0x70, 0x70, 0x5f, 0x64, 0x61, 0x74, 0x61, 0x18, 0x0f, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x41, 0x70, 0x70, 0x44, 0x61, 0x74, 0x61, 0x52, 0x07, 0x61, 0x70, 0x70, 0x44, - 0x61, 0x74, 0x61, 0x42, 0x08, 0x0a, 0x06, 0x5f, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x42, 0x11, 0x0a, - 0x0f, 0x5f, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x5f, 0x76, 0x65, 0x72, 0x69, 0x66, 0x69, 0x65, 0x64, - 0x42, 0x0d, 0x0a, 0x0b, 0x5f, 0x67, 0x69, 0x76, 0x65, 0x6e, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x42, - 0x0e, 0x0a, 0x0c, 0x5f, 0x66, 0x61, 0x6d, 0x69, 0x6c, 0x79, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x42, - 0x0e, 0x0a, 0x0c, 0x5f, 0x6d, 0x69, 0x64, 0x64, 0x6c, 0x65, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x42, - 0x0b, 0x0a, 0x09, 0x5f, 0x6e, 0x69, 0x63, 0x6b, 0x6e, 0x61, 0x6d, 0x65, 0x42, 0x09, 0x0a, 0x07, - 0x5f, 0x67, 0x65, 0x6e, 0x64, 0x65, 0x72, 0x42, 0x0c, 0x0a, 0x0a, 0x5f, 0x62, 0x69, 0x72, 0x74, - 0x68, 0x64, 0x61, 0x74, 0x65, 0x42, 0x0f, 0x0a, 0x0d, 0x5f, 0x70, 0x68, 0x6f, 0x6e, 0x65, 0x5f, - 0x6e, 0x75, 0x6d, 0x62, 0x65, 0x72, 0x42, 0x18, 0x0a, 0x16, 0x5f, 0x70, 0x68, 0x6f, 0x6e, 0x65, - 0x5f, 0x6e, 0x75, 0x6d, 0x62, 0x65, 0x72, 0x5f, 0x76, 0x65, 0x72, 0x69, 0x66, 0x69, 0x65, 0x64, - 0x42, 0x0a, 0x0a, 0x08, 0x5f, 0x70, 0x69, 0x63, 0x74, 0x75, 0x72, 0x65, 0x42, 0x1f, 0x0a, 0x1d, - 0x5f, 0x69, 0x73, 0x5f, 0x6d, 0x75, 0x6c, 0x74, 0x69, 0x5f, 0x66, 0x61, 0x63, 0x74, 0x6f, 0x72, - 0x5f, 0x61, 0x75, 0x74, 0x68, 0x5f, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x22, 0x3d, 0x0a, + 0x61, 0x74, 0x61, 0x12, 0x20, 0x0a, 0x09, 0x72, 0x65, 0x73, 0x65, 0x74, 0x5f, 0x6d, 0x66, 0x61, + 0x18, 0x10, 0x20, 0x01, 0x28, 0x08, 0x48, 0x0c, 0x52, 0x08, 0x72, 0x65, 0x73, 0x65, 0x74, 0x4d, + 0x66, 0x61, 0x88, 0x01, 0x01, 0x42, 0x08, 0x0a, 0x06, 0x5f, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x42, + 0x11, 0x0a, 0x0f, 0x5f, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x5f, 0x76, 0x65, 0x72, 0x69, 0x66, 0x69, + 0x65, 0x64, 0x42, 0x0d, 0x0a, 0x0b, 0x5f, 0x67, 0x69, 0x76, 0x65, 0x6e, 0x5f, 0x6e, 0x61, 0x6d, + 0x65, 0x42, 0x0e, 0x0a, 0x0c, 0x5f, 0x66, 0x61, 0x6d, 0x69, 0x6c, 0x79, 0x5f, 0x6e, 0x61, 0x6d, + 0x65, 0x42, 0x0e, 0x0a, 0x0c, 0x5f, 0x6d, 0x69, 0x64, 0x64, 0x6c, 0x65, 0x5f, 0x6e, 0x61, 0x6d, + 0x65, 0x42, 0x0b, 0x0a, 0x09, 0x5f, 0x6e, 0x69, 0x63, 0x6b, 0x6e, 0x61, 0x6d, 0x65, 0x42, 0x09, + 0x0a, 0x07, 0x5f, 0x67, 0x65, 0x6e, 0x64, 0x65, 0x72, 0x42, 0x0c, 0x0a, 0x0a, 0x5f, 0x62, 0x69, + 0x72, 0x74, 0x68, 0x64, 0x61, 0x74, 0x65, 0x42, 0x0f, 0x0a, 0x0d, 0x5f, 0x70, 0x68, 0x6f, 0x6e, + 0x65, 0x5f, 0x6e, 0x75, 0x6d, 0x62, 0x65, 0x72, 0x42, 0x18, 0x0a, 0x16, 0x5f, 0x70, 0x68, 0x6f, + 0x6e, 0x65, 0x5f, 0x6e, 0x75, 0x6d, 0x62, 0x65, 0x72, 0x5f, 0x76, 0x65, 0x72, 0x69, 0x66, 0x69, + 0x65, 0x64, 0x42, 0x0a, 0x0a, 0x08, 0x5f, 0x70, 0x69, 0x63, 0x74, 0x75, 0x72, 0x65, 0x42, 0x1f, + 0x0a, 0x1d, 0x5f, 0x69, 0x73, 0x5f, 0x6d, 0x75, 0x6c, 0x74, 0x69, 0x5f, 0x66, 0x61, 0x63, 0x74, + 0x6f, 0x72, 0x5f, 0x61, 0x75, 0x74, 0x68, 0x5f, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x42, + 0x0c, 0x0a, 0x0a, 0x5f, 0x72, 0x65, 0x73, 0x65, 0x74, 0x5f, 0x6d, 0x66, 0x61, 0x22, 0x3d, 0x0a, 0x12, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x55, 0x73, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x27, 0x0a, 0x04, 0x75, 0x73, 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x13, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, diff --git a/gen/go/authorizer/v1/authorizer.pb.go b/gen/go/authorizer/v1/authorizer.pb.go index 1f7e5881..cb6d6d57 100644 --- a/gen/go/authorizer/v1/authorizer.pb.go +++ b/gen/go/authorizer/v1/authorizer.pb.go @@ -2,7 +2,8 @@ // public API. Method names match the GraphQL operation names 1:1 // (snake_case in GraphQL → PascalCase in proto): Signup, Login, // MagicLinkLogin, VerifyEmail, ResendVerifyEmail, ForgotPassword, -// ResetPassword, VerifyOtp, ResendOtp, UpdateProfile, DeactivateAccount, +// ResetPassword, VerifyOtp, ResendOtp, SkipMfaSetup, LockMfa, +// EmailOtpMfaSetup, SmsOtpMfaSetup, UpdateProfile, DeactivateAccount, // Revoke, Meta, Session, Profile, ValidateJwtToken, ValidateSession, // CheckPermissions, ListPermissions, Logout. // @@ -845,6 +846,371 @@ func (x *ResendOtpResponse) GetMessage() string { return "" } +type SkipMfaSetupRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // Either email or phone_number is required, to resolve which user's MFA + // session cookie this is. + Email string `protobuf:"bytes,1,opt,name=email,proto3" json:"email,omitempty"` + PhoneNumber string `protobuf:"bytes,2,opt,name=phone_number,json=phoneNumber,proto3" json:"phone_number,omitempty"` + State string `protobuf:"bytes,3,opt,name=state,proto3" json:"state,omitempty"` +} + +func (x *SkipMfaSetupRequest) Reset() { + *x = SkipMfaSetupRequest{} + mi := &file_authorizer_v1_authorizer_proto_msgTypes[12] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SkipMfaSetupRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SkipMfaSetupRequest) ProtoMessage() {} + +func (x *SkipMfaSetupRequest) ProtoReflect() protoreflect.Message { + mi := &file_authorizer_v1_authorizer_proto_msgTypes[12] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SkipMfaSetupRequest.ProtoReflect.Descriptor instead. +func (*SkipMfaSetupRequest) Descriptor() ([]byte, []int) { + return file_authorizer_v1_authorizer_proto_rawDescGZIP(), []int{12} +} + +func (x *SkipMfaSetupRequest) GetEmail() string { + if x != nil { + return x.Email + } + return "" +} + +func (x *SkipMfaSetupRequest) GetPhoneNumber() string { + if x != nil { + return x.PhoneNumber + } + return "" +} + +func (x *SkipMfaSetupRequest) GetState() string { + if x != nil { + return x.State + } + return "" +} + +type LockMfaRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // Either email or phone_number is required, to resolve which user's MFA + // session cookie this is — same pattern as SkipMfaSetupRequest. + Email string `protobuf:"bytes,1,opt,name=email,proto3" json:"email,omitempty"` + PhoneNumber string `protobuf:"bytes,2,opt,name=phone_number,json=phoneNumber,proto3" json:"phone_number,omitempty"` +} + +func (x *LockMfaRequest) Reset() { + *x = LockMfaRequest{} + mi := &file_authorizer_v1_authorizer_proto_msgTypes[13] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *LockMfaRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*LockMfaRequest) ProtoMessage() {} + +func (x *LockMfaRequest) ProtoReflect() protoreflect.Message { + mi := &file_authorizer_v1_authorizer_proto_msgTypes[13] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use LockMfaRequest.ProtoReflect.Descriptor instead. +func (*LockMfaRequest) Descriptor() ([]byte, []int) { + return file_authorizer_v1_authorizer_proto_rawDescGZIP(), []int{13} +} + +func (x *LockMfaRequest) GetEmail() string { + if x != nil { + return x.Email + } + return "" +} + +func (x *LockMfaRequest) GetPhoneNumber() string { + if x != nil { + return x.PhoneNumber + } + return "" +} + +type LockMfaResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Message string `protobuf:"bytes,1,opt,name=message,proto3" json:"message,omitempty"` +} + +func (x *LockMfaResponse) Reset() { + *x = LockMfaResponse{} + mi := &file_authorizer_v1_authorizer_proto_msgTypes[14] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *LockMfaResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*LockMfaResponse) ProtoMessage() {} + +func (x *LockMfaResponse) ProtoReflect() protoreflect.Message { + mi := &file_authorizer_v1_authorizer_proto_msgTypes[14] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use LockMfaResponse.ProtoReflect.Descriptor instead. +func (*LockMfaResponse) Descriptor() ([]byte, []int) { + return file_authorizer_v1_authorizer_proto_rawDescGZIP(), []int{14} +} + +func (x *LockMfaResponse) GetMessage() string { + if x != nil { + return x.Message + } + return "" +} + +type EmailOtpMfaSetupRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // Only used in the MFA-session-cookie mode (a caller in the withheld + // first-time-offer state, with no bearer token yet) to resolve which + // user's MFA session cookie this is — same pattern as SkipMfaSetupRequest + // / LockMfaRequest. Ignored when the caller has a valid bearer + // token/session, which already identifies the user. + Email string `protobuf:"bytes,1,opt,name=email,proto3" json:"email,omitempty"` + PhoneNumber string `protobuf:"bytes,2,opt,name=phone_number,json=phoneNumber,proto3" json:"phone_number,omitempty"` +} + +func (x *EmailOtpMfaSetupRequest) Reset() { + *x = EmailOtpMfaSetupRequest{} + mi := &file_authorizer_v1_authorizer_proto_msgTypes[15] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *EmailOtpMfaSetupRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*EmailOtpMfaSetupRequest) ProtoMessage() {} + +func (x *EmailOtpMfaSetupRequest) ProtoReflect() protoreflect.Message { + mi := &file_authorizer_v1_authorizer_proto_msgTypes[15] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use EmailOtpMfaSetupRequest.ProtoReflect.Descriptor instead. +func (*EmailOtpMfaSetupRequest) Descriptor() ([]byte, []int) { + return file_authorizer_v1_authorizer_proto_rawDescGZIP(), []int{15} +} + +func (x *EmailOtpMfaSetupRequest) GetEmail() string { + if x != nil { + return x.Email + } + return "" +} + +func (x *EmailOtpMfaSetupRequest) GetPhoneNumber() string { + if x != nil { + return x.PhoneNumber + } + return "" +} + +type EmailOtpMfaSetupResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Message string `protobuf:"bytes,1,opt,name=message,proto3" json:"message,omitempty"` +} + +func (x *EmailOtpMfaSetupResponse) Reset() { + *x = EmailOtpMfaSetupResponse{} + mi := &file_authorizer_v1_authorizer_proto_msgTypes[16] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *EmailOtpMfaSetupResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*EmailOtpMfaSetupResponse) ProtoMessage() {} + +func (x *EmailOtpMfaSetupResponse) ProtoReflect() protoreflect.Message { + mi := &file_authorizer_v1_authorizer_proto_msgTypes[16] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use EmailOtpMfaSetupResponse.ProtoReflect.Descriptor instead. +func (*EmailOtpMfaSetupResponse) Descriptor() ([]byte, []int) { + return file_authorizer_v1_authorizer_proto_rawDescGZIP(), []int{16} +} + +func (x *EmailOtpMfaSetupResponse) GetMessage() string { + if x != nil { + return x.Message + } + return "" +} + +type SmsOtpMfaSetupRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // Same dual-mode identification semantics as EmailOtpMfaSetupRequest. + Email string `protobuf:"bytes,1,opt,name=email,proto3" json:"email,omitempty"` + PhoneNumber string `protobuf:"bytes,2,opt,name=phone_number,json=phoneNumber,proto3" json:"phone_number,omitempty"` +} + +func (x *SmsOtpMfaSetupRequest) Reset() { + *x = SmsOtpMfaSetupRequest{} + mi := &file_authorizer_v1_authorizer_proto_msgTypes[17] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SmsOtpMfaSetupRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SmsOtpMfaSetupRequest) ProtoMessage() {} + +func (x *SmsOtpMfaSetupRequest) ProtoReflect() protoreflect.Message { + mi := &file_authorizer_v1_authorizer_proto_msgTypes[17] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SmsOtpMfaSetupRequest.ProtoReflect.Descriptor instead. +func (*SmsOtpMfaSetupRequest) Descriptor() ([]byte, []int) { + return file_authorizer_v1_authorizer_proto_rawDescGZIP(), []int{17} +} + +func (x *SmsOtpMfaSetupRequest) GetEmail() string { + if x != nil { + return x.Email + } + return "" +} + +func (x *SmsOtpMfaSetupRequest) GetPhoneNumber() string { + if x != nil { + return x.PhoneNumber + } + return "" +} + +type SmsOtpMfaSetupResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Message string `protobuf:"bytes,1,opt,name=message,proto3" json:"message,omitempty"` +} + +func (x *SmsOtpMfaSetupResponse) Reset() { + *x = SmsOtpMfaSetupResponse{} + mi := &file_authorizer_v1_authorizer_proto_msgTypes[18] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SmsOtpMfaSetupResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SmsOtpMfaSetupResponse) ProtoMessage() {} + +func (x *SmsOtpMfaSetupResponse) ProtoReflect() protoreflect.Message { + mi := &file_authorizer_v1_authorizer_proto_msgTypes[18] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SmsOtpMfaSetupResponse.ProtoReflect.Descriptor instead. +func (*SmsOtpMfaSetupResponse) Descriptor() ([]byte, []int) { + return file_authorizer_v1_authorizer_proto_rawDescGZIP(), []int{18} +} + +func (x *SmsOtpMfaSetupResponse) GetMessage() string { + if x != nil { + return x.Message + } + return "" +} + type ForgotPasswordRequest struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache @@ -858,7 +1224,7 @@ type ForgotPasswordRequest struct { func (x *ForgotPasswordRequest) Reset() { *x = ForgotPasswordRequest{} - mi := &file_authorizer_v1_authorizer_proto_msgTypes[12] + mi := &file_authorizer_v1_authorizer_proto_msgTypes[19] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -870,7 +1236,7 @@ func (x *ForgotPasswordRequest) String() string { func (*ForgotPasswordRequest) ProtoMessage() {} func (x *ForgotPasswordRequest) ProtoReflect() protoreflect.Message { - mi := &file_authorizer_v1_authorizer_proto_msgTypes[12] + mi := &file_authorizer_v1_authorizer_proto_msgTypes[19] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -883,7 +1249,7 @@ func (x *ForgotPasswordRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ForgotPasswordRequest.ProtoReflect.Descriptor instead. func (*ForgotPasswordRequest) Descriptor() ([]byte, []int) { - return file_authorizer_v1_authorizer_proto_rawDescGZIP(), []int{12} + return file_authorizer_v1_authorizer_proto_rawDescGZIP(), []int{19} } func (x *ForgotPasswordRequest) GetEmail() string { @@ -926,7 +1292,7 @@ type ForgotPasswordResponse struct { func (x *ForgotPasswordResponse) Reset() { *x = ForgotPasswordResponse{} - mi := &file_authorizer_v1_authorizer_proto_msgTypes[13] + mi := &file_authorizer_v1_authorizer_proto_msgTypes[20] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -938,7 +1304,7 @@ func (x *ForgotPasswordResponse) String() string { func (*ForgotPasswordResponse) ProtoMessage() {} func (x *ForgotPasswordResponse) ProtoReflect() protoreflect.Message { - mi := &file_authorizer_v1_authorizer_proto_msgTypes[13] + mi := &file_authorizer_v1_authorizer_proto_msgTypes[20] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -951,7 +1317,7 @@ func (x *ForgotPasswordResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ForgotPasswordResponse.ProtoReflect.Descriptor instead. func (*ForgotPasswordResponse) Descriptor() ([]byte, []int) { - return file_authorizer_v1_authorizer_proto_rawDescGZIP(), []int{13} + return file_authorizer_v1_authorizer_proto_rawDescGZIP(), []int{20} } func (x *ForgotPasswordResponse) GetMessage() string { @@ -983,7 +1349,7 @@ type ResetPasswordRequest struct { func (x *ResetPasswordRequest) Reset() { *x = ResetPasswordRequest{} - mi := &file_authorizer_v1_authorizer_proto_msgTypes[14] + mi := &file_authorizer_v1_authorizer_proto_msgTypes[21] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -995,7 +1361,7 @@ func (x *ResetPasswordRequest) String() string { func (*ResetPasswordRequest) ProtoMessage() {} func (x *ResetPasswordRequest) ProtoReflect() protoreflect.Message { - mi := &file_authorizer_v1_authorizer_proto_msgTypes[14] + mi := &file_authorizer_v1_authorizer_proto_msgTypes[21] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1008,7 +1374,7 @@ func (x *ResetPasswordRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ResetPasswordRequest.ProtoReflect.Descriptor instead. func (*ResetPasswordRequest) Descriptor() ([]byte, []int) { - return file_authorizer_v1_authorizer_proto_rawDescGZIP(), []int{14} + return file_authorizer_v1_authorizer_proto_rawDescGZIP(), []int{21} } func (x *ResetPasswordRequest) GetToken() string { @@ -1056,7 +1422,7 @@ type ResetPasswordResponse struct { func (x *ResetPasswordResponse) Reset() { *x = ResetPasswordResponse{} - mi := &file_authorizer_v1_authorizer_proto_msgTypes[15] + mi := &file_authorizer_v1_authorizer_proto_msgTypes[22] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1068,7 +1434,7 @@ func (x *ResetPasswordResponse) String() string { func (*ResetPasswordResponse) ProtoMessage() {} func (x *ResetPasswordResponse) ProtoReflect() protoreflect.Message { - mi := &file_authorizer_v1_authorizer_proto_msgTypes[15] + mi := &file_authorizer_v1_authorizer_proto_msgTypes[22] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1081,7 +1447,7 @@ func (x *ResetPasswordResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ResetPasswordResponse.ProtoReflect.Descriptor instead. func (*ResetPasswordResponse) Descriptor() ([]byte, []int) { - return file_authorizer_v1_authorizer_proto_rawDescGZIP(), []int{15} + return file_authorizer_v1_authorizer_proto_rawDescGZIP(), []int{22} } func (x *ResetPasswordResponse) GetMessage() string { @@ -1099,7 +1465,7 @@ type ProfileRequest struct { func (x *ProfileRequest) Reset() { *x = ProfileRequest{} - mi := &file_authorizer_v1_authorizer_proto_msgTypes[16] + mi := &file_authorizer_v1_authorizer_proto_msgTypes[23] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1111,7 +1477,7 @@ func (x *ProfileRequest) String() string { func (*ProfileRequest) ProtoMessage() {} func (x *ProfileRequest) ProtoReflect() protoreflect.Message { - mi := &file_authorizer_v1_authorizer_proto_msgTypes[16] + mi := &file_authorizer_v1_authorizer_proto_msgTypes[23] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1124,7 +1490,7 @@ func (x *ProfileRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ProfileRequest.ProtoReflect.Descriptor instead. func (*ProfileRequest) Descriptor() ([]byte, []int) { - return file_authorizer_v1_authorizer_proto_rawDescGZIP(), []int{16} + return file_authorizer_v1_authorizer_proto_rawDescGZIP(), []int{23} } type UpdateProfileRequest struct { @@ -1153,7 +1519,7 @@ type UpdateProfileRequest struct { func (x *UpdateProfileRequest) Reset() { *x = UpdateProfileRequest{} - mi := &file_authorizer_v1_authorizer_proto_msgTypes[17] + mi := &file_authorizer_v1_authorizer_proto_msgTypes[24] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1165,7 +1531,7 @@ func (x *UpdateProfileRequest) String() string { func (*UpdateProfileRequest) ProtoMessage() {} func (x *UpdateProfileRequest) ProtoReflect() protoreflect.Message { - mi := &file_authorizer_v1_authorizer_proto_msgTypes[17] + mi := &file_authorizer_v1_authorizer_proto_msgTypes[24] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1178,7 +1544,7 @@ func (x *UpdateProfileRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use UpdateProfileRequest.ProtoReflect.Descriptor instead. func (*UpdateProfileRequest) Descriptor() ([]byte, []int) { - return file_authorizer_v1_authorizer_proto_rawDescGZIP(), []int{17} + return file_authorizer_v1_authorizer_proto_rawDescGZIP(), []int{24} } func (x *UpdateProfileRequest) GetOldPassword() string { @@ -1289,7 +1655,7 @@ type UpdateProfileResponse struct { func (x *UpdateProfileResponse) Reset() { *x = UpdateProfileResponse{} - mi := &file_authorizer_v1_authorizer_proto_msgTypes[18] + mi := &file_authorizer_v1_authorizer_proto_msgTypes[25] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1301,7 +1667,7 @@ func (x *UpdateProfileResponse) String() string { func (*UpdateProfileResponse) ProtoMessage() {} func (x *UpdateProfileResponse) ProtoReflect() protoreflect.Message { - mi := &file_authorizer_v1_authorizer_proto_msgTypes[18] + mi := &file_authorizer_v1_authorizer_proto_msgTypes[25] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1314,7 +1680,7 @@ func (x *UpdateProfileResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use UpdateProfileResponse.ProtoReflect.Descriptor instead. func (*UpdateProfileResponse) Descriptor() ([]byte, []int) { - return file_authorizer_v1_authorizer_proto_rawDescGZIP(), []int{18} + return file_authorizer_v1_authorizer_proto_rawDescGZIP(), []int{25} } func (x *UpdateProfileResponse) GetMessage() string { @@ -1332,7 +1698,7 @@ type DeactivateAccountRequest struct { func (x *DeactivateAccountRequest) Reset() { *x = DeactivateAccountRequest{} - mi := &file_authorizer_v1_authorizer_proto_msgTypes[19] + mi := &file_authorizer_v1_authorizer_proto_msgTypes[26] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1344,7 +1710,7 @@ func (x *DeactivateAccountRequest) String() string { func (*DeactivateAccountRequest) ProtoMessage() {} func (x *DeactivateAccountRequest) ProtoReflect() protoreflect.Message { - mi := &file_authorizer_v1_authorizer_proto_msgTypes[19] + mi := &file_authorizer_v1_authorizer_proto_msgTypes[26] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1357,7 +1723,7 @@ func (x *DeactivateAccountRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use DeactivateAccountRequest.ProtoReflect.Descriptor instead. func (*DeactivateAccountRequest) Descriptor() ([]byte, []int) { - return file_authorizer_v1_authorizer_proto_rawDescGZIP(), []int{19} + return file_authorizer_v1_authorizer_proto_rawDescGZIP(), []int{26} } type DeactivateAccountResponse struct { @@ -1370,7 +1736,7 @@ type DeactivateAccountResponse struct { func (x *DeactivateAccountResponse) Reset() { *x = DeactivateAccountResponse{} - mi := &file_authorizer_v1_authorizer_proto_msgTypes[20] + mi := &file_authorizer_v1_authorizer_proto_msgTypes[27] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1382,7 +1748,7 @@ func (x *DeactivateAccountResponse) String() string { func (*DeactivateAccountResponse) ProtoMessage() {} func (x *DeactivateAccountResponse) ProtoReflect() protoreflect.Message { - mi := &file_authorizer_v1_authorizer_proto_msgTypes[20] + mi := &file_authorizer_v1_authorizer_proto_msgTypes[27] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1395,7 +1761,7 @@ func (x *DeactivateAccountResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use DeactivateAccountResponse.ProtoReflect.Descriptor instead. func (*DeactivateAccountResponse) Descriptor() ([]byte, []int) { - return file_authorizer_v1_authorizer_proto_rawDescGZIP(), []int{20} + return file_authorizer_v1_authorizer_proto_rawDescGZIP(), []int{27} } func (x *DeactivateAccountResponse) GetMessage() string { @@ -1415,7 +1781,7 @@ type RevokeRequest struct { func (x *RevokeRequest) Reset() { *x = RevokeRequest{} - mi := &file_authorizer_v1_authorizer_proto_msgTypes[21] + mi := &file_authorizer_v1_authorizer_proto_msgTypes[28] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1427,7 +1793,7 @@ func (x *RevokeRequest) String() string { func (*RevokeRequest) ProtoMessage() {} func (x *RevokeRequest) ProtoReflect() protoreflect.Message { - mi := &file_authorizer_v1_authorizer_proto_msgTypes[21] + mi := &file_authorizer_v1_authorizer_proto_msgTypes[28] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1440,7 +1806,7 @@ func (x *RevokeRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use RevokeRequest.ProtoReflect.Descriptor instead. func (*RevokeRequest) Descriptor() ([]byte, []int) { - return file_authorizer_v1_authorizer_proto_rawDescGZIP(), []int{21} + return file_authorizer_v1_authorizer_proto_rawDescGZIP(), []int{28} } func (x *RevokeRequest) GetRefreshToken() string { @@ -1460,7 +1826,7 @@ type RevokeResponse struct { func (x *RevokeResponse) Reset() { *x = RevokeResponse{} - mi := &file_authorizer_v1_authorizer_proto_msgTypes[22] + mi := &file_authorizer_v1_authorizer_proto_msgTypes[29] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1472,7 +1838,7 @@ func (x *RevokeResponse) String() string { func (*RevokeResponse) ProtoMessage() {} func (x *RevokeResponse) ProtoReflect() protoreflect.Message { - mi := &file_authorizer_v1_authorizer_proto_msgTypes[22] + mi := &file_authorizer_v1_authorizer_proto_msgTypes[29] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1485,7 +1851,7 @@ func (x *RevokeResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use RevokeResponse.ProtoReflect.Descriptor instead. func (*RevokeResponse) Descriptor() ([]byte, []int) { - return file_authorizer_v1_authorizer_proto_rawDescGZIP(), []int{22} + return file_authorizer_v1_authorizer_proto_rawDescGZIP(), []int{29} } func (x *RevokeResponse) GetMessage() string { @@ -1511,7 +1877,7 @@ type SessionRequest struct { func (x *SessionRequest) Reset() { *x = SessionRequest{} - mi := &file_authorizer_v1_authorizer_proto_msgTypes[23] + mi := &file_authorizer_v1_authorizer_proto_msgTypes[30] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1523,7 +1889,7 @@ func (x *SessionRequest) String() string { func (*SessionRequest) ProtoMessage() {} func (x *SessionRequest) ProtoReflect() protoreflect.Message { - mi := &file_authorizer_v1_authorizer_proto_msgTypes[23] + mi := &file_authorizer_v1_authorizer_proto_msgTypes[30] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1536,7 +1902,7 @@ func (x *SessionRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SessionRequest.ProtoReflect.Descriptor instead. func (*SessionRequest) Descriptor() ([]byte, []int) { - return file_authorizer_v1_authorizer_proto_rawDescGZIP(), []int{23} + return file_authorizer_v1_authorizer_proto_rawDescGZIP(), []int{30} } func (x *SessionRequest) GetRoles() []string { @@ -1581,7 +1947,7 @@ type ValidateJwtTokenRequest struct { func (x *ValidateJwtTokenRequest) Reset() { *x = ValidateJwtTokenRequest{} - mi := &file_authorizer_v1_authorizer_proto_msgTypes[24] + mi := &file_authorizer_v1_authorizer_proto_msgTypes[31] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1593,7 +1959,7 @@ func (x *ValidateJwtTokenRequest) String() string { func (*ValidateJwtTokenRequest) ProtoMessage() {} func (x *ValidateJwtTokenRequest) ProtoReflect() protoreflect.Message { - mi := &file_authorizer_v1_authorizer_proto_msgTypes[24] + mi := &file_authorizer_v1_authorizer_proto_msgTypes[31] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1606,7 +1972,7 @@ func (x *ValidateJwtTokenRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ValidateJwtTokenRequest.ProtoReflect.Descriptor instead. func (*ValidateJwtTokenRequest) Descriptor() ([]byte, []int) { - return file_authorizer_v1_authorizer_proto_rawDescGZIP(), []int{24} + return file_authorizer_v1_authorizer_proto_rawDescGZIP(), []int{31} } func (x *ValidateJwtTokenRequest) GetTokenType() string { @@ -1649,7 +2015,7 @@ type ValidateJwtTokenResponse struct { func (x *ValidateJwtTokenResponse) Reset() { *x = ValidateJwtTokenResponse{} - mi := &file_authorizer_v1_authorizer_proto_msgTypes[25] + mi := &file_authorizer_v1_authorizer_proto_msgTypes[32] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1661,7 +2027,7 @@ func (x *ValidateJwtTokenResponse) String() string { func (*ValidateJwtTokenResponse) ProtoMessage() {} func (x *ValidateJwtTokenResponse) ProtoReflect() protoreflect.Message { - mi := &file_authorizer_v1_authorizer_proto_msgTypes[25] + mi := &file_authorizer_v1_authorizer_proto_msgTypes[32] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1674,7 +2040,7 @@ func (x *ValidateJwtTokenResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ValidateJwtTokenResponse.ProtoReflect.Descriptor instead. func (*ValidateJwtTokenResponse) Descriptor() ([]byte, []int) { - return file_authorizer_v1_authorizer_proto_rawDescGZIP(), []int{25} + return file_authorizer_v1_authorizer_proto_rawDescGZIP(), []int{32} } func (x *ValidateJwtTokenResponse) GetIsValid() bool { @@ -1704,7 +2070,7 @@ type ValidateSessionRequest struct { func (x *ValidateSessionRequest) Reset() { *x = ValidateSessionRequest{} - mi := &file_authorizer_v1_authorizer_proto_msgTypes[26] + mi := &file_authorizer_v1_authorizer_proto_msgTypes[33] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1716,7 +2082,7 @@ func (x *ValidateSessionRequest) String() string { func (*ValidateSessionRequest) ProtoMessage() {} func (x *ValidateSessionRequest) ProtoReflect() protoreflect.Message { - mi := &file_authorizer_v1_authorizer_proto_msgTypes[26] + mi := &file_authorizer_v1_authorizer_proto_msgTypes[33] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1729,7 +2095,7 @@ func (x *ValidateSessionRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ValidateSessionRequest.ProtoReflect.Descriptor instead. func (*ValidateSessionRequest) Descriptor() ([]byte, []int) { - return file_authorizer_v1_authorizer_proto_rawDescGZIP(), []int{26} + return file_authorizer_v1_authorizer_proto_rawDescGZIP(), []int{33} } func (x *ValidateSessionRequest) GetCookie() string { @@ -1764,7 +2130,7 @@ type ValidateSessionResponse struct { func (x *ValidateSessionResponse) Reset() { *x = ValidateSessionResponse{} - mi := &file_authorizer_v1_authorizer_proto_msgTypes[27] + mi := &file_authorizer_v1_authorizer_proto_msgTypes[34] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1776,7 +2142,7 @@ func (x *ValidateSessionResponse) String() string { func (*ValidateSessionResponse) ProtoMessage() {} func (x *ValidateSessionResponse) ProtoReflect() protoreflect.Message { - mi := &file_authorizer_v1_authorizer_proto_msgTypes[27] + mi := &file_authorizer_v1_authorizer_proto_msgTypes[34] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1789,7 +2155,7 @@ func (x *ValidateSessionResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ValidateSessionResponse.ProtoReflect.Descriptor instead. func (*ValidateSessionResponse) Descriptor() ([]byte, []int) { - return file_authorizer_v1_authorizer_proto_rawDescGZIP(), []int{27} + return file_authorizer_v1_authorizer_proto_rawDescGZIP(), []int{34} } func (x *ValidateSessionResponse) GetIsValid() bool { @@ -1814,7 +2180,7 @@ type MetaRequest struct { func (x *MetaRequest) Reset() { *x = MetaRequest{} - mi := &file_authorizer_v1_authorizer_proto_msgTypes[28] + mi := &file_authorizer_v1_authorizer_proto_msgTypes[35] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1826,7 +2192,7 @@ func (x *MetaRequest) String() string { func (*MetaRequest) ProtoMessage() {} func (x *MetaRequest) ProtoReflect() protoreflect.Message { - mi := &file_authorizer_v1_authorizer_proto_msgTypes[28] + mi := &file_authorizer_v1_authorizer_proto_msgTypes[35] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1839,7 +2205,7 @@ func (x *MetaRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use MetaRequest.ProtoReflect.Descriptor instead. func (*MetaRequest) Descriptor() ([]byte, []int) { - return file_authorizer_v1_authorizer_proto_rawDescGZIP(), []int{28} + return file_authorizer_v1_authorizer_proto_rawDescGZIP(), []int{35} } type CheckPermissionsRequest struct { @@ -1857,7 +2223,7 @@ type CheckPermissionsRequest struct { func (x *CheckPermissionsRequest) Reset() { *x = CheckPermissionsRequest{} - mi := &file_authorizer_v1_authorizer_proto_msgTypes[29] + mi := &file_authorizer_v1_authorizer_proto_msgTypes[36] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1869,7 +2235,7 @@ func (x *CheckPermissionsRequest) String() string { func (*CheckPermissionsRequest) ProtoMessage() {} func (x *CheckPermissionsRequest) ProtoReflect() protoreflect.Message { - mi := &file_authorizer_v1_authorizer_proto_msgTypes[29] + mi := &file_authorizer_v1_authorizer_proto_msgTypes[36] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1882,7 +2248,7 @@ func (x *CheckPermissionsRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use CheckPermissionsRequest.ProtoReflect.Descriptor instead. func (*CheckPermissionsRequest) Descriptor() ([]byte, []int) { - return file_authorizer_v1_authorizer_proto_rawDescGZIP(), []int{29} + return file_authorizer_v1_authorizer_proto_rawDescGZIP(), []int{36} } func (x *CheckPermissionsRequest) GetChecks() []*PermissionCheckInput { @@ -1910,7 +2276,7 @@ type CheckPermissionsResponse struct { func (x *CheckPermissionsResponse) Reset() { *x = CheckPermissionsResponse{} - mi := &file_authorizer_v1_authorizer_proto_msgTypes[30] + mi := &file_authorizer_v1_authorizer_proto_msgTypes[37] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1922,7 +2288,7 @@ func (x *CheckPermissionsResponse) String() string { func (*CheckPermissionsResponse) ProtoMessage() {} func (x *CheckPermissionsResponse) ProtoReflect() protoreflect.Message { - mi := &file_authorizer_v1_authorizer_proto_msgTypes[30] + mi := &file_authorizer_v1_authorizer_proto_msgTypes[37] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1935,7 +2301,7 @@ func (x *CheckPermissionsResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use CheckPermissionsResponse.ProtoReflect.Descriptor instead. func (*CheckPermissionsResponse) Descriptor() ([]byte, []int) { - return file_authorizer_v1_authorizer_proto_rawDescGZIP(), []int{30} + return file_authorizer_v1_authorizer_proto_rawDescGZIP(), []int{37} } func (x *CheckPermissionsResponse) GetResults() []*PermissionCheckResult { @@ -1960,7 +2326,7 @@ type ListPermissionsRequest struct { func (x *ListPermissionsRequest) Reset() { *x = ListPermissionsRequest{} - mi := &file_authorizer_v1_authorizer_proto_msgTypes[31] + mi := &file_authorizer_v1_authorizer_proto_msgTypes[38] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1972,7 +2338,7 @@ func (x *ListPermissionsRequest) String() string { func (*ListPermissionsRequest) ProtoMessage() {} func (x *ListPermissionsRequest) ProtoReflect() protoreflect.Message { - mi := &file_authorizer_v1_authorizer_proto_msgTypes[31] + mi := &file_authorizer_v1_authorizer_proto_msgTypes[38] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1985,7 +2351,7 @@ func (x *ListPermissionsRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListPermissionsRequest.ProtoReflect.Descriptor instead. func (*ListPermissionsRequest) Descriptor() ([]byte, []int) { - return file_authorizer_v1_authorizer_proto_rawDescGZIP(), []int{31} + return file_authorizer_v1_authorizer_proto_rawDescGZIP(), []int{38} } func (x *ListPermissionsRequest) GetRelation() string { @@ -2026,7 +2392,7 @@ type ListPermissionsResponse struct { func (x *ListPermissionsResponse) Reset() { *x = ListPermissionsResponse{} - mi := &file_authorizer_v1_authorizer_proto_msgTypes[32] + mi := &file_authorizer_v1_authorizer_proto_msgTypes[39] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2038,7 +2404,7 @@ func (x *ListPermissionsResponse) String() string { func (*ListPermissionsResponse) ProtoMessage() {} func (x *ListPermissionsResponse) ProtoReflect() protoreflect.Message { - mi := &file_authorizer_v1_authorizer_proto_msgTypes[32] + mi := &file_authorizer_v1_authorizer_proto_msgTypes[39] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2051,7 +2417,7 @@ func (x *ListPermissionsResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListPermissionsResponse.ProtoReflect.Descriptor instead. func (*ListPermissionsResponse) Descriptor() ([]byte, []int) { - return file_authorizer_v1_authorizer_proto_rawDescGZIP(), []int{32} + return file_authorizer_v1_authorizer_proto_rawDescGZIP(), []int{39} } func (x *ListPermissionsResponse) GetObjects() []string { @@ -2194,333 +2560,400 @@ var file_authorizer_v1_authorizer_proto_rawDesc = []byte{ 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x22, 0x2d, 0x0a, 0x11, 0x52, 0x65, 0x73, 0x65, 0x6e, 0x64, 0x4f, 0x74, 0x70, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x9c, 0x01, 0x0a, 0x15, 0x46, - 0x6f, 0x72, 0x67, 0x6f, 0x74, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x52, 0x65, 0x71, + 0x09, 0x52, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x77, 0x0a, 0x13, 0x53, 0x6b, + 0x69, 0x70, 0x4d, 0x66, 0x61, 0x53, 0x65, 0x74, 0x75, 0x70, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, + 0x74, 0x12, 0x1e, 0x0a, 0x05, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, + 0x42, 0x08, 0xba, 0x48, 0x05, 0x72, 0x03, 0x18, 0xc0, 0x02, 0x52, 0x05, 0x65, 0x6d, 0x61, 0x69, + 0x6c, 0x12, 0x2a, 0x0a, 0x0c, 0x70, 0x68, 0x6f, 0x6e, 0x65, 0x5f, 0x6e, 0x75, 0x6d, 0x62, 0x65, + 0x72, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x42, 0x07, 0xba, 0x48, 0x04, 0x72, 0x02, 0x18, 0x20, + 0x52, 0x0b, 0x70, 0x68, 0x6f, 0x6e, 0x65, 0x4e, 0x75, 0x6d, 0x62, 0x65, 0x72, 0x12, 0x14, 0x0a, + 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x73, 0x74, + 0x61, 0x74, 0x65, 0x22, 0x5c, 0x0a, 0x0e, 0x4c, 0x6f, 0x63, 0x6b, 0x4d, 0x66, 0x61, 0x52, 0x65, + 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1e, 0x0a, 0x05, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x18, 0x01, + 0x20, 0x01, 0x28, 0x09, 0x42, 0x08, 0xba, 0x48, 0x05, 0x72, 0x03, 0x18, 0xc0, 0x02, 0x52, 0x05, + 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x12, 0x2a, 0x0a, 0x0c, 0x70, 0x68, 0x6f, 0x6e, 0x65, 0x5f, 0x6e, + 0x75, 0x6d, 0x62, 0x65, 0x72, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x42, 0x07, 0xba, 0x48, 0x04, + 0x72, 0x02, 0x18, 0x20, 0x52, 0x0b, 0x70, 0x68, 0x6f, 0x6e, 0x65, 0x4e, 0x75, 0x6d, 0x62, 0x65, + 0x72, 0x22, 0x2b, 0x0a, 0x0f, 0x4c, 0x6f, 0x63, 0x6b, 0x4d, 0x66, 0x61, 0x52, 0x65, 0x73, 0x70, + 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, + 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x65, + 0x0a, 0x17, 0x45, 0x6d, 0x61, 0x69, 0x6c, 0x4f, 0x74, 0x70, 0x4d, 0x66, 0x61, 0x53, 0x65, 0x74, + 0x75, 0x70, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1e, 0x0a, 0x05, 0x65, 0x6d, 0x61, + 0x69, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x42, 0x08, 0xba, 0x48, 0x05, 0x72, 0x03, 0x18, + 0xc0, 0x02, 0x52, 0x05, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x12, 0x2a, 0x0a, 0x0c, 0x70, 0x68, 0x6f, + 0x6e, 0x65, 0x5f, 0x6e, 0x75, 0x6d, 0x62, 0x65, 0x72, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x42, + 0x07, 0xba, 0x48, 0x04, 0x72, 0x02, 0x18, 0x20, 0x52, 0x0b, 0x70, 0x68, 0x6f, 0x6e, 0x65, 0x4e, + 0x75, 0x6d, 0x62, 0x65, 0x72, 0x22, 0x34, 0x0a, 0x18, 0x45, 0x6d, 0x61, 0x69, 0x6c, 0x4f, 0x74, + 0x70, 0x4d, 0x66, 0x61, 0x53, 0x65, 0x74, 0x75, 0x70, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, + 0x65, 0x12, 0x18, 0x0a, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x01, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x63, 0x0a, 0x15, 0x53, + 0x6d, 0x73, 0x4f, 0x74, 0x70, 0x4d, 0x66, 0x61, 0x53, 0x65, 0x74, 0x75, 0x70, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1e, 0x0a, 0x05, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x42, 0x08, 0xba, 0x48, 0x05, 0x72, 0x03, 0x18, 0xc0, 0x02, 0x52, 0x05, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x12, 0x2a, 0x0a, 0x0c, 0x70, 0x68, 0x6f, 0x6e, 0x65, 0x5f, 0x6e, 0x75, 0x6d, 0x62, 0x65, 0x72, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x42, 0x07, 0xba, 0x48, 0x04, 0x72, 0x02, 0x18, 0x20, 0x52, 0x0b, 0x70, 0x68, 0x6f, 0x6e, 0x65, 0x4e, 0x75, 0x6d, 0x62, 0x65, 0x72, - 0x12, 0x14, 0x0a, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x12, 0x21, 0x0a, 0x0c, 0x72, 0x65, 0x64, 0x69, 0x72, 0x65, - 0x63, 0x74, 0x5f, 0x75, 0x72, 0x69, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x72, 0x65, - 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x55, 0x72, 0x69, 0x22, 0x74, 0x0a, 0x16, 0x46, 0x6f, 0x72, - 0x67, 0x6f, 0x74, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x52, 0x65, 0x73, 0x70, 0x6f, - 0x6e, 0x73, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x01, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x12, 0x40, 0x0a, - 0x1d, 0x73, 0x68, 0x6f, 0x75, 0x6c, 0x64, 0x5f, 0x73, 0x68, 0x6f, 0x77, 0x5f, 0x6d, 0x6f, 0x62, - 0x69, 0x6c, 0x65, 0x5f, 0x6f, 0x74, 0x70, 0x5f, 0x73, 0x63, 0x72, 0x65, 0x65, 0x6e, 0x18, 0x02, - 0x20, 0x01, 0x28, 0x08, 0x52, 0x19, 0x73, 0x68, 0x6f, 0x75, 0x6c, 0x64, 0x53, 0x68, 0x6f, 0x77, - 0x4d, 0x6f, 0x62, 0x69, 0x6c, 0x65, 0x4f, 0x74, 0x70, 0x53, 0x63, 0x72, 0x65, 0x65, 0x6e, 0x22, - 0xc9, 0x01, 0x0a, 0x14, 0x52, 0x65, 0x73, 0x65, 0x74, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, - 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x14, 0x0a, 0x05, 0x74, 0x6f, 0x6b, 0x65, - 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x10, - 0x0a, 0x03, 0x6f, 0x74, 0x70, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6f, 0x74, 0x70, - 0x12, 0x2a, 0x0a, 0x0c, 0x70, 0x68, 0x6f, 0x6e, 0x65, 0x5f, 0x6e, 0x75, 0x6d, 0x62, 0x65, 0x72, - 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x42, 0x07, 0xba, 0x48, 0x04, 0x72, 0x02, 0x18, 0x20, 0x52, - 0x0b, 0x70, 0x68, 0x6f, 0x6e, 0x65, 0x4e, 0x75, 0x6d, 0x62, 0x65, 0x72, 0x12, 0x26, 0x0a, 0x08, - 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x42, 0x0a, - 0xba, 0x48, 0x07, 0x72, 0x05, 0x10, 0x01, 0x18, 0x80, 0x01, 0x52, 0x08, 0x70, 0x61, 0x73, 0x73, - 0x77, 0x6f, 0x72, 0x64, 0x12, 0x35, 0x0a, 0x10, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x72, 0x6d, 0x5f, - 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x42, 0x0a, - 0xba, 0x48, 0x07, 0x72, 0x05, 0x10, 0x01, 0x18, 0x80, 0x01, 0x52, 0x0f, 0x63, 0x6f, 0x6e, 0x66, - 0x69, 0x72, 0x6d, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x22, 0x31, 0x0a, 0x15, 0x52, - 0x65, 0x73, 0x65, 0x74, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x52, 0x65, 0x73, 0x70, - 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, - 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x10, - 0x0a, 0x0e, 0x50, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, - 0x22, 0xd4, 0x04, 0x0a, 0x14, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x50, 0x72, 0x6f, 0x66, 0x69, - 0x6c, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x21, 0x0a, 0x0c, 0x6f, 0x6c, 0x64, - 0x5f, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x0b, 0x6f, 0x6c, 0x64, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x12, 0x2b, 0x0a, 0x0c, - 0x6e, 0x65, 0x77, 0x5f, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x18, 0x02, 0x20, 0x01, - 0x28, 0x09, 0x42, 0x08, 0xba, 0x48, 0x05, 0x72, 0x03, 0x18, 0x80, 0x01, 0x52, 0x0b, 0x6e, 0x65, - 0x77, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x12, 0x3a, 0x0a, 0x14, 0x63, 0x6f, 0x6e, - 0x66, 0x69, 0x72, 0x6d, 0x5f, 0x6e, 0x65, 0x77, 0x5f, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, - 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x42, 0x08, 0xba, 0x48, 0x05, 0x72, 0x03, 0x18, 0x80, - 0x01, 0x52, 0x12, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x72, 0x6d, 0x4e, 0x65, 0x77, 0x50, 0x61, 0x73, - 0x73, 0x77, 0x6f, 0x72, 0x64, 0x12, 0x1e, 0x0a, 0x05, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x18, 0x04, - 0x20, 0x01, 0x28, 0x09, 0x42, 0x08, 0xba, 0x48, 0x05, 0x72, 0x03, 0x18, 0xc0, 0x02, 0x52, 0x05, - 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x12, 0x1d, 0x0a, 0x0a, 0x67, 0x69, 0x76, 0x65, 0x6e, 0x5f, 0x6e, - 0x61, 0x6d, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x67, 0x69, 0x76, 0x65, 0x6e, - 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x1f, 0x0a, 0x0b, 0x66, 0x61, 0x6d, 0x69, 0x6c, 0x79, 0x5f, 0x6e, - 0x61, 0x6d, 0x65, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x66, 0x61, 0x6d, 0x69, 0x6c, - 0x79, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x1f, 0x0a, 0x0b, 0x6d, 0x69, 0x64, 0x64, 0x6c, 0x65, 0x5f, - 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x6d, 0x69, 0x64, 0x64, - 0x6c, 0x65, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x1a, 0x0a, 0x08, 0x6e, 0x69, 0x63, 0x6b, 0x6e, 0x61, - 0x6d, 0x65, 0x18, 0x08, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x6e, 0x69, 0x63, 0x6b, 0x6e, 0x61, - 0x6d, 0x65, 0x12, 0x16, 0x0a, 0x06, 0x67, 0x65, 0x6e, 0x64, 0x65, 0x72, 0x18, 0x09, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x06, 0x67, 0x65, 0x6e, 0x64, 0x65, 0x72, 0x12, 0x1c, 0x0a, 0x09, 0x62, 0x69, - 0x72, 0x74, 0x68, 0x64, 0x61, 0x74, 0x65, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x62, - 0x69, 0x72, 0x74, 0x68, 0x64, 0x61, 0x74, 0x65, 0x12, 0x2a, 0x0a, 0x0c, 0x70, 0x68, 0x6f, 0x6e, - 0x65, 0x5f, 0x6e, 0x75, 0x6d, 0x62, 0x65, 0x72, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x09, 0x42, 0x07, - 0xba, 0x48, 0x04, 0x72, 0x02, 0x18, 0x20, 0x52, 0x0b, 0x70, 0x68, 0x6f, 0x6e, 0x65, 0x4e, 0x75, - 0x6d, 0x62, 0x65, 0x72, 0x12, 0x18, 0x0a, 0x07, 0x70, 0x69, 0x63, 0x74, 0x75, 0x72, 0x65, 0x18, - 0x0c, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x70, 0x69, 0x63, 0x74, 0x75, 0x72, 0x65, 0x12, 0x43, - 0x0a, 0x1c, 0x69, 0x73, 0x5f, 0x6d, 0x75, 0x6c, 0x74, 0x69, 0x5f, 0x66, 0x61, 0x63, 0x74, 0x6f, - 0x72, 0x5f, 0x61, 0x75, 0x74, 0x68, 0x5f, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x0d, - 0x20, 0x01, 0x28, 0x08, 0x48, 0x00, 0x52, 0x18, 0x69, 0x73, 0x4d, 0x75, 0x6c, 0x74, 0x69, 0x46, - 0x61, 0x63, 0x74, 0x6f, 0x72, 0x41, 0x75, 0x74, 0x68, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, - 0x88, 0x01, 0x01, 0x12, 0x31, 0x0a, 0x08, 0x61, 0x70, 0x70, 0x5f, 0x64, 0x61, 0x74, 0x61, 0x18, - 0x0e, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, - 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x41, 0x70, 0x70, 0x44, 0x61, 0x74, 0x61, 0x52, 0x07, 0x61, - 0x70, 0x70, 0x44, 0x61, 0x74, 0x61, 0x42, 0x1f, 0x0a, 0x1d, 0x5f, 0x69, 0x73, 0x5f, 0x6d, 0x75, - 0x6c, 0x74, 0x69, 0x5f, 0x66, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x5f, 0x61, 0x75, 0x74, 0x68, 0x5f, - 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x22, 0x31, 0x0a, 0x15, 0x55, 0x70, 0x64, 0x61, 0x74, - 0x65, 0x50, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, - 0x12, 0x18, 0x0a, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x1a, 0x0a, 0x18, 0x44, 0x65, - 0x61, 0x63, 0x74, 0x69, 0x76, 0x61, 0x74, 0x65, 0x41, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x52, - 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0x35, 0x0a, 0x19, 0x44, 0x65, 0x61, 0x63, 0x74, 0x69, - 0x76, 0x61, 0x74, 0x65, 0x41, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, - 0x6e, 0x73, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x01, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x3d, 0x0a, - 0x0d, 0x52, 0x65, 0x76, 0x6f, 0x6b, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x2c, - 0x0a, 0x0d, 0x72, 0x65, 0x66, 0x72, 0x65, 0x73, 0x68, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, - 0x01, 0x20, 0x01, 0x28, 0x09, 0x42, 0x07, 0xba, 0x48, 0x04, 0x72, 0x02, 0x10, 0x01, 0x52, 0x0c, - 0x72, 0x65, 0x66, 0x72, 0x65, 0x73, 0x68, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x22, 0x2a, 0x0a, 0x0e, - 0x52, 0x65, 0x76, 0x6f, 0x6b, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x18, + 0x22, 0x32, 0x0a, 0x16, 0x53, 0x6d, 0x73, 0x4f, 0x74, 0x70, 0x4d, 0x66, 0x61, 0x53, 0x65, 0x74, + 0x75, 0x70, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x6d, 0x65, + 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x6d, 0x65, 0x73, + 0x73, 0x61, 0x67, 0x65, 0x22, 0x9c, 0x01, 0x0a, 0x15, 0x46, 0x6f, 0x72, 0x67, 0x6f, 0x74, 0x50, + 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1e, + 0x0a, 0x05, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x42, 0x08, 0xba, + 0x48, 0x05, 0x72, 0x03, 0x18, 0xc0, 0x02, 0x52, 0x05, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x12, 0x2a, + 0x0a, 0x0c, 0x70, 0x68, 0x6f, 0x6e, 0x65, 0x5f, 0x6e, 0x75, 0x6d, 0x62, 0x65, 0x72, 0x18, 0x02, + 0x20, 0x01, 0x28, 0x09, 0x42, 0x07, 0xba, 0x48, 0x04, 0x72, 0x02, 0x18, 0x20, 0x52, 0x0b, 0x70, + 0x68, 0x6f, 0x6e, 0x65, 0x4e, 0x75, 0x6d, 0x62, 0x65, 0x72, 0x12, 0x14, 0x0a, 0x05, 0x73, 0x74, + 0x61, 0x74, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, + 0x12, 0x21, 0x0a, 0x0c, 0x72, 0x65, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x5f, 0x75, 0x72, 0x69, + 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x72, 0x65, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, + 0x55, 0x72, 0x69, 0x22, 0x74, 0x0a, 0x16, 0x46, 0x6f, 0x72, 0x67, 0x6f, 0x74, 0x50, 0x61, 0x73, + 0x73, 0x77, 0x6f, 0x72, 0x64, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x18, 0x0a, + 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, + 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x12, 0x40, 0x0a, 0x1d, 0x73, 0x68, 0x6f, 0x75, 0x6c, + 0x64, 0x5f, 0x73, 0x68, 0x6f, 0x77, 0x5f, 0x6d, 0x6f, 0x62, 0x69, 0x6c, 0x65, 0x5f, 0x6f, 0x74, + 0x70, 0x5f, 0x73, 0x63, 0x72, 0x65, 0x65, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x19, + 0x73, 0x68, 0x6f, 0x75, 0x6c, 0x64, 0x53, 0x68, 0x6f, 0x77, 0x4d, 0x6f, 0x62, 0x69, 0x6c, 0x65, + 0x4f, 0x74, 0x70, 0x53, 0x63, 0x72, 0x65, 0x65, 0x6e, 0x22, 0xc9, 0x01, 0x0a, 0x14, 0x52, 0x65, + 0x73, 0x65, 0x74, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x12, 0x14, 0x0a, 0x05, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x05, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x10, 0x0a, 0x03, 0x6f, 0x74, 0x70, 0x18, + 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6f, 0x74, 0x70, 0x12, 0x2a, 0x0a, 0x0c, 0x70, 0x68, + 0x6f, 0x6e, 0x65, 0x5f, 0x6e, 0x75, 0x6d, 0x62, 0x65, 0x72, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, + 0x42, 0x07, 0xba, 0x48, 0x04, 0x72, 0x02, 0x18, 0x20, 0x52, 0x0b, 0x70, 0x68, 0x6f, 0x6e, 0x65, + 0x4e, 0x75, 0x6d, 0x62, 0x65, 0x72, 0x12, 0x26, 0x0a, 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, + 0x72, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x42, 0x0a, 0xba, 0x48, 0x07, 0x72, 0x05, 0x10, + 0x01, 0x18, 0x80, 0x01, 0x52, 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x12, 0x35, + 0x0a, 0x10, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x72, 0x6d, 0x5f, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, + 0x72, 0x64, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x42, 0x0a, 0xba, 0x48, 0x07, 0x72, 0x05, 0x10, + 0x01, 0x18, 0x80, 0x01, 0x52, 0x0f, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x72, 0x6d, 0x50, 0x61, 0x73, + 0x73, 0x77, 0x6f, 0x72, 0x64, 0x22, 0x31, 0x0a, 0x15, 0x52, 0x65, 0x73, 0x65, 0x74, 0x50, 0x61, + 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0xa2, 0x01, 0x0a, 0x0e, 0x53, 0x65, 0x73, - 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x14, 0x0a, 0x05, 0x72, - 0x6f, 0x6c, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, 0x52, 0x05, 0x72, 0x6f, 0x6c, 0x65, - 0x73, 0x12, 0x14, 0x0a, 0x05, 0x73, 0x63, 0x6f, 0x70, 0x65, 0x18, 0x02, 0x20, 0x03, 0x28, 0x09, - 0x52, 0x05, 0x73, 0x63, 0x6f, 0x70, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, - 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x12, 0x4e, 0x0a, - 0x12, 0x72, 0x65, 0x71, 0x75, 0x69, 0x72, 0x65, 0x64, 0x5f, 0x72, 0x65, 0x6c, 0x61, 0x74, 0x69, - 0x6f, 0x6e, 0x73, 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x61, 0x75, 0x74, 0x68, - 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x46, 0x67, 0x61, 0x52, 0x65, 0x6c, - 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x49, 0x6e, 0x70, 0x75, 0x74, 0x52, 0x11, 0x72, 0x65, 0x71, 0x75, - 0x69, 0x72, 0x65, 0x64, 0x52, 0x65, 0x6c, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x22, 0xc6, 0x01, - 0x0a, 0x17, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x4a, 0x77, 0x74, 0x54, 0x6f, 0x6b, - 0x65, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x26, 0x0a, 0x0a, 0x74, 0x6f, 0x6b, - 0x65, 0x6e, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x42, 0x07, 0xba, - 0x48, 0x04, 0x72, 0x02, 0x10, 0x01, 0x52, 0x09, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x54, 0x79, 0x70, - 0x65, 0x12, 0x1d, 0x0a, 0x05, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, - 0x42, 0x07, 0xba, 0x48, 0x04, 0x72, 0x02, 0x10, 0x01, 0x52, 0x05, 0x74, 0x6f, 0x6b, 0x65, 0x6e, - 0x12, 0x14, 0x0a, 0x05, 0x72, 0x6f, 0x6c, 0x65, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x09, 0x52, - 0x05, 0x72, 0x6f, 0x6c, 0x65, 0x73, 0x12, 0x4e, 0x0a, 0x12, 0x72, 0x65, 0x71, 0x75, 0x69, 0x72, - 0x65, 0x64, 0x5f, 0x72, 0x65, 0x6c, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x04, 0x20, 0x03, - 0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, - 0x76, 0x31, 0x2e, 0x46, 0x67, 0x61, 0x52, 0x65, 0x6c, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x49, 0x6e, - 0x70, 0x75, 0x74, 0x52, 0x11, 0x72, 0x65, 0x71, 0x75, 0x69, 0x72, 0x65, 0x64, 0x52, 0x65, 0x6c, - 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x22, 0x65, 0x0a, 0x18, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, - 0x74, 0x65, 0x4a, 0x77, 0x74, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, - 0x73, 0x65, 0x12, 0x19, 0x0a, 0x08, 0x69, 0x73, 0x5f, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x18, 0x01, - 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x69, 0x73, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x12, 0x2e, 0x0a, - 0x06, 0x63, 0x6c, 0x61, 0x69, 0x6d, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e, - 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x41, 0x70, - 0x70, 0x44, 0x61, 0x74, 0x61, 0x52, 0x06, 0x63, 0x6c, 0x61, 0x69, 0x6d, 0x73, 0x22, 0x9f, 0x01, - 0x0a, 0x16, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, - 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1f, 0x0a, 0x06, 0x63, 0x6f, 0x6f, 0x6b, - 0x69, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x42, 0x07, 0xba, 0x48, 0x04, 0x72, 0x02, 0x10, - 0x01, 0x52, 0x06, 0x63, 0x6f, 0x6f, 0x6b, 0x69, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x72, 0x6f, 0x6c, - 0x65, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x09, 0x52, 0x05, 0x72, 0x6f, 0x6c, 0x65, 0x73, 0x12, - 0x4e, 0x0a, 0x12, 0x72, 0x65, 0x71, 0x75, 0x69, 0x72, 0x65, 0x64, 0x5f, 0x72, 0x65, 0x6c, 0x61, - 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x61, 0x75, - 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x46, 0x67, 0x61, 0x52, - 0x65, 0x6c, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x49, 0x6e, 0x70, 0x75, 0x74, 0x52, 0x11, 0x72, 0x65, - 0x71, 0x75, 0x69, 0x72, 0x65, 0x64, 0x52, 0x65, 0x6c, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x22, - 0x5d, 0x0a, 0x17, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x53, 0x65, 0x73, 0x73, 0x69, - 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x19, 0x0a, 0x08, 0x69, 0x73, - 0x5f, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x69, 0x73, - 0x56, 0x61, 0x6c, 0x69, 0x64, 0x12, 0x27, 0x0a, 0x04, 0x75, 0x73, 0x65, 0x72, 0x18, 0x02, 0x20, - 0x01, 0x28, 0x0b, 0x32, 0x13, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, - 0x2e, 0x76, 0x31, 0x2e, 0x55, 0x73, 0x65, 0x72, 0x52, 0x04, 0x75, 0x73, 0x65, 0x72, 0x22, 0x0d, - 0x0a, 0x0b, 0x4d, 0x65, 0x74, 0x61, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0x76, 0x0a, - 0x17, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x50, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, - 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x47, 0x0a, 0x06, 0x63, 0x68, 0x65, 0x63, - 0x6b, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x23, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, - 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x50, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, - 0x69, 0x6f, 0x6e, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x49, 0x6e, 0x70, 0x75, 0x74, 0x42, 0x0a, 0xba, - 0x48, 0x07, 0x92, 0x01, 0x04, 0x08, 0x01, 0x10, 0x64, 0x52, 0x06, 0x63, 0x68, 0x65, 0x63, 0x6b, - 0x73, 0x12, 0x12, 0x0a, 0x04, 0x75, 0x73, 0x65, 0x72, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x04, 0x75, 0x73, 0x65, 0x72, 0x22, 0x5a, 0x0a, 0x18, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x50, 0x65, - 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, - 0x65, 0x12, 0x3e, 0x0a, 0x07, 0x72, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x73, 0x18, 0x01, 0x20, 0x03, - 0x28, 0x0b, 0x32, 0x24, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, + 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x10, 0x0a, 0x0e, 0x50, 0x72, 0x6f, 0x66, + 0x69, 0x6c, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0xd4, 0x04, 0x0a, 0x14, 0x55, + 0x70, 0x64, 0x61, 0x74, 0x65, 0x50, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x52, 0x65, 0x71, 0x75, + 0x65, 0x73, 0x74, 0x12, 0x21, 0x0a, 0x0c, 0x6f, 0x6c, 0x64, 0x5f, 0x70, 0x61, 0x73, 0x73, 0x77, + 0x6f, 0x72, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x6f, 0x6c, 0x64, 0x50, 0x61, + 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x12, 0x2b, 0x0a, 0x0c, 0x6e, 0x65, 0x77, 0x5f, 0x70, 0x61, + 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x42, 0x08, 0xba, 0x48, + 0x05, 0x72, 0x03, 0x18, 0x80, 0x01, 0x52, 0x0b, 0x6e, 0x65, 0x77, 0x50, 0x61, 0x73, 0x73, 0x77, + 0x6f, 0x72, 0x64, 0x12, 0x3a, 0x0a, 0x14, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x72, 0x6d, 0x5f, 0x6e, + 0x65, 0x77, 0x5f, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, + 0x09, 0x42, 0x08, 0xba, 0x48, 0x05, 0x72, 0x03, 0x18, 0x80, 0x01, 0x52, 0x12, 0x63, 0x6f, 0x6e, + 0x66, 0x69, 0x72, 0x6d, 0x4e, 0x65, 0x77, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x12, + 0x1e, 0x0a, 0x05, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x42, 0x08, + 0xba, 0x48, 0x05, 0x72, 0x03, 0x18, 0xc0, 0x02, 0x52, 0x05, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x12, + 0x1d, 0x0a, 0x0a, 0x67, 0x69, 0x76, 0x65, 0x6e, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x05, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x09, 0x67, 0x69, 0x76, 0x65, 0x6e, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x1f, + 0x0a, 0x0b, 0x66, 0x61, 0x6d, 0x69, 0x6c, 0x79, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x06, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x0a, 0x66, 0x61, 0x6d, 0x69, 0x6c, 0x79, 0x4e, 0x61, 0x6d, 0x65, 0x12, + 0x1f, 0x0a, 0x0b, 0x6d, 0x69, 0x64, 0x64, 0x6c, 0x65, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x07, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x6d, 0x69, 0x64, 0x64, 0x6c, 0x65, 0x4e, 0x61, 0x6d, 0x65, + 0x12, 0x1a, 0x0a, 0x08, 0x6e, 0x69, 0x63, 0x6b, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x08, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x08, 0x6e, 0x69, 0x63, 0x6b, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x16, 0x0a, 0x06, + 0x67, 0x65, 0x6e, 0x64, 0x65, 0x72, 0x18, 0x09, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x67, 0x65, + 0x6e, 0x64, 0x65, 0x72, 0x12, 0x1c, 0x0a, 0x09, 0x62, 0x69, 0x72, 0x74, 0x68, 0x64, 0x61, 0x74, + 0x65, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x62, 0x69, 0x72, 0x74, 0x68, 0x64, 0x61, + 0x74, 0x65, 0x12, 0x2a, 0x0a, 0x0c, 0x70, 0x68, 0x6f, 0x6e, 0x65, 0x5f, 0x6e, 0x75, 0x6d, 0x62, + 0x65, 0x72, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x09, 0x42, 0x07, 0xba, 0x48, 0x04, 0x72, 0x02, 0x18, + 0x20, 0x52, 0x0b, 0x70, 0x68, 0x6f, 0x6e, 0x65, 0x4e, 0x75, 0x6d, 0x62, 0x65, 0x72, 0x12, 0x18, + 0x0a, 0x07, 0x70, 0x69, 0x63, 0x74, 0x75, 0x72, 0x65, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x07, 0x70, 0x69, 0x63, 0x74, 0x75, 0x72, 0x65, 0x12, 0x43, 0x0a, 0x1c, 0x69, 0x73, 0x5f, 0x6d, + 0x75, 0x6c, 0x74, 0x69, 0x5f, 0x66, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x5f, 0x61, 0x75, 0x74, 0x68, + 0x5f, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x0d, 0x20, 0x01, 0x28, 0x08, 0x48, 0x00, + 0x52, 0x18, 0x69, 0x73, 0x4d, 0x75, 0x6c, 0x74, 0x69, 0x46, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x41, + 0x75, 0x74, 0x68, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x88, 0x01, 0x01, 0x12, 0x31, 0x0a, + 0x08, 0x61, 0x70, 0x70, 0x5f, 0x64, 0x61, 0x74, 0x61, 0x18, 0x0e, 0x20, 0x01, 0x28, 0x0b, 0x32, + 0x16, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, + 0x41, 0x70, 0x70, 0x44, 0x61, 0x74, 0x61, 0x52, 0x07, 0x61, 0x70, 0x70, 0x44, 0x61, 0x74, 0x61, + 0x42, 0x1f, 0x0a, 0x1d, 0x5f, 0x69, 0x73, 0x5f, 0x6d, 0x75, 0x6c, 0x74, 0x69, 0x5f, 0x66, 0x61, + 0x63, 0x74, 0x6f, 0x72, 0x5f, 0x61, 0x75, 0x74, 0x68, 0x5f, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, + 0x64, 0x22, 0x31, 0x0a, 0x15, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x50, 0x72, 0x6f, 0x66, 0x69, + 0x6c, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x6d, 0x65, + 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x6d, 0x65, 0x73, + 0x73, 0x61, 0x67, 0x65, 0x22, 0x1a, 0x0a, 0x18, 0x44, 0x65, 0x61, 0x63, 0x74, 0x69, 0x76, 0x61, + 0x74, 0x65, 0x41, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, + 0x22, 0x35, 0x0a, 0x19, 0x44, 0x65, 0x61, 0x63, 0x74, 0x69, 0x76, 0x61, 0x74, 0x65, 0x41, 0x63, + 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x18, 0x0a, + 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, + 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x3d, 0x0a, 0x0d, 0x52, 0x65, 0x76, 0x6f, 0x6b, + 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x2c, 0x0a, 0x0d, 0x72, 0x65, 0x66, 0x72, + 0x65, 0x73, 0x68, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x42, + 0x07, 0xba, 0x48, 0x04, 0x72, 0x02, 0x10, 0x01, 0x52, 0x0c, 0x72, 0x65, 0x66, 0x72, 0x65, 0x73, + 0x68, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x22, 0x2a, 0x0a, 0x0e, 0x52, 0x65, 0x76, 0x6f, 0x6b, 0x65, + 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x6d, 0x65, 0x73, 0x73, + 0x61, 0x67, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, + 0x67, 0x65, 0x22, 0xa2, 0x01, 0x0a, 0x0e, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, + 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x14, 0x0a, 0x05, 0x72, 0x6f, 0x6c, 0x65, 0x73, 0x18, 0x01, + 0x20, 0x03, 0x28, 0x09, 0x52, 0x05, 0x72, 0x6f, 0x6c, 0x65, 0x73, 0x12, 0x14, 0x0a, 0x05, 0x73, + 0x63, 0x6f, 0x70, 0x65, 0x18, 0x02, 0x20, 0x03, 0x28, 0x09, 0x52, 0x05, 0x73, 0x63, 0x6f, 0x70, + 0x65, 0x12, 0x14, 0x0a, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x12, 0x4e, 0x0a, 0x12, 0x72, 0x65, 0x71, 0x75, 0x69, + 0x72, 0x65, 0x64, 0x5f, 0x72, 0x65, 0x6c, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x04, 0x20, + 0x03, 0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, + 0x2e, 0x76, 0x31, 0x2e, 0x46, 0x67, 0x61, 0x52, 0x65, 0x6c, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x49, + 0x6e, 0x70, 0x75, 0x74, 0x52, 0x11, 0x72, 0x65, 0x71, 0x75, 0x69, 0x72, 0x65, 0x64, 0x52, 0x65, + 0x6c, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x22, 0xc6, 0x01, 0x0a, 0x17, 0x56, 0x61, 0x6c, 0x69, + 0x64, 0x61, 0x74, 0x65, 0x4a, 0x77, 0x74, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x52, 0x65, 0x71, 0x75, + 0x65, 0x73, 0x74, 0x12, 0x26, 0x0a, 0x0a, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x5f, 0x74, 0x79, 0x70, + 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x42, 0x07, 0xba, 0x48, 0x04, 0x72, 0x02, 0x10, 0x01, + 0x52, 0x09, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x54, 0x79, 0x70, 0x65, 0x12, 0x1d, 0x0a, 0x05, 0x74, + 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x42, 0x07, 0xba, 0x48, 0x04, 0x72, + 0x02, 0x10, 0x01, 0x52, 0x05, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x14, 0x0a, 0x05, 0x72, 0x6f, + 0x6c, 0x65, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x09, 0x52, 0x05, 0x72, 0x6f, 0x6c, 0x65, 0x73, + 0x12, 0x4e, 0x0a, 0x12, 0x72, 0x65, 0x71, 0x75, 0x69, 0x72, 0x65, 0x64, 0x5f, 0x72, 0x65, 0x6c, + 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x61, + 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x46, 0x67, 0x61, + 0x52, 0x65, 0x6c, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x49, 0x6e, 0x70, 0x75, 0x74, 0x52, 0x11, 0x72, + 0x65, 0x71, 0x75, 0x69, 0x72, 0x65, 0x64, 0x52, 0x65, 0x6c, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, + 0x22, 0x65, 0x0a, 0x18, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x4a, 0x77, 0x74, 0x54, + 0x6f, 0x6b, 0x65, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x19, 0x0a, 0x08, + 0x69, 0x73, 0x5f, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, + 0x69, 0x73, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x12, 0x2e, 0x0a, 0x06, 0x63, 0x6c, 0x61, 0x69, 0x6d, + 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, + 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x41, 0x70, 0x70, 0x44, 0x61, 0x74, 0x61, 0x52, + 0x06, 0x63, 0x6c, 0x61, 0x69, 0x6d, 0x73, 0x22, 0x9f, 0x01, 0x0a, 0x16, 0x56, 0x61, 0x6c, 0x69, + 0x64, 0x61, 0x74, 0x65, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x12, 0x1f, 0x0a, 0x06, 0x63, 0x6f, 0x6f, 0x6b, 0x69, 0x65, 0x18, 0x01, 0x20, 0x01, + 0x28, 0x09, 0x42, 0x07, 0xba, 0x48, 0x04, 0x72, 0x02, 0x10, 0x01, 0x52, 0x06, 0x63, 0x6f, 0x6f, + 0x6b, 0x69, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x72, 0x6f, 0x6c, 0x65, 0x73, 0x18, 0x02, 0x20, 0x03, + 0x28, 0x09, 0x52, 0x05, 0x72, 0x6f, 0x6c, 0x65, 0x73, 0x12, 0x4e, 0x0a, 0x12, 0x72, 0x65, 0x71, + 0x75, 0x69, 0x72, 0x65, 0x64, 0x5f, 0x72, 0x65, 0x6c, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x18, + 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, + 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x46, 0x67, 0x61, 0x52, 0x65, 0x6c, 0x61, 0x74, 0x69, 0x6f, + 0x6e, 0x49, 0x6e, 0x70, 0x75, 0x74, 0x52, 0x11, 0x72, 0x65, 0x71, 0x75, 0x69, 0x72, 0x65, 0x64, + 0x52, 0x65, 0x6c, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x22, 0x5d, 0x0a, 0x17, 0x56, 0x61, 0x6c, + 0x69, 0x64, 0x61, 0x74, 0x65, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x70, + 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x19, 0x0a, 0x08, 0x69, 0x73, 0x5f, 0x76, 0x61, 0x6c, 0x69, 0x64, + 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x69, 0x73, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x12, + 0x27, 0x0a, 0x04, 0x75, 0x73, 0x65, 0x72, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x13, 0x2e, + 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x55, 0x73, + 0x65, 0x72, 0x52, 0x04, 0x75, 0x73, 0x65, 0x72, 0x22, 0x0d, 0x0a, 0x0b, 0x4d, 0x65, 0x74, 0x61, + 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0x76, 0x0a, 0x17, 0x43, 0x68, 0x65, 0x63, 0x6b, + 0x50, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x12, 0x47, 0x0a, 0x06, 0x63, 0x68, 0x65, 0x63, 0x6b, 0x73, 0x18, 0x01, 0x20, 0x03, + 0x28, 0x0b, 0x32, 0x23, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x50, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x43, 0x68, 0x65, - 0x63, 0x6b, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x52, 0x07, 0x72, 0x65, 0x73, 0x75, 0x6c, 0x74, - 0x73, 0x22, 0x69, 0x0a, 0x16, 0x4c, 0x69, 0x73, 0x74, 0x50, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, - 0x69, 0x6f, 0x6e, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1a, 0x0a, 0x08, 0x72, - 0x65, 0x6c, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x72, - 0x65, 0x6c, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x1f, 0x0a, 0x0b, 0x6f, 0x62, 0x6a, 0x65, 0x63, - 0x74, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x6f, 0x62, - 0x6a, 0x65, 0x63, 0x74, 0x54, 0x79, 0x70, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x75, 0x73, 0x65, 0x72, - 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x75, 0x73, 0x65, 0x72, 0x22, 0x8e, 0x01, 0x0a, - 0x17, 0x4c, 0x69, 0x73, 0x74, 0x50, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x73, - 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x6f, 0x62, 0x6a, 0x65, - 0x63, 0x74, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, 0x52, 0x07, 0x6f, 0x62, 0x6a, 0x65, 0x63, - 0x74, 0x73, 0x12, 0x3b, 0x0a, 0x0b, 0x70, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, - 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, - 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x50, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, - 0x6f, 0x6e, 0x52, 0x0b, 0x70, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x12, - 0x1c, 0x0a, 0x09, 0x74, 0x72, 0x75, 0x6e, 0x63, 0x61, 0x74, 0x65, 0x64, 0x18, 0x03, 0x20, 0x01, - 0x28, 0x08, 0x52, 0x09, 0x74, 0x72, 0x75, 0x6e, 0x63, 0x61, 0x74, 0x65, 0x64, 0x32, 0xe8, 0x12, - 0x0a, 0x11, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x53, 0x65, 0x72, 0x76, - 0x69, 0x63, 0x65, 0x12, 0x66, 0x0a, 0x06, 0x53, 0x69, 0x67, 0x6e, 0x75, 0x70, 0x12, 0x1c, 0x2e, - 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x69, - 0x67, 0x6e, 0x75, 0x70, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1b, 0x2e, 0x61, 0x75, + 0x63, 0x6b, 0x49, 0x6e, 0x70, 0x75, 0x74, 0x42, 0x0a, 0xba, 0x48, 0x07, 0x92, 0x01, 0x04, 0x08, + 0x01, 0x10, 0x64, 0x52, 0x06, 0x63, 0x68, 0x65, 0x63, 0x6b, 0x73, 0x12, 0x12, 0x0a, 0x04, 0x75, + 0x73, 0x65, 0x72, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x75, 0x73, 0x65, 0x72, 0x22, + 0x5a, 0x0a, 0x18, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x50, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, + 0x6f, 0x6e, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x3e, 0x0a, 0x07, 0x72, + 0x65, 0x73, 0x75, 0x6c, 0x74, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x24, 0x2e, 0x61, + 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x50, 0x65, 0x72, + 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x52, 0x65, 0x73, 0x75, + 0x6c, 0x74, 0x52, 0x07, 0x72, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x73, 0x22, 0x69, 0x0a, 0x16, 0x4c, + 0x69, 0x73, 0x74, 0x50, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x52, 0x65, + 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1a, 0x0a, 0x08, 0x72, 0x65, 0x6c, 0x61, 0x74, 0x69, 0x6f, + 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x72, 0x65, 0x6c, 0x61, 0x74, 0x69, 0x6f, + 0x6e, 0x12, 0x1f, 0x0a, 0x0b, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x5f, 0x74, 0x79, 0x70, 0x65, + 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x54, 0x79, + 0x70, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x75, 0x73, 0x65, 0x72, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x04, 0x75, 0x73, 0x65, 0x72, 0x22, 0x8e, 0x01, 0x0a, 0x17, 0x4c, 0x69, 0x73, 0x74, 0x50, + 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, + 0x73, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x73, 0x18, 0x01, 0x20, + 0x03, 0x28, 0x09, 0x52, 0x07, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x73, 0x12, 0x3b, 0x0a, 0x0b, + 0x70, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, + 0x0b, 0x32, 0x19, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, + 0x31, 0x2e, 0x50, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x0b, 0x70, 0x65, + 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x1c, 0x0a, 0x09, 0x74, 0x72, 0x75, + 0x6e, 0x63, 0x61, 0x74, 0x65, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x09, 0x74, 0x72, + 0x75, 0x6e, 0x63, 0x61, 0x74, 0x65, 0x64, 0x32, 0xe7, 0x16, 0x0a, 0x11, 0x41, 0x75, 0x74, 0x68, + 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x66, 0x0a, + 0x06, 0x53, 0x69, 0x67, 0x6e, 0x75, 0x70, 0x12, 0x1c, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, + 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x69, 0x67, 0x6e, 0x75, 0x70, 0x52, 0x65, + 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1b, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, + 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x41, 0x75, 0x74, 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, + 0x73, 0x65, 0x22, 0x21, 0x92, 0xb5, 0x18, 0x00, 0x98, 0xb5, 0x18, 0x01, 0xa0, 0xb5, 0x18, 0x01, + 0x82, 0xd3, 0xe4, 0x93, 0x02, 0x0f, 0x3a, 0x01, 0x2a, 0x22, 0x0a, 0x2f, 0x76, 0x31, 0x2f, 0x73, + 0x69, 0x67, 0x6e, 0x75, 0x70, 0x12, 0x63, 0x0a, 0x05, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x12, 0x1b, + 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x4c, + 0x6f, 0x67, 0x69, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1b, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x41, 0x75, 0x74, 0x68, - 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x21, 0x92, 0xb5, 0x18, 0x00, 0x98, 0xb5, - 0x18, 0x01, 0xa0, 0xb5, 0x18, 0x01, 0x82, 0xd3, 0xe4, 0x93, 0x02, 0x0f, 0x3a, 0x01, 0x2a, 0x22, - 0x0a, 0x2f, 0x76, 0x31, 0x2f, 0x73, 0x69, 0x67, 0x6e, 0x75, 0x70, 0x12, 0x63, 0x0a, 0x05, 0x4c, - 0x6f, 0x67, 0x69, 0x6e, 0x12, 0x1b, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, - 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, - 0x74, 0x1a, 0x1b, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, - 0x31, 0x2e, 0x41, 0x75, 0x74, 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x20, - 0x92, 0xb5, 0x18, 0x00, 0x98, 0xb5, 0x18, 0x01, 0xa0, 0xb5, 0x18, 0x01, 0x82, 0xd3, 0xe4, 0x93, - 0x02, 0x0e, 0x3a, 0x01, 0x2a, 0x22, 0x09, 0x2f, 0x76, 0x31, 0x2f, 0x6c, 0x6f, 0x67, 0x69, 0x6e, - 0x12, 0x5d, 0x0a, 0x06, 0x4c, 0x6f, 0x67, 0x6f, 0x75, 0x74, 0x12, 0x1c, 0x2e, 0x61, 0x75, 0x74, - 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x6f, 0x67, 0x6f, 0x75, - 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1d, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, - 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x6f, 0x67, 0x6f, 0x75, 0x74, 0x52, - 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x16, 0x98, 0xb5, 0x18, 0x01, 0x82, 0xd3, 0xe4, - 0x93, 0x02, 0x0c, 0x22, 0x0a, 0x2f, 0x76, 0x31, 0x2f, 0x6c, 0x6f, 0x67, 0x6f, 0x75, 0x74, 0x12, - 0x86, 0x01, 0x0a, 0x0e, 0x4d, 0x61, 0x67, 0x69, 0x63, 0x4c, 0x69, 0x6e, 0x6b, 0x4c, 0x6f, 0x67, - 0x69, 0x6e, 0x12, 0x24, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, + 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x20, 0x92, 0xb5, 0x18, 0x00, 0x98, 0xb5, + 0x18, 0x01, 0xa0, 0xb5, 0x18, 0x01, 0x82, 0xd3, 0xe4, 0x93, 0x02, 0x0e, 0x3a, 0x01, 0x2a, 0x22, + 0x09, 0x2f, 0x76, 0x31, 0x2f, 0x6c, 0x6f, 0x67, 0x69, 0x6e, 0x12, 0x5d, 0x0a, 0x06, 0x4c, 0x6f, + 0x67, 0x6f, 0x75, 0x74, 0x12, 0x1c, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, + 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x6f, 0x67, 0x6f, 0x75, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x1a, 0x1d, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, + 0x76, 0x31, 0x2e, 0x4c, 0x6f, 0x67, 0x6f, 0x75, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, + 0x65, 0x22, 0x16, 0x98, 0xb5, 0x18, 0x01, 0x82, 0xd3, 0xe4, 0x93, 0x02, 0x0c, 0x22, 0x0a, 0x2f, + 0x76, 0x31, 0x2f, 0x6c, 0x6f, 0x67, 0x6f, 0x75, 0x74, 0x12, 0x86, 0x01, 0x0a, 0x0e, 0x4d, 0x61, + 0x67, 0x69, 0x63, 0x4c, 0x69, 0x6e, 0x6b, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x12, 0x24, 0x2e, 0x61, + 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x4d, 0x61, 0x67, + 0x69, 0x63, 0x4c, 0x69, 0x6e, 0x6b, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x1a, 0x25, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x4d, 0x61, 0x67, 0x69, 0x63, 0x4c, 0x69, 0x6e, 0x6b, 0x4c, 0x6f, 0x67, 0x69, - 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x25, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, - 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x4d, 0x61, 0x67, 0x69, 0x63, 0x4c, 0x69, - 0x6e, 0x6b, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, - 0x27, 0x98, 0xb5, 0x18, 0x01, 0xa0, 0xb5, 0x18, 0x01, 0x82, 0xd3, 0xe4, 0x93, 0x02, 0x19, 0x3a, - 0x01, 0x2a, 0x22, 0x14, 0x2f, 0x76, 0x31, 0x2f, 0x6d, 0x61, 0x67, 0x69, 0x63, 0x5f, 0x6c, 0x69, - 0x6e, 0x6b, 0x5f, 0x6c, 0x6f, 0x67, 0x69, 0x6e, 0x12, 0x72, 0x0a, 0x0b, 0x56, 0x65, 0x72, 0x69, - 0x66, 0x79, 0x45, 0x6d, 0x61, 0x69, 0x6c, 0x12, 0x21, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, - 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x56, 0x65, 0x72, 0x69, 0x66, 0x79, 0x45, 0x6d, - 0x61, 0x69, 0x6c, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1b, 0x2e, 0x61, 0x75, 0x74, - 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x41, 0x75, 0x74, 0x68, 0x52, - 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x23, 0x98, 0xb5, 0x18, 0x01, 0xa0, 0xb5, 0x18, - 0x01, 0x82, 0xd3, 0xe4, 0x93, 0x02, 0x15, 0x3a, 0x01, 0x2a, 0x22, 0x10, 0x2f, 0x76, 0x31, 0x2f, - 0x76, 0x65, 0x72, 0x69, 0x66, 0x79, 0x5f, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x12, 0x8e, 0x01, 0x0a, - 0x11, 0x52, 0x65, 0x73, 0x65, 0x6e, 0x64, 0x56, 0x65, 0x72, 0x69, 0x66, 0x79, 0x45, 0x6d, 0x61, - 0x69, 0x6c, 0x12, 0x27, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, - 0x76, 0x31, 0x2e, 0x52, 0x65, 0x73, 0x65, 0x6e, 0x64, 0x56, 0x65, 0x72, 0x69, 0x66, 0x79, 0x45, - 0x6d, 0x61, 0x69, 0x6c, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x28, 0x2e, 0x61, 0x75, - 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x73, 0x65, - 0x6e, 0x64, 0x56, 0x65, 0x72, 0x69, 0x66, 0x79, 0x45, 0x6d, 0x61, 0x69, 0x6c, 0x52, 0x65, 0x73, - 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x26, 0xa0, 0xb5, 0x18, 0x01, 0x82, 0xd3, 0xe4, 0x93, 0x02, - 0x1c, 0x3a, 0x01, 0x2a, 0x22, 0x17, 0x2f, 0x76, 0x31, 0x2f, 0x72, 0x65, 0x73, 0x65, 0x6e, 0x64, - 0x5f, 0x76, 0x65, 0x72, 0x69, 0x66, 0x79, 0x5f, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x12, 0x6c, 0x0a, - 0x09, 0x56, 0x65, 0x72, 0x69, 0x66, 0x79, 0x4f, 0x74, 0x70, 0x12, 0x1f, 0x2e, 0x61, 0x75, 0x74, - 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x56, 0x65, 0x72, 0x69, 0x66, - 0x79, 0x4f, 0x74, 0x70, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1b, 0x2e, 0x61, 0x75, - 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x41, 0x75, 0x74, 0x68, - 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x21, 0x98, 0xb5, 0x18, 0x01, 0xa0, 0xb5, - 0x18, 0x01, 0x82, 0xd3, 0xe4, 0x93, 0x02, 0x13, 0x3a, 0x01, 0x2a, 0x22, 0x0e, 0x2f, 0x76, 0x31, - 0x2f, 0x76, 0x65, 0x72, 0x69, 0x66, 0x79, 0x5f, 0x6f, 0x74, 0x70, 0x12, 0x6d, 0x0a, 0x09, 0x52, - 0x65, 0x73, 0x65, 0x6e, 0x64, 0x4f, 0x74, 0x70, 0x12, 0x1f, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, - 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x73, 0x65, 0x6e, 0x64, 0x4f, - 0x74, 0x70, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x20, 0x2e, 0x61, 0x75, 0x74, 0x68, - 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x73, 0x65, 0x6e, 0x64, - 0x4f, 0x74, 0x70, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x1d, 0xa0, 0xb5, 0x18, - 0x01, 0x82, 0xd3, 0xe4, 0x93, 0x02, 0x13, 0x3a, 0x01, 0x2a, 0x22, 0x0e, 0x2f, 0x76, 0x31, 0x2f, - 0x72, 0x65, 0x73, 0x65, 0x6e, 0x64, 0x5f, 0x6f, 0x74, 0x70, 0x12, 0x81, 0x01, 0x0a, 0x0e, 0x46, - 0x6f, 0x72, 0x67, 0x6f, 0x74, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x12, 0x24, 0x2e, - 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x46, 0x6f, - 0x72, 0x67, 0x6f, 0x74, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x52, 0x65, 0x71, 0x75, - 0x65, 0x73, 0x74, 0x1a, 0x25, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, - 0x2e, 0x76, 0x31, 0x2e, 0x46, 0x6f, 0x72, 0x67, 0x6f, 0x74, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, - 0x72, 0x64, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x22, 0xa0, 0xb5, 0x18, 0x01, - 0x82, 0xd3, 0xe4, 0x93, 0x02, 0x18, 0x3a, 0x01, 0x2a, 0x22, 0x13, 0x2f, 0x76, 0x31, 0x2f, 0x66, - 0x6f, 0x72, 0x67, 0x6f, 0x74, 0x5f, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x12, 0x81, - 0x01, 0x0a, 0x0d, 0x52, 0x65, 0x73, 0x65, 0x74, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, - 0x12, 0x23, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, - 0x2e, 0x52, 0x65, 0x73, 0x65, 0x74, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x52, 0x65, - 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x24, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, - 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x73, 0x65, 0x74, 0x50, 0x61, 0x73, 0x73, 0x77, - 0x6f, 0x72, 0x64, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x25, 0x98, 0xb5, 0x18, - 0x01, 0xa0, 0xb5, 0x18, 0x01, 0x82, 0xd3, 0xe4, 0x93, 0x02, 0x17, 0x3a, 0x01, 0x2a, 0x22, 0x12, - 0x2f, 0x76, 0x31, 0x2f, 0x72, 0x65, 0x73, 0x65, 0x74, 0x5f, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, - 0x72, 0x64, 0x12, 0x58, 0x0a, 0x07, 0x50, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x12, 0x1d, 0x2e, - 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x50, 0x72, - 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x13, 0x2e, 0x61, - 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x55, 0x73, 0x65, - 0x72, 0x22, 0x19, 0x92, 0xb5, 0x18, 0x02, 0x08, 0x01, 0x82, 0xd3, 0xe4, 0x93, 0x02, 0x0d, 0x12, - 0x0b, 0x2f, 0x76, 0x31, 0x2f, 0x70, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x12, 0x7d, 0x0a, 0x0d, - 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x50, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x12, 0x23, 0x2e, - 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x55, 0x70, - 0x64, 0x61, 0x74, 0x65, 0x50, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, - 0x73, 0x74, 0x1a, 0x24, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, - 0x76, 0x31, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x50, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, - 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x21, 0x98, 0xb5, 0x18, 0x01, 0x82, 0xd3, - 0xe4, 0x93, 0x02, 0x17, 0x3a, 0x01, 0x2a, 0x22, 0x12, 0x2f, 0x76, 0x31, 0x2f, 0x75, 0x70, 0x64, - 0x61, 0x74, 0x65, 0x5f, 0x70, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x12, 0x93, 0x01, 0x0a, 0x11, - 0x44, 0x65, 0x61, 0x63, 0x74, 0x69, 0x76, 0x61, 0x74, 0x65, 0x41, 0x63, 0x63, 0x6f, 0x75, 0x6e, - 0x74, 0x12, 0x27, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, - 0x31, 0x2e, 0x44, 0x65, 0x61, 0x63, 0x74, 0x69, 0x76, 0x61, 0x74, 0x65, 0x41, 0x63, 0x63, 0x6f, - 0x75, 0x6e, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x28, 0x2e, 0x61, 0x75, 0x74, + 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x27, 0x98, 0xb5, 0x18, 0x01, 0xa0, + 0xb5, 0x18, 0x01, 0x82, 0xd3, 0xe4, 0x93, 0x02, 0x19, 0x3a, 0x01, 0x2a, 0x22, 0x14, 0x2f, 0x76, + 0x31, 0x2f, 0x6d, 0x61, 0x67, 0x69, 0x63, 0x5f, 0x6c, 0x69, 0x6e, 0x6b, 0x5f, 0x6c, 0x6f, 0x67, + 0x69, 0x6e, 0x12, 0x72, 0x0a, 0x0b, 0x56, 0x65, 0x72, 0x69, 0x66, 0x79, 0x45, 0x6d, 0x61, 0x69, + 0x6c, 0x12, 0x21, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, + 0x31, 0x2e, 0x56, 0x65, 0x72, 0x69, 0x66, 0x79, 0x45, 0x6d, 0x61, 0x69, 0x6c, 0x52, 0x65, 0x71, + 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1b, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, + 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x41, 0x75, 0x74, 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, + 0x65, 0x22, 0x23, 0x98, 0xb5, 0x18, 0x01, 0xa0, 0xb5, 0x18, 0x01, 0x82, 0xd3, 0xe4, 0x93, 0x02, + 0x15, 0x3a, 0x01, 0x2a, 0x22, 0x10, 0x2f, 0x76, 0x31, 0x2f, 0x76, 0x65, 0x72, 0x69, 0x66, 0x79, + 0x5f, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x12, 0x8e, 0x01, 0x0a, 0x11, 0x52, 0x65, 0x73, 0x65, 0x6e, + 0x64, 0x56, 0x65, 0x72, 0x69, 0x66, 0x79, 0x45, 0x6d, 0x61, 0x69, 0x6c, 0x12, 0x27, 0x2e, 0x61, + 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x73, + 0x65, 0x6e, 0x64, 0x56, 0x65, 0x72, 0x69, 0x66, 0x79, 0x45, 0x6d, 0x61, 0x69, 0x6c, 0x52, 0x65, + 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x28, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, + 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x73, 0x65, 0x6e, 0x64, 0x56, 0x65, 0x72, 0x69, + 0x66, 0x79, 0x45, 0x6d, 0x61, 0x69, 0x6c, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, + 0x26, 0xa0, 0xb5, 0x18, 0x01, 0x82, 0xd3, 0xe4, 0x93, 0x02, 0x1c, 0x3a, 0x01, 0x2a, 0x22, 0x17, + 0x2f, 0x76, 0x31, 0x2f, 0x72, 0x65, 0x73, 0x65, 0x6e, 0x64, 0x5f, 0x76, 0x65, 0x72, 0x69, 0x66, + 0x79, 0x5f, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x12, 0x6c, 0x0a, 0x09, 0x56, 0x65, 0x72, 0x69, 0x66, + 0x79, 0x4f, 0x74, 0x70, 0x12, 0x1f, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, + 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x56, 0x65, 0x72, 0x69, 0x66, 0x79, 0x4f, 0x74, 0x70, 0x52, 0x65, + 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1b, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, + 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x41, 0x75, 0x74, 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, + 0x73, 0x65, 0x22, 0x21, 0x98, 0xb5, 0x18, 0x01, 0xa0, 0xb5, 0x18, 0x01, 0x82, 0xd3, 0xe4, 0x93, + 0x02, 0x13, 0x3a, 0x01, 0x2a, 0x22, 0x0e, 0x2f, 0x76, 0x31, 0x2f, 0x76, 0x65, 0x72, 0x69, 0x66, + 0x79, 0x5f, 0x6f, 0x74, 0x70, 0x12, 0x6d, 0x0a, 0x09, 0x52, 0x65, 0x73, 0x65, 0x6e, 0x64, 0x4f, + 0x74, 0x70, 0x12, 0x1f, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, + 0x76, 0x31, 0x2e, 0x52, 0x65, 0x73, 0x65, 0x6e, 0x64, 0x4f, 0x74, 0x70, 0x52, 0x65, 0x71, 0x75, + 0x65, 0x73, 0x74, 0x1a, 0x20, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, + 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x73, 0x65, 0x6e, 0x64, 0x4f, 0x74, 0x70, 0x52, 0x65, 0x73, + 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x1d, 0xa0, 0xb5, 0x18, 0x01, 0x82, 0xd3, 0xe4, 0x93, 0x02, + 0x13, 0x3a, 0x01, 0x2a, 0x22, 0x0e, 0x2f, 0x76, 0x31, 0x2f, 0x72, 0x65, 0x73, 0x65, 0x6e, 0x64, + 0x5f, 0x6f, 0x74, 0x70, 0x12, 0x76, 0x0a, 0x0c, 0x53, 0x6b, 0x69, 0x70, 0x4d, 0x66, 0x61, 0x53, + 0x65, 0x74, 0x75, 0x70, 0x12, 0x22, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, + 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x6b, 0x69, 0x70, 0x4d, 0x66, 0x61, 0x53, 0x65, 0x74, 0x75, + 0x70, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1b, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, + 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x41, 0x75, 0x74, 0x68, 0x52, 0x65, 0x73, + 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x25, 0x98, 0xb5, 0x18, 0x01, 0xa0, 0xb5, 0x18, 0x01, 0x82, + 0xd3, 0xe4, 0x93, 0x02, 0x17, 0x3a, 0x01, 0x2a, 0x22, 0x12, 0x2f, 0x76, 0x31, 0x2f, 0x73, 0x6b, + 0x69, 0x70, 0x5f, 0x6d, 0x66, 0x61, 0x5f, 0x73, 0x65, 0x74, 0x75, 0x70, 0x12, 0x69, 0x0a, 0x07, + 0x4c, 0x6f, 0x63, 0x6b, 0x4d, 0x66, 0x61, 0x12, 0x1d, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, + 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x6f, 0x63, 0x6b, 0x4d, 0x66, 0x61, 0x52, + 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1e, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, + 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x6f, 0x63, 0x6b, 0x4d, 0x66, 0x61, 0x52, 0x65, + 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x1f, 0x98, 0xb5, 0x18, 0x01, 0xa0, 0xb5, 0x18, 0x01, + 0x82, 0xd3, 0xe4, 0x93, 0x02, 0x11, 0x3a, 0x01, 0x2a, 0x22, 0x0c, 0x2f, 0x76, 0x31, 0x2f, 0x6c, + 0x6f, 0x63, 0x6b, 0x5f, 0x6d, 0x66, 0x61, 0x12, 0x8f, 0x01, 0x0a, 0x10, 0x45, 0x6d, 0x61, 0x69, + 0x6c, 0x4f, 0x74, 0x70, 0x4d, 0x66, 0x61, 0x53, 0x65, 0x74, 0x75, 0x70, 0x12, 0x26, 0x2e, 0x61, + 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x45, 0x6d, 0x61, + 0x69, 0x6c, 0x4f, 0x74, 0x70, 0x4d, 0x66, 0x61, 0x53, 0x65, 0x74, 0x75, 0x70, 0x52, 0x65, 0x71, + 0x75, 0x65, 0x73, 0x74, 0x1a, 0x27, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, + 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x45, 0x6d, 0x61, 0x69, 0x6c, 0x4f, 0x74, 0x70, 0x4d, 0x66, 0x61, + 0x53, 0x65, 0x74, 0x75, 0x70, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x2a, 0x98, + 0xb5, 0x18, 0x01, 0xa0, 0xb5, 0x18, 0x01, 0x82, 0xd3, 0xe4, 0x93, 0x02, 0x1c, 0x3a, 0x01, 0x2a, + 0x22, 0x17, 0x2f, 0x76, 0x31, 0x2f, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x5f, 0x6f, 0x74, 0x70, 0x5f, + 0x6d, 0x66, 0x61, 0x5f, 0x73, 0x65, 0x74, 0x75, 0x70, 0x12, 0x87, 0x01, 0x0a, 0x0e, 0x53, 0x6d, + 0x73, 0x4f, 0x74, 0x70, 0x4d, 0x66, 0x61, 0x53, 0x65, 0x74, 0x75, 0x70, 0x12, 0x24, 0x2e, 0x61, + 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x6d, 0x73, + 0x4f, 0x74, 0x70, 0x4d, 0x66, 0x61, 0x53, 0x65, 0x74, 0x75, 0x70, 0x52, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x1a, 0x25, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, + 0x76, 0x31, 0x2e, 0x53, 0x6d, 0x73, 0x4f, 0x74, 0x70, 0x4d, 0x66, 0x61, 0x53, 0x65, 0x74, 0x75, + 0x70, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x28, 0x98, 0xb5, 0x18, 0x01, 0xa0, + 0xb5, 0x18, 0x01, 0x82, 0xd3, 0xe4, 0x93, 0x02, 0x1a, 0x3a, 0x01, 0x2a, 0x22, 0x15, 0x2f, 0x76, + 0x31, 0x2f, 0x73, 0x6d, 0x73, 0x5f, 0x6f, 0x74, 0x70, 0x5f, 0x6d, 0x66, 0x61, 0x5f, 0x73, 0x65, + 0x74, 0x75, 0x70, 0x12, 0x81, 0x01, 0x0a, 0x0e, 0x46, 0x6f, 0x72, 0x67, 0x6f, 0x74, 0x50, 0x61, + 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x12, 0x24, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, + 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x46, 0x6f, 0x72, 0x67, 0x6f, 0x74, 0x50, 0x61, 0x73, + 0x73, 0x77, 0x6f, 0x72, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x25, 0x2e, 0x61, + 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x46, 0x6f, 0x72, + 0x67, 0x6f, 0x74, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x52, 0x65, 0x73, 0x70, 0x6f, + 0x6e, 0x73, 0x65, 0x22, 0x22, 0xa0, 0xb5, 0x18, 0x01, 0x82, 0xd3, 0xe4, 0x93, 0x02, 0x18, 0x3a, + 0x01, 0x2a, 0x22, 0x13, 0x2f, 0x76, 0x31, 0x2f, 0x66, 0x6f, 0x72, 0x67, 0x6f, 0x74, 0x5f, 0x70, + 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x12, 0x81, 0x01, 0x0a, 0x0d, 0x52, 0x65, 0x73, 0x65, + 0x74, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x12, 0x23, 0x2e, 0x61, 0x75, 0x74, 0x68, + 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x73, 0x65, 0x74, 0x50, + 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x24, + 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x52, + 0x65, 0x73, 0x65, 0x74, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x52, 0x65, 0x73, 0x70, + 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x25, 0x98, 0xb5, 0x18, 0x01, 0xa0, 0xb5, 0x18, 0x01, 0x82, 0xd3, + 0xe4, 0x93, 0x02, 0x17, 0x3a, 0x01, 0x2a, 0x22, 0x12, 0x2f, 0x76, 0x31, 0x2f, 0x72, 0x65, 0x73, + 0x65, 0x74, 0x5f, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x12, 0x58, 0x0a, 0x07, 0x50, + 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x12, 0x1d, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, + 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x50, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x52, 0x65, + 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x13, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, + 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x55, 0x73, 0x65, 0x72, 0x22, 0x19, 0x92, 0xb5, 0x18, 0x02, + 0x08, 0x01, 0x82, 0xd3, 0xe4, 0x93, 0x02, 0x0d, 0x12, 0x0b, 0x2f, 0x76, 0x31, 0x2f, 0x70, 0x72, + 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x12, 0x7d, 0x0a, 0x0d, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x50, + 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x12, 0x23, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, + 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x50, 0x72, 0x6f, + 0x66, 0x69, 0x6c, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x24, 0x2e, 0x61, 0x75, + 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x55, 0x70, 0x64, 0x61, + 0x74, 0x65, 0x50, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, + 0x65, 0x22, 0x21, 0x98, 0xb5, 0x18, 0x01, 0x82, 0xd3, 0xe4, 0x93, 0x02, 0x17, 0x3a, 0x01, 0x2a, + 0x22, 0x12, 0x2f, 0x76, 0x31, 0x2f, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x5f, 0x70, 0x72, 0x6f, + 0x66, 0x69, 0x6c, 0x65, 0x12, 0x93, 0x01, 0x0a, 0x11, 0x44, 0x65, 0x61, 0x63, 0x74, 0x69, 0x76, + 0x61, 0x74, 0x65, 0x41, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x27, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x44, 0x65, 0x61, 0x63, 0x74, - 0x69, 0x76, 0x61, 0x74, 0x65, 0x41, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x52, 0x65, 0x73, 0x70, - 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x2b, 0x92, 0xb5, 0x18, 0x02, 0x18, 0x01, 0x98, 0xb5, 0x18, 0x01, - 0x82, 0xd3, 0xe4, 0x93, 0x02, 0x1b, 0x3a, 0x01, 0x2a, 0x22, 0x16, 0x2f, 0x76, 0x31, 0x2f, 0x64, - 0x65, 0x61, 0x63, 0x74, 0x69, 0x76, 0x61, 0x74, 0x65, 0x5f, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, - 0x74, 0x12, 0x64, 0x0a, 0x06, 0x52, 0x65, 0x76, 0x6f, 0x6b, 0x65, 0x12, 0x1c, 0x2e, 0x61, 0x75, - 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x76, 0x6f, - 0x6b, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1d, 0x2e, 0x61, 0x75, 0x74, 0x68, - 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x76, 0x6f, 0x6b, 0x65, - 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x1d, 0x98, 0xb5, 0x18, 0x01, 0xa0, 0xb5, - 0x18, 0x01, 0x82, 0xd3, 0xe4, 0x93, 0x02, 0x0f, 0x3a, 0x01, 0x2a, 0x22, 0x0a, 0x2f, 0x76, 0x31, - 0x2f, 0x72, 0x65, 0x76, 0x6f, 0x6b, 0x65, 0x12, 0x5d, 0x0a, 0x07, 0x53, 0x65, 0x73, 0x73, 0x69, - 0x6f, 0x6e, 0x12, 0x1d, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, - 0x76, 0x31, 0x2e, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, - 0x74, 0x1a, 0x1b, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, - 0x31, 0x2e, 0x41, 0x75, 0x74, 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x16, - 0x82, 0xd3, 0xe4, 0x93, 0x02, 0x10, 0x3a, 0x01, 0x2a, 0x22, 0x0b, 0x2f, 0x76, 0x31, 0x2f, 0x73, - 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x8a, 0x01, 0x0a, 0x10, 0x56, 0x61, 0x6c, 0x69, 0x64, - 0x61, 0x74, 0x65, 0x4a, 0x77, 0x74, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x26, 0x2e, 0x61, 0x75, - 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x56, 0x61, 0x6c, 0x69, - 0x64, 0x61, 0x74, 0x65, 0x4a, 0x77, 0x74, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x52, 0x65, 0x71, 0x75, - 0x65, 0x73, 0x74, 0x1a, 0x27, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, - 0x2e, 0x76, 0x31, 0x2e, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x4a, 0x77, 0x74, 0x54, - 0x6f, 0x6b, 0x65, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x25, 0xa0, 0xb5, - 0x18, 0x01, 0x82, 0xd3, 0xe4, 0x93, 0x02, 0x1b, 0x3a, 0x01, 0x2a, 0x22, 0x16, 0x2f, 0x76, 0x31, - 0x2f, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x5f, 0x6a, 0x77, 0x74, 0x5f, 0x74, 0x6f, - 0x6b, 0x65, 0x6e, 0x12, 0x85, 0x01, 0x0a, 0x0f, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, - 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x25, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, + 0x69, 0x76, 0x61, 0x74, 0x65, 0x41, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x52, 0x65, 0x71, 0x75, + 0x65, 0x73, 0x74, 0x1a, 0x28, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, + 0x2e, 0x76, 0x31, 0x2e, 0x44, 0x65, 0x61, 0x63, 0x74, 0x69, 0x76, 0x61, 0x74, 0x65, 0x41, 0x63, + 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x2b, 0x92, + 0xb5, 0x18, 0x02, 0x18, 0x01, 0x98, 0xb5, 0x18, 0x01, 0x82, 0xd3, 0xe4, 0x93, 0x02, 0x1b, 0x3a, + 0x01, 0x2a, 0x22, 0x16, 0x2f, 0x76, 0x31, 0x2f, 0x64, 0x65, 0x61, 0x63, 0x74, 0x69, 0x76, 0x61, + 0x74, 0x65, 0x5f, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x64, 0x0a, 0x06, 0x52, 0x65, + 0x76, 0x6f, 0x6b, 0x65, 0x12, 0x1c, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, + 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x76, 0x6f, 0x6b, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x1a, 0x1d, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, + 0x76, 0x31, 0x2e, 0x52, 0x65, 0x76, 0x6f, 0x6b, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, + 0x65, 0x22, 0x1d, 0x98, 0xb5, 0x18, 0x01, 0xa0, 0xb5, 0x18, 0x01, 0x82, 0xd3, 0xe4, 0x93, 0x02, + 0x0f, 0x3a, 0x01, 0x2a, 0x22, 0x0a, 0x2f, 0x76, 0x31, 0x2f, 0x72, 0x65, 0x76, 0x6f, 0x6b, 0x65, + 0x12, 0x5d, 0x0a, 0x07, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x1d, 0x2e, 0x61, 0x75, + 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x65, 0x73, 0x73, + 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1b, 0x2e, 0x61, 0x75, 0x74, + 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x41, 0x75, 0x74, 0x68, 0x52, + 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x16, 0x82, 0xd3, 0xe4, 0x93, 0x02, 0x10, 0x3a, + 0x01, 0x2a, 0x22, 0x0b, 0x2f, 0x76, 0x31, 0x2f, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x12, + 0x8a, 0x01, 0x0a, 0x10, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x4a, 0x77, 0x74, 0x54, + 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x26, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, + 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x4a, 0x77, 0x74, + 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x27, 0x2e, 0x61, + 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x56, 0x61, 0x6c, + 0x69, 0x64, 0x61, 0x74, 0x65, 0x4a, 0x77, 0x74, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x52, 0x65, 0x73, + 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x25, 0xa0, 0xb5, 0x18, 0x01, 0x82, 0xd3, 0xe4, 0x93, 0x02, + 0x1b, 0x3a, 0x01, 0x2a, 0x22, 0x16, 0x2f, 0x76, 0x31, 0x2f, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x61, + 0x74, 0x65, 0x5f, 0x6a, 0x77, 0x74, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x85, 0x01, 0x0a, + 0x0f, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, + 0x12, 0x25, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, + 0x2e, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, + 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x26, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, - 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x26, - 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x56, - 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, - 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x23, 0xa0, 0xb5, 0x18, 0x01, 0x82, 0xd3, 0xe4, 0x93, - 0x02, 0x19, 0x3a, 0x01, 0x2a, 0x22, 0x14, 0x2f, 0x76, 0x31, 0x2f, 0x76, 0x61, 0x6c, 0x69, 0x64, - 0x61, 0x74, 0x65, 0x5f, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x53, 0x0a, 0x04, 0x4d, - 0x65, 0x74, 0x61, 0x12, 0x1a, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, - 0x2e, 0x76, 0x31, 0x2e, 0x4d, 0x65, 0x74, 0x61, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, - 0x13, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, - 0x4d, 0x65, 0x74, 0x61, 0x22, 0x1a, 0x92, 0xb5, 0x18, 0x02, 0x08, 0x01, 0xa0, 0xb5, 0x18, 0x01, - 0x82, 0xd3, 0xe4, 0x93, 0x02, 0x0a, 0x12, 0x08, 0x2f, 0x76, 0x31, 0x2f, 0x6d, 0x65, 0x74, 0x61, - 0x12, 0x8b, 0x01, 0x0a, 0x10, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x50, 0x65, 0x72, 0x6d, 0x69, 0x73, - 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x26, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, - 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x50, 0x65, 0x72, 0x6d, 0x69, - 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x27, 0x2e, - 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x68, - 0x65, 0x63, 0x6b, 0x50, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x52, 0x65, - 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x26, 0x92, 0xb5, 0x18, 0x02, 0x08, 0x01, 0x82, 0xd3, - 0xe4, 0x93, 0x02, 0x1a, 0x3a, 0x01, 0x2a, 0x22, 0x15, 0x2f, 0x76, 0x31, 0x2f, 0x63, 0x68, 0x65, - 0x63, 0x6b, 0x5f, 0x70, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x87, - 0x01, 0x0a, 0x0f, 0x4c, 0x69, 0x73, 0x74, 0x50, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, - 0x6e, 0x73, 0x12, 0x25, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, + 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, + 0x23, 0xa0, 0xb5, 0x18, 0x01, 0x82, 0xd3, 0xe4, 0x93, 0x02, 0x19, 0x3a, 0x01, 0x2a, 0x22, 0x14, + 0x2f, 0x76, 0x31, 0x2f, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x5f, 0x73, 0x65, 0x73, + 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x53, 0x0a, 0x04, 0x4d, 0x65, 0x74, 0x61, 0x12, 0x1a, 0x2e, 0x61, + 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x4d, 0x65, 0x74, + 0x61, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x13, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, + 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x4d, 0x65, 0x74, 0x61, 0x22, 0x1a, 0x92, + 0xb5, 0x18, 0x02, 0x08, 0x01, 0xa0, 0xb5, 0x18, 0x01, 0x82, 0xd3, 0xe4, 0x93, 0x02, 0x0a, 0x12, + 0x08, 0x2f, 0x76, 0x31, 0x2f, 0x6d, 0x65, 0x74, 0x61, 0x12, 0x8b, 0x01, 0x0a, 0x10, 0x43, 0x68, + 0x65, 0x63, 0x6b, 0x50, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x26, + 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x43, + 0x68, 0x65, 0x63, 0x6b, 0x50, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x52, + 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x27, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, + 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x50, 0x65, 0x72, 0x6d, + 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, + 0x26, 0x92, 0xb5, 0x18, 0x02, 0x08, 0x01, 0x82, 0xd3, 0xe4, 0x93, 0x02, 0x1a, 0x3a, 0x01, 0x2a, + 0x22, 0x15, 0x2f, 0x76, 0x31, 0x2f, 0x63, 0x68, 0x65, 0x63, 0x6b, 0x5f, 0x70, 0x65, 0x72, 0x6d, + 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x87, 0x01, 0x0a, 0x0f, 0x4c, 0x69, 0x73, 0x74, + 0x50, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x25, 0x2e, 0x61, 0x75, + 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x69, 0x73, 0x74, + 0x50, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x1a, 0x26, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x50, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, - 0x6e, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x26, 0x2e, 0x61, 0x75, 0x74, 0x68, - 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x50, 0x65, - 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, - 0x65, 0x22, 0x25, 0x92, 0xb5, 0x18, 0x02, 0x08, 0x01, 0x82, 0xd3, 0xe4, 0x93, 0x02, 0x19, 0x3a, - 0x01, 0x2a, 0x22, 0x14, 0x2f, 0x76, 0x31, 0x2f, 0x6c, 0x69, 0x73, 0x74, 0x5f, 0x70, 0x65, 0x72, - 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x42, 0xc0, 0x01, 0x0a, 0x11, 0x63, 0x6f, 0x6d, - 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x42, 0x0f, - 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 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, + 0x6e, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x25, 0x92, 0xb5, 0x18, 0x02, + 0x08, 0x01, 0x82, 0xd3, 0xe4, 0x93, 0x02, 0x19, 0x3a, 0x01, 0x2a, 0x22, 0x14, 0x2f, 0x76, 0x31, + 0x2f, 0x6c, 0x69, 0x73, 0x74, 0x5f, 0x70, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, + 0x73, 0x42, 0xc0, 0x01, 0x0a, 0x11, 0x63, 0x6f, 0x6d, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, + 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x42, 0x0f, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, + 0x7a, 0x65, 0x72, 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 ( @@ -2535,7 +2968,7 @@ func file_authorizer_v1_authorizer_proto_rawDescGZIP() []byte { return file_authorizer_v1_authorizer_proto_rawDescData } -var file_authorizer_v1_authorizer_proto_msgTypes = make([]protoimpl.MessageInfo, 33) +var file_authorizer_v1_authorizer_proto_msgTypes = make([]protoimpl.MessageInfo, 40) var file_authorizer_v1_authorizer_proto_goTypes = []any{ (*SignupRequest)(nil), // 0: authorizer.v1.SignupRequest (*LoginRequest)(nil), // 1: authorizer.v1.LoginRequest @@ -2549,47 +2982,54 @@ var file_authorizer_v1_authorizer_proto_goTypes = []any{ (*VerifyOtpRequest)(nil), // 9: authorizer.v1.VerifyOtpRequest (*ResendOtpRequest)(nil), // 10: authorizer.v1.ResendOtpRequest (*ResendOtpResponse)(nil), // 11: authorizer.v1.ResendOtpResponse - (*ForgotPasswordRequest)(nil), // 12: authorizer.v1.ForgotPasswordRequest - (*ForgotPasswordResponse)(nil), // 13: authorizer.v1.ForgotPasswordResponse - (*ResetPasswordRequest)(nil), // 14: authorizer.v1.ResetPasswordRequest - (*ResetPasswordResponse)(nil), // 15: authorizer.v1.ResetPasswordResponse - (*ProfileRequest)(nil), // 16: authorizer.v1.ProfileRequest - (*UpdateProfileRequest)(nil), // 17: authorizer.v1.UpdateProfileRequest - (*UpdateProfileResponse)(nil), // 18: authorizer.v1.UpdateProfileResponse - (*DeactivateAccountRequest)(nil), // 19: authorizer.v1.DeactivateAccountRequest - (*DeactivateAccountResponse)(nil), // 20: authorizer.v1.DeactivateAccountResponse - (*RevokeRequest)(nil), // 21: authorizer.v1.RevokeRequest - (*RevokeResponse)(nil), // 22: authorizer.v1.RevokeResponse - (*SessionRequest)(nil), // 23: authorizer.v1.SessionRequest - (*ValidateJwtTokenRequest)(nil), // 24: authorizer.v1.ValidateJwtTokenRequest - (*ValidateJwtTokenResponse)(nil), // 25: authorizer.v1.ValidateJwtTokenResponse - (*ValidateSessionRequest)(nil), // 26: authorizer.v1.ValidateSessionRequest - (*ValidateSessionResponse)(nil), // 27: authorizer.v1.ValidateSessionResponse - (*MetaRequest)(nil), // 28: authorizer.v1.MetaRequest - (*CheckPermissionsRequest)(nil), // 29: authorizer.v1.CheckPermissionsRequest - (*CheckPermissionsResponse)(nil), // 30: authorizer.v1.CheckPermissionsResponse - (*ListPermissionsRequest)(nil), // 31: authorizer.v1.ListPermissionsRequest - (*ListPermissionsResponse)(nil), // 32: authorizer.v1.ListPermissionsResponse - (*AppData)(nil), // 33: authorizer.v1.AppData - (*FgaRelationInput)(nil), // 34: authorizer.v1.FgaRelationInput - (*User)(nil), // 35: authorizer.v1.User - (*PermissionCheckInput)(nil), // 36: authorizer.v1.PermissionCheckInput - (*PermissionCheckResult)(nil), // 37: authorizer.v1.PermissionCheckResult - (*Permission)(nil), // 38: authorizer.v1.Permission - (*AuthResponse)(nil), // 39: authorizer.v1.AuthResponse - (*Meta)(nil), // 40: authorizer.v1.Meta + (*SkipMfaSetupRequest)(nil), // 12: authorizer.v1.SkipMfaSetupRequest + (*LockMfaRequest)(nil), // 13: authorizer.v1.LockMfaRequest + (*LockMfaResponse)(nil), // 14: authorizer.v1.LockMfaResponse + (*EmailOtpMfaSetupRequest)(nil), // 15: authorizer.v1.EmailOtpMfaSetupRequest + (*EmailOtpMfaSetupResponse)(nil), // 16: authorizer.v1.EmailOtpMfaSetupResponse + (*SmsOtpMfaSetupRequest)(nil), // 17: authorizer.v1.SmsOtpMfaSetupRequest + (*SmsOtpMfaSetupResponse)(nil), // 18: authorizer.v1.SmsOtpMfaSetupResponse + (*ForgotPasswordRequest)(nil), // 19: authorizer.v1.ForgotPasswordRequest + (*ForgotPasswordResponse)(nil), // 20: authorizer.v1.ForgotPasswordResponse + (*ResetPasswordRequest)(nil), // 21: authorizer.v1.ResetPasswordRequest + (*ResetPasswordResponse)(nil), // 22: authorizer.v1.ResetPasswordResponse + (*ProfileRequest)(nil), // 23: authorizer.v1.ProfileRequest + (*UpdateProfileRequest)(nil), // 24: authorizer.v1.UpdateProfileRequest + (*UpdateProfileResponse)(nil), // 25: authorizer.v1.UpdateProfileResponse + (*DeactivateAccountRequest)(nil), // 26: authorizer.v1.DeactivateAccountRequest + (*DeactivateAccountResponse)(nil), // 27: authorizer.v1.DeactivateAccountResponse + (*RevokeRequest)(nil), // 28: authorizer.v1.RevokeRequest + (*RevokeResponse)(nil), // 29: authorizer.v1.RevokeResponse + (*SessionRequest)(nil), // 30: authorizer.v1.SessionRequest + (*ValidateJwtTokenRequest)(nil), // 31: authorizer.v1.ValidateJwtTokenRequest + (*ValidateJwtTokenResponse)(nil), // 32: authorizer.v1.ValidateJwtTokenResponse + (*ValidateSessionRequest)(nil), // 33: authorizer.v1.ValidateSessionRequest + (*ValidateSessionResponse)(nil), // 34: authorizer.v1.ValidateSessionResponse + (*MetaRequest)(nil), // 35: authorizer.v1.MetaRequest + (*CheckPermissionsRequest)(nil), // 36: authorizer.v1.CheckPermissionsRequest + (*CheckPermissionsResponse)(nil), // 37: authorizer.v1.CheckPermissionsResponse + (*ListPermissionsRequest)(nil), // 38: authorizer.v1.ListPermissionsRequest + (*ListPermissionsResponse)(nil), // 39: authorizer.v1.ListPermissionsResponse + (*AppData)(nil), // 40: authorizer.v1.AppData + (*FgaRelationInput)(nil), // 41: authorizer.v1.FgaRelationInput + (*User)(nil), // 42: authorizer.v1.User + (*PermissionCheckInput)(nil), // 43: authorizer.v1.PermissionCheckInput + (*PermissionCheckResult)(nil), // 44: authorizer.v1.PermissionCheckResult + (*Permission)(nil), // 45: authorizer.v1.Permission + (*AuthResponse)(nil), // 46: authorizer.v1.AuthResponse + (*Meta)(nil), // 47: authorizer.v1.Meta } var file_authorizer_v1_authorizer_proto_depIdxs = []int32{ - 33, // 0: authorizer.v1.SignupRequest.app_data:type_name -> authorizer.v1.AppData - 33, // 1: authorizer.v1.UpdateProfileRequest.app_data:type_name -> authorizer.v1.AppData - 34, // 2: authorizer.v1.SessionRequest.required_relations:type_name -> authorizer.v1.FgaRelationInput - 34, // 3: authorizer.v1.ValidateJwtTokenRequest.required_relations:type_name -> authorizer.v1.FgaRelationInput - 33, // 4: authorizer.v1.ValidateJwtTokenResponse.claims:type_name -> authorizer.v1.AppData - 34, // 5: authorizer.v1.ValidateSessionRequest.required_relations:type_name -> authorizer.v1.FgaRelationInput - 35, // 6: authorizer.v1.ValidateSessionResponse.user:type_name -> authorizer.v1.User - 36, // 7: authorizer.v1.CheckPermissionsRequest.checks:type_name -> authorizer.v1.PermissionCheckInput - 37, // 8: authorizer.v1.CheckPermissionsResponse.results:type_name -> authorizer.v1.PermissionCheckResult - 38, // 9: authorizer.v1.ListPermissionsResponse.permissions:type_name -> authorizer.v1.Permission + 40, // 0: authorizer.v1.SignupRequest.app_data:type_name -> authorizer.v1.AppData + 40, // 1: authorizer.v1.UpdateProfileRequest.app_data:type_name -> authorizer.v1.AppData + 41, // 2: authorizer.v1.SessionRequest.required_relations:type_name -> authorizer.v1.FgaRelationInput + 41, // 3: authorizer.v1.ValidateJwtTokenRequest.required_relations:type_name -> authorizer.v1.FgaRelationInput + 40, // 4: authorizer.v1.ValidateJwtTokenResponse.claims:type_name -> authorizer.v1.AppData + 41, // 5: authorizer.v1.ValidateSessionRequest.required_relations:type_name -> authorizer.v1.FgaRelationInput + 42, // 6: authorizer.v1.ValidateSessionResponse.user:type_name -> authorizer.v1.User + 43, // 7: authorizer.v1.CheckPermissionsRequest.checks:type_name -> authorizer.v1.PermissionCheckInput + 44, // 8: authorizer.v1.CheckPermissionsResponse.results:type_name -> authorizer.v1.PermissionCheckResult + 45, // 9: authorizer.v1.ListPermissionsResponse.permissions:type_name -> authorizer.v1.Permission 0, // 10: authorizer.v1.AuthorizerService.Signup:input_type -> authorizer.v1.SignupRequest 1, // 11: authorizer.v1.AuthorizerService.Login:input_type -> authorizer.v1.LoginRequest 2, // 12: authorizer.v1.AuthorizerService.Logout:input_type -> authorizer.v1.LogoutRequest @@ -2598,40 +3038,48 @@ var file_authorizer_v1_authorizer_proto_depIdxs = []int32{ 7, // 15: authorizer.v1.AuthorizerService.ResendVerifyEmail:input_type -> authorizer.v1.ResendVerifyEmailRequest 9, // 16: authorizer.v1.AuthorizerService.VerifyOtp:input_type -> authorizer.v1.VerifyOtpRequest 10, // 17: authorizer.v1.AuthorizerService.ResendOtp:input_type -> authorizer.v1.ResendOtpRequest - 12, // 18: authorizer.v1.AuthorizerService.ForgotPassword:input_type -> authorizer.v1.ForgotPasswordRequest - 14, // 19: authorizer.v1.AuthorizerService.ResetPassword:input_type -> authorizer.v1.ResetPasswordRequest - 16, // 20: authorizer.v1.AuthorizerService.Profile:input_type -> authorizer.v1.ProfileRequest - 17, // 21: authorizer.v1.AuthorizerService.UpdateProfile:input_type -> authorizer.v1.UpdateProfileRequest - 19, // 22: authorizer.v1.AuthorizerService.DeactivateAccount:input_type -> authorizer.v1.DeactivateAccountRequest - 21, // 23: authorizer.v1.AuthorizerService.Revoke:input_type -> authorizer.v1.RevokeRequest - 23, // 24: authorizer.v1.AuthorizerService.Session:input_type -> authorizer.v1.SessionRequest - 24, // 25: authorizer.v1.AuthorizerService.ValidateJwtToken:input_type -> authorizer.v1.ValidateJwtTokenRequest - 26, // 26: authorizer.v1.AuthorizerService.ValidateSession:input_type -> authorizer.v1.ValidateSessionRequest - 28, // 27: authorizer.v1.AuthorizerService.Meta:input_type -> authorizer.v1.MetaRequest - 29, // 28: authorizer.v1.AuthorizerService.CheckPermissions:input_type -> authorizer.v1.CheckPermissionsRequest - 31, // 29: authorizer.v1.AuthorizerService.ListPermissions:input_type -> authorizer.v1.ListPermissionsRequest - 39, // 30: authorizer.v1.AuthorizerService.Signup:output_type -> authorizer.v1.AuthResponse - 39, // 31: authorizer.v1.AuthorizerService.Login:output_type -> authorizer.v1.AuthResponse - 3, // 32: authorizer.v1.AuthorizerService.Logout:output_type -> authorizer.v1.LogoutResponse - 5, // 33: authorizer.v1.AuthorizerService.MagicLinkLogin:output_type -> authorizer.v1.MagicLinkLoginResponse - 39, // 34: authorizer.v1.AuthorizerService.VerifyEmail:output_type -> authorizer.v1.AuthResponse - 8, // 35: authorizer.v1.AuthorizerService.ResendVerifyEmail:output_type -> authorizer.v1.ResendVerifyEmailResponse - 39, // 36: authorizer.v1.AuthorizerService.VerifyOtp:output_type -> authorizer.v1.AuthResponse - 11, // 37: authorizer.v1.AuthorizerService.ResendOtp:output_type -> authorizer.v1.ResendOtpResponse - 13, // 38: authorizer.v1.AuthorizerService.ForgotPassword:output_type -> authorizer.v1.ForgotPasswordResponse - 15, // 39: authorizer.v1.AuthorizerService.ResetPassword:output_type -> authorizer.v1.ResetPasswordResponse - 35, // 40: authorizer.v1.AuthorizerService.Profile:output_type -> authorizer.v1.User - 18, // 41: authorizer.v1.AuthorizerService.UpdateProfile:output_type -> authorizer.v1.UpdateProfileResponse - 20, // 42: authorizer.v1.AuthorizerService.DeactivateAccount:output_type -> authorizer.v1.DeactivateAccountResponse - 22, // 43: authorizer.v1.AuthorizerService.Revoke:output_type -> authorizer.v1.RevokeResponse - 39, // 44: authorizer.v1.AuthorizerService.Session:output_type -> authorizer.v1.AuthResponse - 25, // 45: authorizer.v1.AuthorizerService.ValidateJwtToken:output_type -> authorizer.v1.ValidateJwtTokenResponse - 27, // 46: authorizer.v1.AuthorizerService.ValidateSession:output_type -> authorizer.v1.ValidateSessionResponse - 40, // 47: authorizer.v1.AuthorizerService.Meta:output_type -> authorizer.v1.Meta - 30, // 48: authorizer.v1.AuthorizerService.CheckPermissions:output_type -> authorizer.v1.CheckPermissionsResponse - 32, // 49: authorizer.v1.AuthorizerService.ListPermissions:output_type -> authorizer.v1.ListPermissionsResponse - 30, // [30:50] is the sub-list for method output_type - 10, // [10:30] is the sub-list for method input_type + 12, // 18: authorizer.v1.AuthorizerService.SkipMfaSetup:input_type -> authorizer.v1.SkipMfaSetupRequest + 13, // 19: authorizer.v1.AuthorizerService.LockMfa:input_type -> authorizer.v1.LockMfaRequest + 15, // 20: authorizer.v1.AuthorizerService.EmailOtpMfaSetup:input_type -> authorizer.v1.EmailOtpMfaSetupRequest + 17, // 21: authorizer.v1.AuthorizerService.SmsOtpMfaSetup:input_type -> authorizer.v1.SmsOtpMfaSetupRequest + 19, // 22: authorizer.v1.AuthorizerService.ForgotPassword:input_type -> authorizer.v1.ForgotPasswordRequest + 21, // 23: authorizer.v1.AuthorizerService.ResetPassword:input_type -> authorizer.v1.ResetPasswordRequest + 23, // 24: authorizer.v1.AuthorizerService.Profile:input_type -> authorizer.v1.ProfileRequest + 24, // 25: authorizer.v1.AuthorizerService.UpdateProfile:input_type -> authorizer.v1.UpdateProfileRequest + 26, // 26: authorizer.v1.AuthorizerService.DeactivateAccount:input_type -> authorizer.v1.DeactivateAccountRequest + 28, // 27: authorizer.v1.AuthorizerService.Revoke:input_type -> authorizer.v1.RevokeRequest + 30, // 28: authorizer.v1.AuthorizerService.Session:input_type -> authorizer.v1.SessionRequest + 31, // 29: authorizer.v1.AuthorizerService.ValidateJwtToken:input_type -> authorizer.v1.ValidateJwtTokenRequest + 33, // 30: authorizer.v1.AuthorizerService.ValidateSession:input_type -> authorizer.v1.ValidateSessionRequest + 35, // 31: authorizer.v1.AuthorizerService.Meta:input_type -> authorizer.v1.MetaRequest + 36, // 32: authorizer.v1.AuthorizerService.CheckPermissions:input_type -> authorizer.v1.CheckPermissionsRequest + 38, // 33: authorizer.v1.AuthorizerService.ListPermissions:input_type -> authorizer.v1.ListPermissionsRequest + 46, // 34: authorizer.v1.AuthorizerService.Signup:output_type -> authorizer.v1.AuthResponse + 46, // 35: authorizer.v1.AuthorizerService.Login:output_type -> authorizer.v1.AuthResponse + 3, // 36: authorizer.v1.AuthorizerService.Logout:output_type -> authorizer.v1.LogoutResponse + 5, // 37: authorizer.v1.AuthorizerService.MagicLinkLogin:output_type -> authorizer.v1.MagicLinkLoginResponse + 46, // 38: authorizer.v1.AuthorizerService.VerifyEmail:output_type -> authorizer.v1.AuthResponse + 8, // 39: authorizer.v1.AuthorizerService.ResendVerifyEmail:output_type -> authorizer.v1.ResendVerifyEmailResponse + 46, // 40: authorizer.v1.AuthorizerService.VerifyOtp:output_type -> authorizer.v1.AuthResponse + 11, // 41: authorizer.v1.AuthorizerService.ResendOtp:output_type -> authorizer.v1.ResendOtpResponse + 46, // 42: authorizer.v1.AuthorizerService.SkipMfaSetup:output_type -> authorizer.v1.AuthResponse + 14, // 43: authorizer.v1.AuthorizerService.LockMfa:output_type -> authorizer.v1.LockMfaResponse + 16, // 44: authorizer.v1.AuthorizerService.EmailOtpMfaSetup:output_type -> authorizer.v1.EmailOtpMfaSetupResponse + 18, // 45: authorizer.v1.AuthorizerService.SmsOtpMfaSetup:output_type -> authorizer.v1.SmsOtpMfaSetupResponse + 20, // 46: authorizer.v1.AuthorizerService.ForgotPassword:output_type -> authorizer.v1.ForgotPasswordResponse + 22, // 47: authorizer.v1.AuthorizerService.ResetPassword:output_type -> authorizer.v1.ResetPasswordResponse + 42, // 48: authorizer.v1.AuthorizerService.Profile:output_type -> authorizer.v1.User + 25, // 49: authorizer.v1.AuthorizerService.UpdateProfile:output_type -> authorizer.v1.UpdateProfileResponse + 27, // 50: authorizer.v1.AuthorizerService.DeactivateAccount:output_type -> authorizer.v1.DeactivateAccountResponse + 29, // 51: authorizer.v1.AuthorizerService.Revoke:output_type -> authorizer.v1.RevokeResponse + 46, // 52: authorizer.v1.AuthorizerService.Session:output_type -> authorizer.v1.AuthResponse + 32, // 53: authorizer.v1.AuthorizerService.ValidateJwtToken:output_type -> authorizer.v1.ValidateJwtTokenResponse + 34, // 54: authorizer.v1.AuthorizerService.ValidateSession:output_type -> authorizer.v1.ValidateSessionResponse + 47, // 55: authorizer.v1.AuthorizerService.Meta:output_type -> authorizer.v1.Meta + 37, // 56: authorizer.v1.AuthorizerService.CheckPermissions:output_type -> authorizer.v1.CheckPermissionsResponse + 39, // 57: authorizer.v1.AuthorizerService.ListPermissions:output_type -> authorizer.v1.ListPermissionsResponse + 34, // [34:58] is the sub-list for method output_type + 10, // [10:34] is the sub-list for method input_type 10, // [10:10] is the sub-list for extension type_name 10, // [10:10] is the sub-list for extension extendee 0, // [0:10] is the sub-list for field type_name @@ -2645,14 +3093,14 @@ func file_authorizer_v1_authorizer_proto_init() { file_authorizer_v1_annotations_proto_init() file_authorizer_v1_common_proto_init() file_authorizer_v1_types_proto_init() - file_authorizer_v1_authorizer_proto_msgTypes[17].OneofWrappers = []any{} + file_authorizer_v1_authorizer_proto_msgTypes[24].OneofWrappers = []any{} type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: file_authorizer_v1_authorizer_proto_rawDesc, NumEnums: 0, - NumMessages: 33, + NumMessages: 40, NumExtensions: 0, NumServices: 1, }, diff --git a/gen/go/authorizer/v1/authorizer.pb.gw.go b/gen/go/authorizer/v1/authorizer.pb.gw.go index 17438927..c59817ed 100644 --- a/gen/go/authorizer/v1/authorizer.pb.gw.go +++ b/gen/go/authorizer/v1/authorizer.pb.gw.go @@ -231,6 +231,110 @@ func local_request_AuthorizerService_ResendOtp_0(ctx context.Context, marshaler } +func request_AuthorizerService_SkipMfaSetup_0(ctx context.Context, marshaler runtime.Marshaler, client AuthorizerServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var protoReq SkipMfaSetupRequest + var metadata runtime.ServerMetadata + + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && err != io.EOF { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + + msg, err := client.SkipMfaSetup(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err + +} + +func local_request_AuthorizerService_SkipMfaSetup_0(ctx context.Context, marshaler runtime.Marshaler, server AuthorizerServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var protoReq SkipMfaSetupRequest + var metadata runtime.ServerMetadata + + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && err != io.EOF { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + + msg, err := server.SkipMfaSetup(ctx, &protoReq) + return msg, metadata, err + +} + +func request_AuthorizerService_LockMfa_0(ctx context.Context, marshaler runtime.Marshaler, client AuthorizerServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var protoReq LockMfaRequest + var metadata runtime.ServerMetadata + + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && err != io.EOF { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + + msg, err := client.LockMfa(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err + +} + +func local_request_AuthorizerService_LockMfa_0(ctx context.Context, marshaler runtime.Marshaler, server AuthorizerServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var protoReq LockMfaRequest + var metadata runtime.ServerMetadata + + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && err != io.EOF { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + + msg, err := server.LockMfa(ctx, &protoReq) + return msg, metadata, err + +} + +func request_AuthorizerService_EmailOtpMfaSetup_0(ctx context.Context, marshaler runtime.Marshaler, client AuthorizerServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var protoReq EmailOtpMfaSetupRequest + var metadata runtime.ServerMetadata + + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && err != io.EOF { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + + msg, err := client.EmailOtpMfaSetup(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err + +} + +func local_request_AuthorizerService_EmailOtpMfaSetup_0(ctx context.Context, marshaler runtime.Marshaler, server AuthorizerServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var protoReq EmailOtpMfaSetupRequest + var metadata runtime.ServerMetadata + + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && err != io.EOF { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + + msg, err := server.EmailOtpMfaSetup(ctx, &protoReq) + return msg, metadata, err + +} + +func request_AuthorizerService_SmsOtpMfaSetup_0(ctx context.Context, marshaler runtime.Marshaler, client AuthorizerServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var protoReq SmsOtpMfaSetupRequest + var metadata runtime.ServerMetadata + + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && err != io.EOF { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + + msg, err := client.SmsOtpMfaSetup(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err + +} + +func local_request_AuthorizerService_SmsOtpMfaSetup_0(ctx context.Context, marshaler runtime.Marshaler, server AuthorizerServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var protoReq SmsOtpMfaSetupRequest + var metadata runtime.ServerMetadata + + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && err != io.EOF { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + + msg, err := server.SmsOtpMfaSetup(ctx, &protoReq) + return msg, metadata, err + +} + func request_AuthorizerService_ForgotPassword_0(ctx context.Context, marshaler runtime.Marshaler, client AuthorizerServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { var protoReq ForgotPasswordRequest var metadata runtime.ServerMetadata @@ -734,6 +838,106 @@ func RegisterAuthorizerServiceHandlerServer(ctx context.Context, mux *runtime.Se }) + mux.Handle("POST", pattern_AuthorizerService_SkipMfaSetup_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + var err error + var annotatedContext context.Context + annotatedContext, err = runtime.AnnotateIncomingContext(ctx, mux, req, "/authorizer.v1.AuthorizerService/SkipMfaSetup", runtime.WithHTTPPathPattern("/v1/skip_mfa_setup")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_AuthorizerService_SkipMfaSetup_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + + forward_AuthorizerService_SkipMfaSetup_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + + }) + + mux.Handle("POST", pattern_AuthorizerService_LockMfa_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + var err error + var annotatedContext context.Context + annotatedContext, err = runtime.AnnotateIncomingContext(ctx, mux, req, "/authorizer.v1.AuthorizerService/LockMfa", runtime.WithHTTPPathPattern("/v1/lock_mfa")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_AuthorizerService_LockMfa_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + + forward_AuthorizerService_LockMfa_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + + }) + + mux.Handle("POST", pattern_AuthorizerService_EmailOtpMfaSetup_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + var err error + var annotatedContext context.Context + annotatedContext, err = runtime.AnnotateIncomingContext(ctx, mux, req, "/authorizer.v1.AuthorizerService/EmailOtpMfaSetup", runtime.WithHTTPPathPattern("/v1/email_otp_mfa_setup")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_AuthorizerService_EmailOtpMfaSetup_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + + forward_AuthorizerService_EmailOtpMfaSetup_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + + }) + + mux.Handle("POST", pattern_AuthorizerService_SmsOtpMfaSetup_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + var err error + var annotatedContext context.Context + annotatedContext, err = runtime.AnnotateIncomingContext(ctx, mux, req, "/authorizer.v1.AuthorizerService/SmsOtpMfaSetup", runtime.WithHTTPPathPattern("/v1/sms_otp_mfa_setup")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_AuthorizerService_SmsOtpMfaSetup_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + + forward_AuthorizerService_SmsOtpMfaSetup_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + + }) + mux.Handle("POST", pattern_AuthorizerService_ForgotPassword_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { ctx, cancel := context.WithCancel(req.Context()) defer cancel() @@ -1251,6 +1455,94 @@ func RegisterAuthorizerServiceHandlerClient(ctx context.Context, mux *runtime.Se }) + mux.Handle("POST", pattern_AuthorizerService_SkipMfaSetup_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + var err error + var annotatedContext context.Context + annotatedContext, err = runtime.AnnotateContext(ctx, mux, req, "/authorizer.v1.AuthorizerService/SkipMfaSetup", runtime.WithHTTPPathPattern("/v1/skip_mfa_setup")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_AuthorizerService_SkipMfaSetup_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + + forward_AuthorizerService_SkipMfaSetup_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + + }) + + mux.Handle("POST", pattern_AuthorizerService_LockMfa_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + var err error + var annotatedContext context.Context + annotatedContext, err = runtime.AnnotateContext(ctx, mux, req, "/authorizer.v1.AuthorizerService/LockMfa", runtime.WithHTTPPathPattern("/v1/lock_mfa")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_AuthorizerService_LockMfa_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + + forward_AuthorizerService_LockMfa_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + + }) + + mux.Handle("POST", pattern_AuthorizerService_EmailOtpMfaSetup_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + var err error + var annotatedContext context.Context + annotatedContext, err = runtime.AnnotateContext(ctx, mux, req, "/authorizer.v1.AuthorizerService/EmailOtpMfaSetup", runtime.WithHTTPPathPattern("/v1/email_otp_mfa_setup")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_AuthorizerService_EmailOtpMfaSetup_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + + forward_AuthorizerService_EmailOtpMfaSetup_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + + }) + + mux.Handle("POST", pattern_AuthorizerService_SmsOtpMfaSetup_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + var err error + var annotatedContext context.Context + annotatedContext, err = runtime.AnnotateContext(ctx, mux, req, "/authorizer.v1.AuthorizerService/SmsOtpMfaSetup", runtime.WithHTTPPathPattern("/v1/sms_otp_mfa_setup")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_AuthorizerService_SmsOtpMfaSetup_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + + forward_AuthorizerService_SmsOtpMfaSetup_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + + }) + mux.Handle("POST", pattern_AuthorizerService_ForgotPassword_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { ctx, cancel := context.WithCancel(req.Context()) defer cancel() @@ -1535,6 +1827,14 @@ var ( pattern_AuthorizerService_ResendOtp_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"v1", "resend_otp"}, "")) + pattern_AuthorizerService_SkipMfaSetup_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"v1", "skip_mfa_setup"}, "")) + + pattern_AuthorizerService_LockMfa_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"v1", "lock_mfa"}, "")) + + pattern_AuthorizerService_EmailOtpMfaSetup_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"v1", "email_otp_mfa_setup"}, "")) + + pattern_AuthorizerService_SmsOtpMfaSetup_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"v1", "sms_otp_mfa_setup"}, "")) + pattern_AuthorizerService_ForgotPassword_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"v1", "forgot_password"}, "")) pattern_AuthorizerService_ResetPassword_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"v1", "reset_password"}, "")) @@ -1577,6 +1877,14 @@ var ( forward_AuthorizerService_ResendOtp_0 = runtime.ForwardResponseMessage + forward_AuthorizerService_SkipMfaSetup_0 = runtime.ForwardResponseMessage + + forward_AuthorizerService_LockMfa_0 = runtime.ForwardResponseMessage + + forward_AuthorizerService_EmailOtpMfaSetup_0 = runtime.ForwardResponseMessage + + forward_AuthorizerService_SmsOtpMfaSetup_0 = runtime.ForwardResponseMessage + forward_AuthorizerService_ForgotPassword_0 = runtime.ForwardResponseMessage forward_AuthorizerService_ResetPassword_0 = runtime.ForwardResponseMessage diff --git a/gen/go/authorizer/v1/authorizer_grpc.pb.go b/gen/go/authorizer/v1/authorizer_grpc.pb.go index 6ae658f3..079fab00 100644 --- a/gen/go/authorizer/v1/authorizer_grpc.pb.go +++ b/gen/go/authorizer/v1/authorizer_grpc.pb.go @@ -2,7 +2,8 @@ // public API. Method names match the GraphQL operation names 1:1 // (snake_case in GraphQL → PascalCase in proto): Signup, Login, // MagicLinkLogin, VerifyEmail, ResendVerifyEmail, ForgotPassword, -// ResetPassword, VerifyOtp, ResendOtp, UpdateProfile, DeactivateAccount, +// ResetPassword, VerifyOtp, ResendOtp, SkipMfaSetup, LockMfa, +// EmailOtpMfaSetup, SmsOtpMfaSetup, UpdateProfile, DeactivateAccount, // Revoke, Meta, Session, Profile, ValidateJwtToken, ValidateSession, // CheckPermissions, ListPermissions, Logout. // @@ -46,6 +47,10 @@ const ( AuthorizerService_ResendVerifyEmail_FullMethodName = "/authorizer.v1.AuthorizerService/ResendVerifyEmail" AuthorizerService_VerifyOtp_FullMethodName = "/authorizer.v1.AuthorizerService/VerifyOtp" AuthorizerService_ResendOtp_FullMethodName = "/authorizer.v1.AuthorizerService/ResendOtp" + AuthorizerService_SkipMfaSetup_FullMethodName = "/authorizer.v1.AuthorizerService/SkipMfaSetup" + AuthorizerService_LockMfa_FullMethodName = "/authorizer.v1.AuthorizerService/LockMfa" + AuthorizerService_EmailOtpMfaSetup_FullMethodName = "/authorizer.v1.AuthorizerService/EmailOtpMfaSetup" + AuthorizerService_SmsOtpMfaSetup_FullMethodName = "/authorizer.v1.AuthorizerService/SmsOtpMfaSetup" AuthorizerService_ForgotPassword_FullMethodName = "/authorizer.v1.AuthorizerService/ForgotPassword" AuthorizerService_ResetPassword_FullMethodName = "/authorizer.v1.AuthorizerService/ResetPassword" AuthorizerService_Profile_FullMethodName = "/authorizer.v1.AuthorizerService/Profile" @@ -80,6 +85,33 @@ type AuthorizerServiceClient interface { ResendVerifyEmail(ctx context.Context, in *ResendVerifyEmailRequest, opts ...grpc.CallOption) (*ResendVerifyEmailResponse, error) VerifyOtp(ctx context.Context, in *VerifyOtpRequest, opts ...grpc.CallOption) (*AuthResponse, error) ResendOtp(ctx context.Context, in *ResendOtpRequest, opts ...grpc.CallOption) (*ResendOtpResponse, error) + // SkipMfaSetup completes an in-progress, token-withheld MFA offer by + // recording that the caller explicitly declined it, then issues the + // access token that was withheld. Identified by the MFA session cookie + // (set when the offer screen was returned) plus email/phone_number to + // resolve the pending user — same identification pattern as VerifyOtp. + // Fails with FAILED_PRECONDITION if MFA is organization-enforced + // (enforce-mfa) — enforcement is never skippable. + SkipMfaSetup(ctx context.Context, in *SkipMfaSetupRequest, opts ...grpc.CallOption) (*AuthResponse, error) + // LockMfa records that the caller lost access to their only MFA + // factor(s). Only allowed when the caller has NO verified Email/SMS OTP + // fallback enrolled — if one exists, use it instead of locking. Does not + // issue a token; the account requires admin recovery afterward. + LockMfa(ctx context.Context, in *LockMfaRequest, opts ...grpc.CallOption) (*LockMfaResponse, error) + // EmailOtpMfaSetup sends a one-time code to the caller's own email and + // creates an unverified email-OTP MFA enrollment. Dual-mode permissions: + // (a) an authenticated caller (bearer token) — the settings-screen action + // for an ALREADY-logged-in user adding a second factor; the request body is + // unused in this mode. (b) a caller in the withheld first-time-offer + // state, with no bearer token yet — identified by the MFA session cookie + // plus email/phone_number, same pattern as SkipMfaSetup. Either mode + // reuses the same underlying Authenticator row once VerifyOtp marks it + // verified. + EmailOtpMfaSetup(ctx context.Context, in *EmailOtpMfaSetupRequest, opts ...grpc.CallOption) (*EmailOtpMfaSetupResponse, error) + // SmsOtpMfaSetup sends a one-time code to the caller's own phone number + // and creates an unverified SMS-OTP MFA enrollment. Same dual-mode + // permissions and relationship to VerifyOtp as EmailOtpMfaSetup. + SmsOtpMfaSetup(ctx context.Context, in *SmsOtpMfaSetupRequest, opts ...grpc.CallOption) (*SmsOtpMfaSetupResponse, error) ForgotPassword(ctx context.Context, in *ForgotPasswordRequest, opts ...grpc.CallOption) (*ForgotPasswordResponse, error) ResetPassword(ctx context.Context, in *ResetPasswordRequest, opts ...grpc.CallOption) (*ResetPasswordResponse, error) // Profile returns the authenticated user. @@ -203,6 +235,46 @@ func (c *authorizerServiceClient) ResendOtp(ctx context.Context, in *ResendOtpRe return out, nil } +func (c *authorizerServiceClient) SkipMfaSetup(ctx context.Context, in *SkipMfaSetupRequest, opts ...grpc.CallOption) (*AuthResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(AuthResponse) + err := c.cc.Invoke(ctx, AuthorizerService_SkipMfaSetup_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *authorizerServiceClient) LockMfa(ctx context.Context, in *LockMfaRequest, opts ...grpc.CallOption) (*LockMfaResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(LockMfaResponse) + err := c.cc.Invoke(ctx, AuthorizerService_LockMfa_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *authorizerServiceClient) EmailOtpMfaSetup(ctx context.Context, in *EmailOtpMfaSetupRequest, opts ...grpc.CallOption) (*EmailOtpMfaSetupResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(EmailOtpMfaSetupResponse) + err := c.cc.Invoke(ctx, AuthorizerService_EmailOtpMfaSetup_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *authorizerServiceClient) SmsOtpMfaSetup(ctx context.Context, in *SmsOtpMfaSetupRequest, opts ...grpc.CallOption) (*SmsOtpMfaSetupResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(SmsOtpMfaSetupResponse) + err := c.cc.Invoke(ctx, AuthorizerService_SmsOtpMfaSetup_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + func (c *authorizerServiceClient) ForgotPassword(ctx context.Context, in *ForgotPasswordRequest, opts ...grpc.CallOption) (*ForgotPasswordResponse, error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(ForgotPasswordResponse) @@ -343,6 +415,33 @@ type AuthorizerServiceServer interface { ResendVerifyEmail(context.Context, *ResendVerifyEmailRequest) (*ResendVerifyEmailResponse, error) VerifyOtp(context.Context, *VerifyOtpRequest) (*AuthResponse, error) ResendOtp(context.Context, *ResendOtpRequest) (*ResendOtpResponse, error) + // SkipMfaSetup completes an in-progress, token-withheld MFA offer by + // recording that the caller explicitly declined it, then issues the + // access token that was withheld. Identified by the MFA session cookie + // (set when the offer screen was returned) plus email/phone_number to + // resolve the pending user — same identification pattern as VerifyOtp. + // Fails with FAILED_PRECONDITION if MFA is organization-enforced + // (enforce-mfa) — enforcement is never skippable. + SkipMfaSetup(context.Context, *SkipMfaSetupRequest) (*AuthResponse, error) + // LockMfa records that the caller lost access to their only MFA + // factor(s). Only allowed when the caller has NO verified Email/SMS OTP + // fallback enrolled — if one exists, use it instead of locking. Does not + // issue a token; the account requires admin recovery afterward. + LockMfa(context.Context, *LockMfaRequest) (*LockMfaResponse, error) + // EmailOtpMfaSetup sends a one-time code to the caller's own email and + // creates an unverified email-OTP MFA enrollment. Dual-mode permissions: + // (a) an authenticated caller (bearer token) — the settings-screen action + // for an ALREADY-logged-in user adding a second factor; the request body is + // unused in this mode. (b) a caller in the withheld first-time-offer + // state, with no bearer token yet — identified by the MFA session cookie + // plus email/phone_number, same pattern as SkipMfaSetup. Either mode + // reuses the same underlying Authenticator row once VerifyOtp marks it + // verified. + EmailOtpMfaSetup(context.Context, *EmailOtpMfaSetupRequest) (*EmailOtpMfaSetupResponse, error) + // SmsOtpMfaSetup sends a one-time code to the caller's own phone number + // and creates an unverified SMS-OTP MFA enrollment. Same dual-mode + // permissions and relationship to VerifyOtp as EmailOtpMfaSetup. + SmsOtpMfaSetup(context.Context, *SmsOtpMfaSetupRequest) (*SmsOtpMfaSetupResponse, error) ForgotPassword(context.Context, *ForgotPasswordRequest) (*ForgotPasswordResponse, error) ResetPassword(context.Context, *ResetPasswordRequest) (*ResetPasswordResponse, error) // Profile returns the authenticated user. @@ -409,6 +508,18 @@ func (UnimplementedAuthorizerServiceServer) VerifyOtp(context.Context, *VerifyOt func (UnimplementedAuthorizerServiceServer) ResendOtp(context.Context, *ResendOtpRequest) (*ResendOtpResponse, error) { return nil, status.Errorf(codes.Unimplemented, "method ResendOtp not implemented") } +func (UnimplementedAuthorizerServiceServer) SkipMfaSetup(context.Context, *SkipMfaSetupRequest) (*AuthResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method SkipMfaSetup not implemented") +} +func (UnimplementedAuthorizerServiceServer) LockMfa(context.Context, *LockMfaRequest) (*LockMfaResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method LockMfa not implemented") +} +func (UnimplementedAuthorizerServiceServer) EmailOtpMfaSetup(context.Context, *EmailOtpMfaSetupRequest) (*EmailOtpMfaSetupResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method EmailOtpMfaSetup not implemented") +} +func (UnimplementedAuthorizerServiceServer) SmsOtpMfaSetup(context.Context, *SmsOtpMfaSetupRequest) (*SmsOtpMfaSetupResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method SmsOtpMfaSetup not implemented") +} func (UnimplementedAuthorizerServiceServer) ForgotPassword(context.Context, *ForgotPasswordRequest) (*ForgotPasswordResponse, error) { return nil, status.Errorf(codes.Unimplemented, "method ForgotPassword not implemented") } @@ -609,6 +720,78 @@ func _AuthorizerService_ResendOtp_Handler(srv interface{}, ctx context.Context, return interceptor(ctx, in, info, handler) } +func _AuthorizerService_SkipMfaSetup_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(SkipMfaSetupRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(AuthorizerServiceServer).SkipMfaSetup(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: AuthorizerService_SkipMfaSetup_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(AuthorizerServiceServer).SkipMfaSetup(ctx, req.(*SkipMfaSetupRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _AuthorizerService_LockMfa_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(LockMfaRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(AuthorizerServiceServer).LockMfa(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: AuthorizerService_LockMfa_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(AuthorizerServiceServer).LockMfa(ctx, req.(*LockMfaRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _AuthorizerService_EmailOtpMfaSetup_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(EmailOtpMfaSetupRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(AuthorizerServiceServer).EmailOtpMfaSetup(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: AuthorizerService_EmailOtpMfaSetup_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(AuthorizerServiceServer).EmailOtpMfaSetup(ctx, req.(*EmailOtpMfaSetupRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _AuthorizerService_SmsOtpMfaSetup_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(SmsOtpMfaSetupRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(AuthorizerServiceServer).SmsOtpMfaSetup(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: AuthorizerService_SmsOtpMfaSetup_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(AuthorizerServiceServer).SmsOtpMfaSetup(ctx, req.(*SmsOtpMfaSetupRequest)) + } + return interceptor(ctx, in, info, handler) +} + func _AuthorizerService_ForgotPassword_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { in := new(ForgotPasswordRequest) if err := dec(in); err != nil { @@ -864,6 +1047,22 @@ var AuthorizerService_ServiceDesc = grpc.ServiceDesc{ MethodName: "ResendOtp", Handler: _AuthorizerService_ResendOtp_Handler, }, + { + MethodName: "SkipMfaSetup", + Handler: _AuthorizerService_SkipMfaSetup_Handler, + }, + { + MethodName: "LockMfa", + Handler: _AuthorizerService_LockMfa_Handler, + }, + { + MethodName: "EmailOtpMfaSetup", + Handler: _AuthorizerService_EmailOtpMfaSetup_Handler, + }, + { + MethodName: "SmsOtpMfaSetup", + Handler: _AuthorizerService_SmsOtpMfaSetup_Handler, + }, { MethodName: "ForgotPassword", Handler: _AuthorizerService_ForgotPassword_Handler, diff --git a/gen/go/authorizer/v1/types.pb.go b/gen/go/authorizer/v1/types.pb.go index ded81f1b..36d4d32a 100644 --- a/gen/go/authorizer/v1/types.pb.go +++ b/gen/go/authorizer/v1/types.pb.go @@ -53,6 +53,10 @@ type User struct { IsMultiFactorAuthEnabled bool `protobuf:"varint,19,opt,name=is_multi_factor_auth_enabled,json=isMultiFactorAuthEnabled,proto3" json:"is_multi_factor_auth_enabled,omitempty"` // Free-form key/value bag — same as GraphQL `app_data: Map`. AppData *AppData `protobuf:"bytes,20,opt,name=app_data,json=appData,proto3" json:"app_data,omitempty"` + // Mirrors User.has_skipped_mfa_setup_at in GraphQL. 0 means never skipped. + HasSkippedMfaSetupAt int64 `protobuf:"varint,21,opt,name=has_skipped_mfa_setup_at,json=hasSkippedMfaSetupAt,proto3" json:"has_skipped_mfa_setup_at,omitempty"` + // Mirrors User.mfa_locked_at in GraphQL. 0 means not locked. + MfaLockedAt int64 `protobuf:"varint,22,opt,name=mfa_locked_at,json=mfaLockedAt,proto3" json:"mfa_locked_at,omitempty"` } func (x *User) Reset() { @@ -225,6 +229,20 @@ func (x *User) GetAppData() *AppData { return nil } +func (x *User) GetHasSkippedMfaSetupAt() int64 { + if x != nil { + return x.HasSkippedMfaSetupAt + } + return 0 +} + +func (x *User) GetMfaLockedAt() int64 { + if x != nil { + return x.MfaLockedAt + } + return 0 +} + // AuthResponse mirrors the GraphQL AuthResponse type. Returned (wrapped) by // every method that produces a session: Signup, Login, MagicLinkLogin, // VerifyEmail, VerifyOtp, Session. @@ -247,6 +265,14 @@ type AuthResponse struct { AuthenticatorScannerImage string `protobuf:"bytes,10,opt,name=authenticator_scanner_image,json=authenticatorScannerImage,proto3" json:"authenticator_scanner_image,omitempty"` AuthenticatorSecret string `protobuf:"bytes,11,opt,name=authenticator_secret,json=authenticatorSecret,proto3" json:"authenticator_secret,omitempty"` AuthenticatorRecoveryCodes []string `protobuf:"bytes,12,rep,name=authenticator_recovery_codes,json=authenticatorRecoveryCodes,proto3" json:"authenticator_recovery_codes,omitempty"` + // Mirrors AuthResponse.should_offer_webauthn_mfa_verify in GraphQL. + ShouldOfferWebauthnMfaVerify bool `protobuf:"varint,13,opt,name=should_offer_webauthn_mfa_verify,json=shouldOfferWebauthnMfaVerify,proto3" json:"should_offer_webauthn_mfa_verify,omitempty"` + // Mirrors AuthResponse.should_offer_webauthn_mfa_setup in GraphQL. + ShouldOfferWebauthnMfaSetup bool `protobuf:"varint,14,opt,name=should_offer_webauthn_mfa_setup,json=shouldOfferWebauthnMfaSetup,proto3" json:"should_offer_webauthn_mfa_setup,omitempty"` + // Mirrors AuthResponse.should_offer_email_otp_mfa_setup in GraphQL. + ShouldOfferEmailOtpMfaSetup bool `protobuf:"varint,15,opt,name=should_offer_email_otp_mfa_setup,json=shouldOfferEmailOtpMfaSetup,proto3" json:"should_offer_email_otp_mfa_setup,omitempty"` + // Mirrors AuthResponse.should_offer_sms_otp_mfa_setup in GraphQL. + ShouldOfferSmsOtpMfaSetup bool `protobuf:"varint,16,opt,name=should_offer_sms_otp_mfa_setup,json=shouldOfferSmsOtpMfaSetup,proto3" json:"should_offer_sms_otp_mfa_setup,omitempty"` } func (x *AuthResponse) Reset() { @@ -363,6 +389,34 @@ func (x *AuthResponse) GetAuthenticatorRecoveryCodes() []string { return nil } +func (x *AuthResponse) GetShouldOfferWebauthnMfaVerify() bool { + if x != nil { + return x.ShouldOfferWebauthnMfaVerify + } + return false +} + +func (x *AuthResponse) GetShouldOfferWebauthnMfaSetup() bool { + if x != nil { + return x.ShouldOfferWebauthnMfaSetup + } + return false +} + +func (x *AuthResponse) GetShouldOfferEmailOtpMfaSetup() bool { + if x != nil { + return x.ShouldOfferEmailOtpMfaSetup + } + return false +} + +func (x *AuthResponse) GetShouldOfferSmsOtpMfaSetup() bool { + if x != nil { + return x.ShouldOfferSmsOtpMfaSetup + } + return false +} + // Permission is one (object, relation) pair the subject holds: "subject has // `relation` on `object`". Mirrors the GraphQL Permission type. type Permission struct { @@ -697,6 +751,8 @@ type Meta struct { 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"` + // Mirrors Meta.is_mfa_enforced in GraphQL. + IsMfaEnforced bool `protobuf:"varint,26,opt,name=is_mfa_enforced,json=isMfaEnforced,proto3" json:"is_mfa_enforced,omitempty"` } func (x *Meta) Reset() { @@ -904,6 +960,13 @@ func (x *Meta) GetIsWebauthnEnabled() bool { return false } +func (x *Meta) GetIsMfaEnforced() bool { + if x != nil { + return x.IsMfaEnforced + } + return false +} + var File_authorizer_v1_types_proto protoreflect.FileDescriptor var file_authorizer_v1_types_proto_rawDesc = []byte{ @@ -911,7 +974,7 @@ var file_authorizer_v1_types_proto_rawDesc = []byte{ 0x74, 0x79, 0x70, 0x65, 0x73, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x0d, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x1a, 0x1a, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2f, 0x76, 0x31, 0x2f, 0x63, 0x6f, 0x6d, 0x6d, 0x6f, 0x6e, - 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0xc1, 0x05, 0x0a, 0x04, 0x55, 0x73, 0x65, 0x72, 0x12, + 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0x9d, 0x06, 0x0a, 0x04, 0x55, 0x73, 0x65, 0x72, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x14, 0x0a, 0x05, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x12, 0x25, 0x0a, 0x0e, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x5f, 0x76, @@ -955,174 +1018,200 @@ var file_authorizer_v1_types_proto_rawDesc = []byte{ 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x31, 0x0a, 0x08, 0x61, 0x70, 0x70, 0x5f, 0x64, 0x61, 0x74, 0x61, 0x18, 0x14, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x41, 0x70, 0x70, 0x44, 0x61, 0x74, - 0x61, 0x52, 0x07, 0x61, 0x70, 0x70, 0x44, 0x61, 0x74, 0x61, 0x22, 0xc1, 0x04, 0x0a, 0x0c, 0x41, - 0x75, 0x74, 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x6d, - 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x6d, 0x65, - 0x73, 0x73, 0x61, 0x67, 0x65, 0x12, 0x3e, 0x0a, 0x1c, 0x73, 0x68, 0x6f, 0x75, 0x6c, 0x64, 0x5f, - 0x73, 0x68, 0x6f, 0x77, 0x5f, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x5f, 0x6f, 0x74, 0x70, 0x5f, 0x73, - 0x63, 0x72, 0x65, 0x65, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x18, 0x73, 0x68, 0x6f, - 0x75, 0x6c, 0x64, 0x53, 0x68, 0x6f, 0x77, 0x45, 0x6d, 0x61, 0x69, 0x6c, 0x4f, 0x74, 0x70, 0x53, - 0x63, 0x72, 0x65, 0x65, 0x6e, 0x12, 0x40, 0x0a, 0x1d, 0x73, 0x68, 0x6f, 0x75, 0x6c, 0x64, 0x5f, - 0x73, 0x68, 0x6f, 0x77, 0x5f, 0x6d, 0x6f, 0x62, 0x69, 0x6c, 0x65, 0x5f, 0x6f, 0x74, 0x70, 0x5f, - 0x73, 0x63, 0x72, 0x65, 0x65, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x19, 0x73, 0x68, - 0x6f, 0x75, 0x6c, 0x64, 0x53, 0x68, 0x6f, 0x77, 0x4d, 0x6f, 0x62, 0x69, 0x6c, 0x65, 0x4f, 0x74, - 0x70, 0x53, 0x63, 0x72, 0x65, 0x65, 0x6e, 0x12, 0x35, 0x0a, 0x17, 0x73, 0x68, 0x6f, 0x75, 0x6c, - 0x64, 0x5f, 0x73, 0x68, 0x6f, 0x77, 0x5f, 0x74, 0x6f, 0x74, 0x70, 0x5f, 0x73, 0x63, 0x72, 0x65, - 0x65, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x14, 0x73, 0x68, 0x6f, 0x75, 0x6c, 0x64, - 0x53, 0x68, 0x6f, 0x77, 0x54, 0x6f, 0x74, 0x70, 0x53, 0x63, 0x72, 0x65, 0x65, 0x6e, 0x12, 0x21, - 0x0a, 0x0c, 0x61, 0x63, 0x63, 0x65, 0x73, 0x73, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x05, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x61, 0x63, 0x63, 0x65, 0x73, 0x73, 0x54, 0x6f, 0x6b, 0x65, - 0x6e, 0x12, 0x19, 0x0a, 0x08, 0x69, 0x64, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x06, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x07, 0x69, 0x64, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x23, 0x0a, 0x0d, - 0x72, 0x65, 0x66, 0x72, 0x65, 0x73, 0x68, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x07, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x0c, 0x72, 0x65, 0x66, 0x72, 0x65, 0x73, 0x68, 0x54, 0x6f, 0x6b, 0x65, - 0x6e, 0x12, 0x1d, 0x0a, 0x0a, 0x65, 0x78, 0x70, 0x69, 0x72, 0x65, 0x73, 0x5f, 0x69, 0x6e, 0x18, - 0x08, 0x20, 0x01, 0x28, 0x03, 0x52, 0x09, 0x65, 0x78, 0x70, 0x69, 0x72, 0x65, 0x73, 0x49, 0x6e, - 0x12, 0x27, 0x0a, 0x04, 0x75, 0x73, 0x65, 0x72, 0x18, 0x09, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x13, - 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x55, - 0x73, 0x65, 0x72, 0x52, 0x04, 0x75, 0x73, 0x65, 0x72, 0x12, 0x3e, 0x0a, 0x1b, 0x61, 0x75, 0x74, - 0x68, 0x65, 0x6e, 0x74, 0x69, 0x63, 0x61, 0x74, 0x6f, 0x72, 0x5f, 0x73, 0x63, 0x61, 0x6e, 0x6e, - 0x65, 0x72, 0x5f, 0x69, 0x6d, 0x61, 0x67, 0x65, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x09, 0x52, 0x19, - 0x61, 0x75, 0x74, 0x68, 0x65, 0x6e, 0x74, 0x69, 0x63, 0x61, 0x74, 0x6f, 0x72, 0x53, 0x63, 0x61, - 0x6e, 0x6e, 0x65, 0x72, 0x49, 0x6d, 0x61, 0x67, 0x65, 0x12, 0x31, 0x0a, 0x14, 0x61, 0x75, 0x74, - 0x68, 0x65, 0x6e, 0x74, 0x69, 0x63, 0x61, 0x74, 0x6f, 0x72, 0x5f, 0x73, 0x65, 0x63, 0x72, 0x65, - 0x74, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x09, 0x52, 0x13, 0x61, 0x75, 0x74, 0x68, 0x65, 0x6e, 0x74, - 0x69, 0x63, 0x61, 0x74, 0x6f, 0x72, 0x53, 0x65, 0x63, 0x72, 0x65, 0x74, 0x12, 0x40, 0x0a, 0x1c, - 0x61, 0x75, 0x74, 0x68, 0x65, 0x6e, 0x74, 0x69, 0x63, 0x61, 0x74, 0x6f, 0x72, 0x5f, 0x72, 0x65, - 0x63, 0x6f, 0x76, 0x65, 0x72, 0x79, 0x5f, 0x63, 0x6f, 0x64, 0x65, 0x73, 0x18, 0x0c, 0x20, 0x03, - 0x28, 0x09, 0x52, 0x1a, 0x61, 0x75, 0x74, 0x68, 0x65, 0x6e, 0x74, 0x69, 0x63, 0x61, 0x74, 0x6f, - 0x72, 0x52, 0x65, 0x63, 0x6f, 0x76, 0x65, 0x72, 0x79, 0x43, 0x6f, 0x64, 0x65, 0x73, 0x22, 0x40, - 0x0a, 0x0a, 0x50, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x16, 0x0a, 0x06, - 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x6f, 0x62, - 0x6a, 0x65, 0x63, 0x74, 0x12, 0x1a, 0x0a, 0x08, 0x72, 0x65, 0x6c, 0x61, 0x74, 0x69, 0x6f, 0x6e, - 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x72, 0x65, 0x6c, 0x61, 0x74, 0x69, 0x6f, 0x6e, - 0x22, 0x46, 0x0a, 0x10, 0x46, 0x67, 0x61, 0x52, 0x65, 0x6c, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x49, - 0x6e, 0x70, 0x75, 0x74, 0x12, 0x1a, 0x0a, 0x08, 0x72, 0x65, 0x6c, 0x61, 0x74, 0x69, 0x6f, 0x6e, - 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x72, 0x65, 0x6c, 0x61, 0x74, 0x69, 0x6f, 0x6e, - 0x12, 0x16, 0x0a, 0x06, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x06, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x22, 0x57, 0x0a, 0x0d, 0x46, 0x67, 0x61, 0x54, - 0x75, 0x70, 0x6c, 0x65, 0x49, 0x6e, 0x70, 0x75, 0x74, 0x12, 0x12, 0x0a, 0x04, 0x75, 0x73, 0x65, - 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x75, 0x73, 0x65, 0x72, 0x12, 0x1a, 0x0a, - 0x08, 0x72, 0x65, 0x6c, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x08, 0x72, 0x65, 0x6c, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x16, 0x0a, 0x06, 0x6f, 0x62, 0x6a, - 0x65, 0x63, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x6f, 0x62, 0x6a, 0x65, 0x63, - 0x74, 0x22, 0x95, 0x01, 0x0a, 0x14, 0x50, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, - 0x43, 0x68, 0x65, 0x63, 0x6b, 0x49, 0x6e, 0x70, 0x75, 0x74, 0x12, 0x1a, 0x0a, 0x08, 0x72, 0x65, - 0x6c, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x72, 0x65, - 0x6c, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x16, 0x0a, 0x06, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, - 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x12, 0x49, - 0x0a, 0x11, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x75, 0x61, 0x6c, 0x5f, 0x74, 0x75, 0x70, - 0x6c, 0x65, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x61, 0x75, 0x74, 0x68, - 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x46, 0x67, 0x61, 0x54, 0x75, 0x70, - 0x6c, 0x65, 0x49, 0x6e, 0x70, 0x75, 0x74, 0x52, 0x10, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, - 0x75, 0x61, 0x6c, 0x54, 0x75, 0x70, 0x6c, 0x65, 0x73, 0x22, 0x65, 0x0a, 0x15, 0x50, 0x65, 0x72, - 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x52, 0x65, 0x73, 0x75, - 0x6c, 0x74, 0x12, 0x1a, 0x0a, 0x08, 0x72, 0x65, 0x6c, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x01, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x72, 0x65, 0x6c, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x16, - 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, 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, - 0x12, 0x35, 0x0a, 0x17, 0x69, 0x73, 0x5f, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x5f, 0x6c, 0x6f, - 0x67, 0x69, 0x6e, 0x5f, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, - 0x08, 0x52, 0x14, 0x69, 0x73, 0x47, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x4c, 0x6f, 0x67, 0x69, 0x6e, - 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x39, 0x0a, 0x19, 0x69, 0x73, 0x5f, 0x66, 0x61, - 0x63, 0x65, 0x62, 0x6f, 0x6f, 0x6b, 0x5f, 0x6c, 0x6f, 0x67, 0x69, 0x6e, 0x5f, 0x65, 0x6e, 0x61, - 0x62, 0x6c, 0x65, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x16, 0x69, 0x73, 0x46, 0x61, - 0x63, 0x65, 0x62, 0x6f, 0x6f, 0x6b, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x45, 0x6e, 0x61, 0x62, 0x6c, - 0x65, 0x64, 0x12, 0x35, 0x0a, 0x17, 0x69, 0x73, 0x5f, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x5f, - 0x6c, 0x6f, 0x67, 0x69, 0x6e, 0x5f, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x05, 0x20, - 0x01, 0x28, 0x08, 0x52, 0x14, 0x69, 0x73, 0x47, 0x69, 0x74, 0x68, 0x75, 0x62, 0x4c, 0x6f, 0x67, - 0x69, 0x6e, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x39, 0x0a, 0x19, 0x69, 0x73, 0x5f, - 0x6c, 0x69, 0x6e, 0x6b, 0x65, 0x64, 0x69, 0x6e, 0x5f, 0x6c, 0x6f, 0x67, 0x69, 0x6e, 0x5f, 0x65, - 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x06, 0x20, 0x01, 0x28, 0x08, 0x52, 0x16, 0x69, 0x73, - 0x4c, 0x69, 0x6e, 0x6b, 0x65, 0x64, 0x69, 0x6e, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x45, 0x6e, 0x61, - 0x62, 0x6c, 0x65, 0x64, 0x12, 0x33, 0x0a, 0x16, 0x69, 0x73, 0x5f, 0x61, 0x70, 0x70, 0x6c, 0x65, - 0x5f, 0x6c, 0x6f, 0x67, 0x69, 0x6e, 0x5f, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x07, - 0x20, 0x01, 0x28, 0x08, 0x52, 0x13, 0x69, 0x73, 0x41, 0x70, 0x70, 0x6c, 0x65, 0x4c, 0x6f, 0x67, - 0x69, 0x6e, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x37, 0x0a, 0x18, 0x69, 0x73, 0x5f, - 0x64, 0x69, 0x73, 0x63, 0x6f, 0x72, 0x64, 0x5f, 0x6c, 0x6f, 0x67, 0x69, 0x6e, 0x5f, 0x65, 0x6e, - 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x08, 0x20, 0x01, 0x28, 0x08, 0x52, 0x15, 0x69, 0x73, 0x44, - 0x69, 0x73, 0x63, 0x6f, 0x72, 0x64, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x45, 0x6e, 0x61, 0x62, 0x6c, - 0x65, 0x64, 0x12, 0x37, 0x0a, 0x18, 0x69, 0x73, 0x5f, 0x74, 0x77, 0x69, 0x74, 0x74, 0x65, 0x72, - 0x5f, 0x6c, 0x6f, 0x67, 0x69, 0x6e, 0x5f, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x09, - 0x20, 0x01, 0x28, 0x08, 0x52, 0x15, 0x69, 0x73, 0x54, 0x77, 0x69, 0x74, 0x74, 0x65, 0x72, 0x4c, - 0x6f, 0x67, 0x69, 0x6e, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x3b, 0x0a, 0x1a, 0x69, - 0x73, 0x5f, 0x6d, 0x69, 0x63, 0x72, 0x6f, 0x73, 0x6f, 0x66, 0x74, 0x5f, 0x6c, 0x6f, 0x67, 0x69, - 0x6e, 0x5f, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x08, 0x52, - 0x17, 0x69, 0x73, 0x4d, 0x69, 0x63, 0x72, 0x6f, 0x73, 0x6f, 0x66, 0x74, 0x4c, 0x6f, 0x67, 0x69, - 0x6e, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x35, 0x0a, 0x17, 0x69, 0x73, 0x5f, 0x74, - 0x77, 0x69, 0x74, 0x63, 0x68, 0x5f, 0x6c, 0x6f, 0x67, 0x69, 0x6e, 0x5f, 0x65, 0x6e, 0x61, 0x62, - 0x6c, 0x65, 0x64, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x08, 0x52, 0x14, 0x69, 0x73, 0x54, 0x77, 0x69, - 0x74, 0x63, 0x68, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, - 0x35, 0x0a, 0x17, 0x69, 0x73, 0x5f, 0x72, 0x6f, 0x62, 0x6c, 0x6f, 0x78, 0x5f, 0x6c, 0x6f, 0x67, - 0x69, 0x6e, 0x5f, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x08, - 0x52, 0x14, 0x69, 0x73, 0x52, 0x6f, 0x62, 0x6c, 0x6f, 0x78, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x45, - 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x41, 0x0a, 0x1d, 0x69, 0x73, 0x5f, 0x65, 0x6d, 0x61, - 0x69, 0x6c, 0x5f, 0x76, 0x65, 0x72, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, - 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x0d, 0x20, 0x01, 0x28, 0x08, 0x52, 0x1a, 0x69, - 0x73, 0x45, 0x6d, 0x61, 0x69, 0x6c, 0x56, 0x65, 0x72, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x69, - 0x6f, 0x6e, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x45, 0x0a, 0x1f, 0x69, 0x73, 0x5f, - 0x62, 0x61, 0x73, 0x69, 0x63, 0x5f, 0x61, 0x75, 0x74, 0x68, 0x65, 0x6e, 0x74, 0x69, 0x63, 0x61, - 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x0e, 0x20, 0x01, - 0x28, 0x08, 0x52, 0x1c, 0x69, 0x73, 0x42, 0x61, 0x73, 0x69, 0x63, 0x41, 0x75, 0x74, 0x68, 0x65, + 0x61, 0x52, 0x07, 0x61, 0x70, 0x70, 0x44, 0x61, 0x74, 0x61, 0x12, 0x36, 0x0a, 0x18, 0x68, 0x61, + 0x73, 0x5f, 0x73, 0x6b, 0x69, 0x70, 0x70, 0x65, 0x64, 0x5f, 0x6d, 0x66, 0x61, 0x5f, 0x73, 0x65, + 0x74, 0x75, 0x70, 0x5f, 0x61, 0x74, 0x18, 0x15, 0x20, 0x01, 0x28, 0x03, 0x52, 0x14, 0x68, 0x61, + 0x73, 0x53, 0x6b, 0x69, 0x70, 0x70, 0x65, 0x64, 0x4d, 0x66, 0x61, 0x53, 0x65, 0x74, 0x75, 0x70, + 0x41, 0x74, 0x12, 0x22, 0x0a, 0x0d, 0x6d, 0x66, 0x61, 0x5f, 0x6c, 0x6f, 0x63, 0x6b, 0x65, 0x64, + 0x5f, 0x61, 0x74, 0x18, 0x16, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x6d, 0x66, 0x61, 0x4c, 0x6f, + 0x63, 0x6b, 0x65, 0x64, 0x41, 0x74, 0x22, 0xd9, 0x06, 0x0a, 0x0c, 0x41, 0x75, 0x74, 0x68, 0x52, + 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, + 0x67, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, + 0x65, 0x12, 0x3e, 0x0a, 0x1c, 0x73, 0x68, 0x6f, 0x75, 0x6c, 0x64, 0x5f, 0x73, 0x68, 0x6f, 0x77, + 0x5f, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x5f, 0x6f, 0x74, 0x70, 0x5f, 0x73, 0x63, 0x72, 0x65, 0x65, + 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x18, 0x73, 0x68, 0x6f, 0x75, 0x6c, 0x64, 0x53, + 0x68, 0x6f, 0x77, 0x45, 0x6d, 0x61, 0x69, 0x6c, 0x4f, 0x74, 0x70, 0x53, 0x63, 0x72, 0x65, 0x65, + 0x6e, 0x12, 0x40, 0x0a, 0x1d, 0x73, 0x68, 0x6f, 0x75, 0x6c, 0x64, 0x5f, 0x73, 0x68, 0x6f, 0x77, + 0x5f, 0x6d, 0x6f, 0x62, 0x69, 0x6c, 0x65, 0x5f, 0x6f, 0x74, 0x70, 0x5f, 0x73, 0x63, 0x72, 0x65, + 0x65, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x19, 0x73, 0x68, 0x6f, 0x75, 0x6c, 0x64, + 0x53, 0x68, 0x6f, 0x77, 0x4d, 0x6f, 0x62, 0x69, 0x6c, 0x65, 0x4f, 0x74, 0x70, 0x53, 0x63, 0x72, + 0x65, 0x65, 0x6e, 0x12, 0x35, 0x0a, 0x17, 0x73, 0x68, 0x6f, 0x75, 0x6c, 0x64, 0x5f, 0x73, 0x68, + 0x6f, 0x77, 0x5f, 0x74, 0x6f, 0x74, 0x70, 0x5f, 0x73, 0x63, 0x72, 0x65, 0x65, 0x6e, 0x18, 0x04, + 0x20, 0x01, 0x28, 0x08, 0x52, 0x14, 0x73, 0x68, 0x6f, 0x75, 0x6c, 0x64, 0x53, 0x68, 0x6f, 0x77, + 0x54, 0x6f, 0x74, 0x70, 0x53, 0x63, 0x72, 0x65, 0x65, 0x6e, 0x12, 0x21, 0x0a, 0x0c, 0x61, 0x63, + 0x63, 0x65, 0x73, 0x73, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x0b, 0x61, 0x63, 0x63, 0x65, 0x73, 0x73, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x19, 0x0a, + 0x08, 0x69, 0x64, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x07, 0x69, 0x64, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x23, 0x0a, 0x0d, 0x72, 0x65, 0x66, 0x72, + 0x65, 0x73, 0x68, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x0c, 0x72, 0x65, 0x66, 0x72, 0x65, 0x73, 0x68, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x1d, 0x0a, + 0x0a, 0x65, 0x78, 0x70, 0x69, 0x72, 0x65, 0x73, 0x5f, 0x69, 0x6e, 0x18, 0x08, 0x20, 0x01, 0x28, + 0x03, 0x52, 0x09, 0x65, 0x78, 0x70, 0x69, 0x72, 0x65, 0x73, 0x49, 0x6e, 0x12, 0x27, 0x0a, 0x04, + 0x75, 0x73, 0x65, 0x72, 0x18, 0x09, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x13, 0x2e, 0x61, 0x75, 0x74, + 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x55, 0x73, 0x65, 0x72, 0x52, + 0x04, 0x75, 0x73, 0x65, 0x72, 0x12, 0x3e, 0x0a, 0x1b, 0x61, 0x75, 0x74, 0x68, 0x65, 0x6e, 0x74, + 0x69, 0x63, 0x61, 0x74, 0x6f, 0x72, 0x5f, 0x73, 0x63, 0x61, 0x6e, 0x6e, 0x65, 0x72, 0x5f, 0x69, + 0x6d, 0x61, 0x67, 0x65, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x09, 0x52, 0x19, 0x61, 0x75, 0x74, 0x68, + 0x65, 0x6e, 0x74, 0x69, 0x63, 0x61, 0x74, 0x6f, 0x72, 0x53, 0x63, 0x61, 0x6e, 0x6e, 0x65, 0x72, + 0x49, 0x6d, 0x61, 0x67, 0x65, 0x12, 0x31, 0x0a, 0x14, 0x61, 0x75, 0x74, 0x68, 0x65, 0x6e, 0x74, + 0x69, 0x63, 0x61, 0x74, 0x6f, 0x72, 0x5f, 0x73, 0x65, 0x63, 0x72, 0x65, 0x74, 0x18, 0x0b, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x13, 0x61, 0x75, 0x74, 0x68, 0x65, 0x6e, 0x74, 0x69, 0x63, 0x61, 0x74, + 0x6f, 0x72, 0x53, 0x65, 0x63, 0x72, 0x65, 0x74, 0x12, 0x40, 0x0a, 0x1c, 0x61, 0x75, 0x74, 0x68, + 0x65, 0x6e, 0x74, 0x69, 0x63, 0x61, 0x74, 0x6f, 0x72, 0x5f, 0x72, 0x65, 0x63, 0x6f, 0x76, 0x65, + 0x72, 0x79, 0x5f, 0x63, 0x6f, 0x64, 0x65, 0x73, 0x18, 0x0c, 0x20, 0x03, 0x28, 0x09, 0x52, 0x1a, + 0x61, 0x75, 0x74, 0x68, 0x65, 0x6e, 0x74, 0x69, 0x63, 0x61, 0x74, 0x6f, 0x72, 0x52, 0x65, 0x63, + 0x6f, 0x76, 0x65, 0x72, 0x79, 0x43, 0x6f, 0x64, 0x65, 0x73, 0x12, 0x46, 0x0a, 0x20, 0x73, 0x68, + 0x6f, 0x75, 0x6c, 0x64, 0x5f, 0x6f, 0x66, 0x66, 0x65, 0x72, 0x5f, 0x77, 0x65, 0x62, 0x61, 0x75, + 0x74, 0x68, 0x6e, 0x5f, 0x6d, 0x66, 0x61, 0x5f, 0x76, 0x65, 0x72, 0x69, 0x66, 0x79, 0x18, 0x0d, + 0x20, 0x01, 0x28, 0x08, 0x52, 0x1c, 0x73, 0x68, 0x6f, 0x75, 0x6c, 0x64, 0x4f, 0x66, 0x66, 0x65, + 0x72, 0x57, 0x65, 0x62, 0x61, 0x75, 0x74, 0x68, 0x6e, 0x4d, 0x66, 0x61, 0x56, 0x65, 0x72, 0x69, + 0x66, 0x79, 0x12, 0x44, 0x0a, 0x1f, 0x73, 0x68, 0x6f, 0x75, 0x6c, 0x64, 0x5f, 0x6f, 0x66, 0x66, + 0x65, 0x72, 0x5f, 0x77, 0x65, 0x62, 0x61, 0x75, 0x74, 0x68, 0x6e, 0x5f, 0x6d, 0x66, 0x61, 0x5f, + 0x73, 0x65, 0x74, 0x75, 0x70, 0x18, 0x0e, 0x20, 0x01, 0x28, 0x08, 0x52, 0x1b, 0x73, 0x68, 0x6f, + 0x75, 0x6c, 0x64, 0x4f, 0x66, 0x66, 0x65, 0x72, 0x57, 0x65, 0x62, 0x61, 0x75, 0x74, 0x68, 0x6e, + 0x4d, 0x66, 0x61, 0x53, 0x65, 0x74, 0x75, 0x70, 0x12, 0x45, 0x0a, 0x20, 0x73, 0x68, 0x6f, 0x75, + 0x6c, 0x64, 0x5f, 0x6f, 0x66, 0x66, 0x65, 0x72, 0x5f, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x5f, 0x6f, + 0x74, 0x70, 0x5f, 0x6d, 0x66, 0x61, 0x5f, 0x73, 0x65, 0x74, 0x75, 0x70, 0x18, 0x0f, 0x20, 0x01, + 0x28, 0x08, 0x52, 0x1b, 0x73, 0x68, 0x6f, 0x75, 0x6c, 0x64, 0x4f, 0x66, 0x66, 0x65, 0x72, 0x45, + 0x6d, 0x61, 0x69, 0x6c, 0x4f, 0x74, 0x70, 0x4d, 0x66, 0x61, 0x53, 0x65, 0x74, 0x75, 0x70, 0x12, + 0x41, 0x0a, 0x1e, 0x73, 0x68, 0x6f, 0x75, 0x6c, 0x64, 0x5f, 0x6f, 0x66, 0x66, 0x65, 0x72, 0x5f, + 0x73, 0x6d, 0x73, 0x5f, 0x6f, 0x74, 0x70, 0x5f, 0x6d, 0x66, 0x61, 0x5f, 0x73, 0x65, 0x74, 0x75, + 0x70, 0x18, 0x10, 0x20, 0x01, 0x28, 0x08, 0x52, 0x19, 0x73, 0x68, 0x6f, 0x75, 0x6c, 0x64, 0x4f, + 0x66, 0x66, 0x65, 0x72, 0x53, 0x6d, 0x73, 0x4f, 0x74, 0x70, 0x4d, 0x66, 0x61, 0x53, 0x65, 0x74, + 0x75, 0x70, 0x22, 0x40, 0x0a, 0x0a, 0x50, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, + 0x12, 0x16, 0x0a, 0x06, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x06, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x12, 0x1a, 0x0a, 0x08, 0x72, 0x65, 0x6c, 0x61, + 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x72, 0x65, 0x6c, 0x61, + 0x74, 0x69, 0x6f, 0x6e, 0x22, 0x46, 0x0a, 0x10, 0x46, 0x67, 0x61, 0x52, 0x65, 0x6c, 0x61, 0x74, + 0x69, 0x6f, 0x6e, 0x49, 0x6e, 0x70, 0x75, 0x74, 0x12, 0x1a, 0x0a, 0x08, 0x72, 0x65, 0x6c, 0x61, + 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x72, 0x65, 0x6c, 0x61, + 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x16, 0x0a, 0x06, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x18, 0x02, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x22, 0x57, 0x0a, 0x0d, + 0x46, 0x67, 0x61, 0x54, 0x75, 0x70, 0x6c, 0x65, 0x49, 0x6e, 0x70, 0x75, 0x74, 0x12, 0x12, 0x0a, + 0x04, 0x75, 0x73, 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x75, 0x73, 0x65, + 0x72, 0x12, 0x1a, 0x0a, 0x08, 0x72, 0x65, 0x6c, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x08, 0x72, 0x65, 0x6c, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x16, 0x0a, + 0x06, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x6f, + 0x62, 0x6a, 0x65, 0x63, 0x74, 0x22, 0x95, 0x01, 0x0a, 0x14, 0x50, 0x65, 0x72, 0x6d, 0x69, 0x73, + 0x73, 0x69, 0x6f, 0x6e, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x49, 0x6e, 0x70, 0x75, 0x74, 0x12, 0x1a, + 0x0a, 0x08, 0x72, 0x65, 0x6c, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x08, 0x72, 0x65, 0x6c, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x16, 0x0a, 0x06, 0x6f, 0x62, + 0x6a, 0x65, 0x63, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x6f, 0x62, 0x6a, 0x65, + 0x63, 0x74, 0x12, 0x49, 0x0a, 0x11, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x75, 0x61, 0x6c, + 0x5f, 0x74, 0x75, 0x70, 0x6c, 0x65, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1c, 0x2e, + 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x46, 0x67, + 0x61, 0x54, 0x75, 0x70, 0x6c, 0x65, 0x49, 0x6e, 0x70, 0x75, 0x74, 0x52, 0x10, 0x63, 0x6f, 0x6e, + 0x74, 0x65, 0x78, 0x74, 0x75, 0x61, 0x6c, 0x54, 0x75, 0x70, 0x6c, 0x65, 0x73, 0x22, 0x65, 0x0a, + 0x15, 0x50, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x43, 0x68, 0x65, 0x63, 0x6b, + 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x12, 0x1a, 0x0a, 0x08, 0x72, 0x65, 0x6c, 0x61, 0x74, 0x69, + 0x6f, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x72, 0x65, 0x6c, 0x61, 0x74, 0x69, + 0x6f, 0x6e, 0x12, 0x16, 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, 0xa8, 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, 0x12, 0x35, 0x0a, 0x17, 0x69, 0x73, 0x5f, 0x67, 0x6f, 0x6f, 0x67, 0x6c, + 0x65, 0x5f, 0x6c, 0x6f, 0x67, 0x69, 0x6e, 0x5f, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, + 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x14, 0x69, 0x73, 0x47, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x4c, + 0x6f, 0x67, 0x69, 0x6e, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x39, 0x0a, 0x19, 0x69, + 0x73, 0x5f, 0x66, 0x61, 0x63, 0x65, 0x62, 0x6f, 0x6f, 0x6b, 0x5f, 0x6c, 0x6f, 0x67, 0x69, 0x6e, + 0x5f, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x16, + 0x69, 0x73, 0x46, 0x61, 0x63, 0x65, 0x62, 0x6f, 0x6f, 0x6b, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x45, + 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x35, 0x0a, 0x17, 0x69, 0x73, 0x5f, 0x67, 0x69, 0x74, + 0x68, 0x75, 0x62, 0x5f, 0x6c, 0x6f, 0x67, 0x69, 0x6e, 0x5f, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, + 0x64, 0x18, 0x05, 0x20, 0x01, 0x28, 0x08, 0x52, 0x14, 0x69, 0x73, 0x47, 0x69, 0x74, 0x68, 0x75, + 0x62, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x39, 0x0a, + 0x19, 0x69, 0x73, 0x5f, 0x6c, 0x69, 0x6e, 0x6b, 0x65, 0x64, 0x69, 0x6e, 0x5f, 0x6c, 0x6f, 0x67, + 0x69, 0x6e, 0x5f, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x06, 0x20, 0x01, 0x28, 0x08, + 0x52, 0x16, 0x69, 0x73, 0x4c, 0x69, 0x6e, 0x6b, 0x65, 0x64, 0x69, 0x6e, 0x4c, 0x6f, 0x67, 0x69, + 0x6e, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x33, 0x0a, 0x16, 0x69, 0x73, 0x5f, 0x61, + 0x70, 0x70, 0x6c, 0x65, 0x5f, 0x6c, 0x6f, 0x67, 0x69, 0x6e, 0x5f, 0x65, 0x6e, 0x61, 0x62, 0x6c, + 0x65, 0x64, 0x18, 0x07, 0x20, 0x01, 0x28, 0x08, 0x52, 0x13, 0x69, 0x73, 0x41, 0x70, 0x70, 0x6c, + 0x65, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x37, 0x0a, + 0x18, 0x69, 0x73, 0x5f, 0x64, 0x69, 0x73, 0x63, 0x6f, 0x72, 0x64, 0x5f, 0x6c, 0x6f, 0x67, 0x69, + 0x6e, 0x5f, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x08, 0x20, 0x01, 0x28, 0x08, 0x52, + 0x15, 0x69, 0x73, 0x44, 0x69, 0x73, 0x63, 0x6f, 0x72, 0x64, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x45, + 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x37, 0x0a, 0x18, 0x69, 0x73, 0x5f, 0x74, 0x77, 0x69, + 0x74, 0x74, 0x65, 0x72, 0x5f, 0x6c, 0x6f, 0x67, 0x69, 0x6e, 0x5f, 0x65, 0x6e, 0x61, 0x62, 0x6c, + 0x65, 0x64, 0x18, 0x09, 0x20, 0x01, 0x28, 0x08, 0x52, 0x15, 0x69, 0x73, 0x54, 0x77, 0x69, 0x74, + 0x74, 0x65, 0x72, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, + 0x3b, 0x0a, 0x1a, 0x69, 0x73, 0x5f, 0x6d, 0x69, 0x63, 0x72, 0x6f, 0x73, 0x6f, 0x66, 0x74, 0x5f, + 0x6c, 0x6f, 0x67, 0x69, 0x6e, 0x5f, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x0a, 0x20, + 0x01, 0x28, 0x08, 0x52, 0x17, 0x69, 0x73, 0x4d, 0x69, 0x63, 0x72, 0x6f, 0x73, 0x6f, 0x66, 0x74, + 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x35, 0x0a, 0x17, + 0x69, 0x73, 0x5f, 0x74, 0x77, 0x69, 0x74, 0x63, 0x68, 0x5f, 0x6c, 0x6f, 0x67, 0x69, 0x6e, 0x5f, + 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x08, 0x52, 0x14, 0x69, + 0x73, 0x54, 0x77, 0x69, 0x74, 0x63, 0x68, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x45, 0x6e, 0x61, 0x62, + 0x6c, 0x65, 0x64, 0x12, 0x35, 0x0a, 0x17, 0x69, 0x73, 0x5f, 0x72, 0x6f, 0x62, 0x6c, 0x6f, 0x78, + 0x5f, 0x6c, 0x6f, 0x67, 0x69, 0x6e, 0x5f, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x0c, + 0x20, 0x01, 0x28, 0x08, 0x52, 0x14, 0x69, 0x73, 0x52, 0x6f, 0x62, 0x6c, 0x6f, 0x78, 0x4c, 0x6f, + 0x67, 0x69, 0x6e, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x41, 0x0a, 0x1d, 0x69, 0x73, + 0x5f, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x5f, 0x76, 0x65, 0x72, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, + 0x69, 0x6f, 0x6e, 0x5f, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x0d, 0x20, 0x01, 0x28, + 0x08, 0x52, 0x1a, 0x69, 0x73, 0x45, 0x6d, 0x61, 0x69, 0x6c, 0x56, 0x65, 0x72, 0x69, 0x66, 0x69, + 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x45, 0x0a, + 0x1f, 0x69, 0x73, 0x5f, 0x62, 0x61, 0x73, 0x69, 0x63, 0x5f, 0x61, 0x75, 0x74, 0x68, 0x65, 0x6e, + 0x74, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, + 0x18, 0x0e, 0x20, 0x01, 0x28, 0x08, 0x52, 0x1c, 0x69, 0x73, 0x42, 0x61, 0x73, 0x69, 0x63, 0x41, + 0x75, 0x74, 0x68, 0x65, 0x6e, 0x74, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x61, + 0x62, 0x6c, 0x65, 0x64, 0x12, 0x3c, 0x0a, 0x1b, 0x69, 0x73, 0x5f, 0x6d, 0x61, 0x67, 0x69, 0x63, + 0x5f, 0x6c, 0x69, 0x6e, 0x6b, 0x5f, 0x6c, 0x6f, 0x67, 0x69, 0x6e, 0x5f, 0x65, 0x6e, 0x61, 0x62, + 0x6c, 0x65, 0x64, 0x18, 0x0f, 0x20, 0x01, 0x28, 0x08, 0x52, 0x17, 0x69, 0x73, 0x4d, 0x61, 0x67, + 0x69, 0x63, 0x4c, 0x69, 0x6e, 0x6b, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x45, 0x6e, 0x61, 0x62, 0x6c, + 0x65, 0x64, 0x12, 0x2b, 0x0a, 0x12, 0x69, 0x73, 0x5f, 0x73, 0x69, 0x67, 0x6e, 0x5f, 0x75, 0x70, + 0x5f, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x10, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0f, + 0x69, 0x73, 0x53, 0x69, 0x67, 0x6e, 0x55, 0x70, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, + 0x3b, 0x0a, 0x1a, 0x69, 0x73, 0x5f, 0x73, 0x74, 0x72, 0x6f, 0x6e, 0x67, 0x5f, 0x70, 0x61, 0x73, + 0x73, 0x77, 0x6f, 0x72, 0x64, 0x5f, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x11, 0x20, + 0x01, 0x28, 0x08, 0x52, 0x17, 0x69, 0x73, 0x53, 0x74, 0x72, 0x6f, 0x6e, 0x67, 0x50, 0x61, 0x73, + 0x73, 0x77, 0x6f, 0x72, 0x64, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x3e, 0x0a, 0x1c, + 0x69, 0x73, 0x5f, 0x6d, 0x75, 0x6c, 0x74, 0x69, 0x5f, 0x66, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x5f, + 0x61, 0x75, 0x74, 0x68, 0x5f, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x12, 0x20, 0x01, + 0x28, 0x08, 0x52, 0x18, 0x69, 0x73, 0x4d, 0x75, 0x6c, 0x74, 0x69, 0x46, 0x61, 0x63, 0x74, 0x6f, + 0x72, 0x41, 0x75, 0x74, 0x68, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x52, 0x0a, 0x26, + 0x69, 0x73, 0x5f, 0x6d, 0x6f, 0x62, 0x69, 0x6c, 0x65, 0x5f, 0x62, 0x61, 0x73, 0x69, 0x63, 0x5f, + 0x61, 0x75, 0x74, 0x68, 0x65, 0x6e, 0x74, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x65, + 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x13, 0x20, 0x01, 0x28, 0x08, 0x52, 0x22, 0x69, 0x73, + 0x4d, 0x6f, 0x62, 0x69, 0x6c, 0x65, 0x42, 0x61, 0x73, 0x69, 0x63, 0x41, 0x75, 0x74, 0x68, 0x65, 0x6e, 0x74, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, - 0x12, 0x3c, 0x0a, 0x1b, 0x69, 0x73, 0x5f, 0x6d, 0x61, 0x67, 0x69, 0x63, 0x5f, 0x6c, 0x69, 0x6e, - 0x6b, 0x5f, 0x6c, 0x6f, 0x67, 0x69, 0x6e, 0x5f, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, - 0x0f, 0x20, 0x01, 0x28, 0x08, 0x52, 0x17, 0x69, 0x73, 0x4d, 0x61, 0x67, 0x69, 0x63, 0x4c, 0x69, - 0x6e, 0x6b, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x2b, - 0x0a, 0x12, 0x69, 0x73, 0x5f, 0x73, 0x69, 0x67, 0x6e, 0x5f, 0x75, 0x70, 0x5f, 0x65, 0x6e, 0x61, - 0x62, 0x6c, 0x65, 0x64, 0x18, 0x10, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0f, 0x69, 0x73, 0x53, 0x69, - 0x67, 0x6e, 0x55, 0x70, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x3b, 0x0a, 0x1a, 0x69, - 0x73, 0x5f, 0x73, 0x74, 0x72, 0x6f, 0x6e, 0x67, 0x5f, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, - 0x64, 0x5f, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x11, 0x20, 0x01, 0x28, 0x08, 0x52, - 0x17, 0x69, 0x73, 0x53, 0x74, 0x72, 0x6f, 0x6e, 0x67, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, - 0x64, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x3e, 0x0a, 0x1c, 0x69, 0x73, 0x5f, 0x6d, - 0x75, 0x6c, 0x74, 0x69, 0x5f, 0x66, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x5f, 0x61, 0x75, 0x74, 0x68, - 0x5f, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x12, 0x20, 0x01, 0x28, 0x08, 0x52, 0x18, - 0x69, 0x73, 0x4d, 0x75, 0x6c, 0x74, 0x69, 0x46, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x41, 0x75, 0x74, - 0x68, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x52, 0x0a, 0x26, 0x69, 0x73, 0x5f, 0x6d, - 0x6f, 0x62, 0x69, 0x6c, 0x65, 0x5f, 0x62, 0x61, 0x73, 0x69, 0x63, 0x5f, 0x61, 0x75, 0x74, 0x68, - 0x65, 0x6e, 0x74, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x65, 0x6e, 0x61, 0x62, 0x6c, - 0x65, 0x64, 0x18, 0x13, 0x20, 0x01, 0x28, 0x08, 0x52, 0x22, 0x69, 0x73, 0x4d, 0x6f, 0x62, 0x69, - 0x6c, 0x65, 0x42, 0x61, 0x73, 0x69, 0x63, 0x41, 0x75, 0x74, 0x68, 0x65, 0x6e, 0x74, 0x69, 0x63, - 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x41, 0x0a, 0x1d, - 0x69, 0x73, 0x5f, 0x70, 0x68, 0x6f, 0x6e, 0x65, 0x5f, 0x76, 0x65, 0x72, 0x69, 0x66, 0x69, 0x63, - 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x14, 0x20, - 0x01, 0x28, 0x08, 0x52, 0x1a, 0x69, 0x73, 0x50, 0x68, 0x6f, 0x6e, 0x65, 0x56, 0x65, 0x72, 0x69, - 0x66, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, - 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, 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, + 0x12, 0x41, 0x0a, 0x1d, 0x69, 0x73, 0x5f, 0x70, 0x68, 0x6f, 0x6e, 0x65, 0x5f, 0x76, 0x65, 0x72, + 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, + 0x64, 0x18, 0x14, 0x20, 0x01, 0x28, 0x08, 0x52, 0x1a, 0x69, 0x73, 0x50, 0x68, 0x6f, 0x6e, 0x65, + 0x56, 0x65, 0x72, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x61, 0x62, + 0x6c, 0x65, 0x64, 0x12, 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, 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, 0x12, 0x26, 0x0a, 0x0f, 0x69, 0x73, 0x5f, 0x6d, 0x66, + 0x61, 0x5f, 0x65, 0x6e, 0x66, 0x6f, 0x72, 0x63, 0x65, 0x64, 0x18, 0x1a, 0x20, 0x01, 0x28, 0x08, + 0x52, 0x0d, 0x69, 0x73, 0x4d, 0x66, 0x61, 0x45, 0x6e, 0x66, 0x6f, 0x72, 0x63, 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 dbeca209..7c20feec 100644 --- a/gen/openapi/authorizer.swagger.json +++ b/gen/openapi/authorizer.swagger.json @@ -1495,6 +1495,39 @@ ] } }, + "/v1/email_otp_mfa_setup": { + "post": { + "summary": "EmailOtpMfaSetup sends a one-time code to the caller's own email and\ncreates an unverified email-OTP MFA enrollment. Dual-mode permissions:\n(a) an authenticated caller (bearer token) — the settings-screen action\nfor an ALREADY-logged-in user adding a second factor; the request body is\nunused in this mode. (b) a caller in the withheld first-time-offer\nstate, with no bearer token yet — identified by the MFA session cookie\nplus email/phone_number, same pattern as SkipMfaSetup. Either mode\nreuses the same underlying Authenticator row once VerifyOtp marks it\nverified.", + "operationId": "AuthorizerService_EmailOtpMfaSetup", + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "$ref": "#/definitions/v1EmailOtpMfaSetupResponse" + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "$ref": "#/definitions/rpcStatus" + } + } + }, + "parameters": [ + { + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/v1EmailOtpMfaSetupRequest" + } + } + ], + "tags": [ + "AuthorizerService" + ] + } + }, "/v1/forgot_password": { "post": { "operationId": "AuthorizerService_ForgotPassword", @@ -1560,6 +1593,39 @@ ] } }, + "/v1/lock_mfa": { + "post": { + "summary": "LockMfa records that the caller lost access to their only MFA\nfactor(s). Only allowed when the caller has NO verified Email/SMS OTP\nfallback enrolled — if one exists, use it instead of locking. Does not\nissue a token; the account requires admin recovery afterward.", + "operationId": "AuthorizerService_LockMfa", + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "$ref": "#/definitions/v1LockMfaResponse" + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "$ref": "#/definitions/rpcStatus" + } + } + }, + "parameters": [ + { + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/v1LockMfaRequest" + } + } + ], + "tags": [ + "AuthorizerService" + ] + } + }, "/v1/login": { "post": { "summary": "Login authenticates with email/phone + password.", @@ -1890,6 +1956,72 @@ ] } }, + "/v1/skip_mfa_setup": { + "post": { + "summary": "SkipMfaSetup completes an in-progress, token-withheld MFA offer by\nrecording that the caller explicitly declined it, then issues the\naccess token that was withheld. Identified by the MFA session cookie\n(set when the offer screen was returned) plus email/phone_number to\nresolve the pending user — same identification pattern as VerifyOtp.\nFails with FAILED_PRECONDITION if MFA is organization-enforced\n(enforce-mfa) — enforcement is never skippable.", + "operationId": "AuthorizerService_SkipMfaSetup", + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "$ref": "#/definitions/v1AuthResponse" + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "$ref": "#/definitions/rpcStatus" + } + } + }, + "parameters": [ + { + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/v1SkipMfaSetupRequest" + } + } + ], + "tags": [ + "AuthorizerService" + ] + } + }, + "/v1/sms_otp_mfa_setup": { + "post": { + "summary": "SmsOtpMfaSetup sends a one-time code to the caller's own phone number\nand creates an unverified SMS-OTP MFA enrollment. Same dual-mode\npermissions and relationship to VerifyOtp as EmailOtpMfaSetup.", + "operationId": "AuthorizerService_SmsOtpMfaSetup", + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "$ref": "#/definitions/v1SmsOtpMfaSetupResponse" + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "$ref": "#/definitions/rpcStatus" + } + } + }, + "parameters": [ + { + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/v1SmsOtpMfaSetupRequest" + } + } + ], + "tags": [ + "AuthorizerService" + ] + } + }, "/v1/update_profile": { "post": { "operationId": "AuthorizerService_UpdateProfile", @@ -2156,6 +2288,10 @@ }, "is_webauthn_enabled": { "type": "boolean" + }, + "is_mfa_enforced": { + "type": "boolean", + "description": "Mirrors Meta.is_mfa_enforced in GraphQL." } }, "description": "Meta mirrors the GraphQL Meta type — server feature flags + provider\navailability, returned by the Meta query." @@ -2231,6 +2367,16 @@ "app_data": { "$ref": "#/definitions/v1AppData", "description": "Free-form key/value bag — same as GraphQL `app_data: Map`." + }, + "has_skipped_mfa_setup_at": { + "type": "string", + "format": "int64", + "description": "Mirrors User.has_skipped_mfa_setup_at in GraphQL. 0 means never skipped." + }, + "mfa_locked_at": { + "type": "string", + "format": "int64", + "description": "Mirrors User.mfa_locked_at in GraphQL. 0 means not locked." } }, "description": "User mirrors the GraphQL User type. Returned by Profile and embedded in\nAuthResponse." @@ -2555,6 +2701,22 @@ "items": { "type": "string" } + }, + "should_offer_webauthn_mfa_verify": { + "type": "boolean", + "description": "Mirrors AuthResponse.should_offer_webauthn_mfa_verify in GraphQL." + }, + "should_offer_webauthn_mfa_setup": { + "type": "boolean", + "description": "Mirrors AuthResponse.should_offer_webauthn_mfa_setup in GraphQL." + }, + "should_offer_email_otp_mfa_setup": { + "type": "boolean", + "description": "Mirrors AuthResponse.should_offer_email_otp_mfa_setup in GraphQL." + }, + "should_offer_sms_otp_mfa_setup": { + "type": "boolean", + "description": "Mirrors AuthResponse.should_offer_sms_otp_mfa_setup in GraphQL." } }, "description": "AuthResponse mirrors the GraphQL AuthResponse type. Returned (wrapped) by\nevery method that produces a session: Signup, Login, MagicLinkLogin,\nVerifyEmail, VerifyOtp, Session." @@ -2774,6 +2936,26 @@ } } }, + "v1EmailOtpMfaSetupRequest": { + "type": "object", + "properties": { + "email": { + "type": "string", + "description": "Only used in the MFA-session-cookie mode (a caller in the withheld\nfirst-time-offer state, with no bearer token yet) to resolve which\nuser's MFA session cookie this is — same pattern as SkipMfaSetupRequest\n/ LockMfaRequest. Ignored when the caller has a valid bearer\ntoken/session, which already identifies the user." + }, + "phone_number": { + "type": "string" + } + } + }, + "v1EmailOtpMfaSetupResponse": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + } + }, "v1EmailTemplate": { "type": "object", "properties": { @@ -3211,6 +3393,26 @@ } } }, + "v1LockMfaRequest": { + "type": "object", + "properties": { + "email": { + "type": "string", + "description": "Either email or phone_number is required, to resolve which user's MFA\nsession cookie this is — same pattern as SkipMfaSetupRequest." + }, + "phone_number": { + "type": "string" + } + } + }, + "v1LockMfaResponse": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + } + }, "v1LoginRequest": { "type": "object", "properties": { @@ -3577,6 +3779,41 @@ } } }, + "v1SkipMfaSetupRequest": { + "type": "object", + "properties": { + "email": { + "type": "string", + "description": "Either email or phone_number is required, to resolve which user's MFA\nsession cookie this is." + }, + "phone_number": { + "type": "string" + }, + "state": { + "type": "string" + } + } + }, + "v1SmsOtpMfaSetupRequest": { + "type": "object", + "properties": { + "email": { + "type": "string", + "description": "Same dual-mode identification semantics as EmailOtpMfaSetupRequest." + }, + "phone_number": { + "type": "string" + } + } + }, + "v1SmsOtpMfaSetupResponse": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + } + }, "v1TestEndpointRequest": { "type": "object", "properties": { @@ -3906,6 +4143,10 @@ }, "app_data": { "$ref": "#/definitions/v1AppData" + }, + "reset_mfa": { + "type": "boolean", + "description": "Mirrors UpdateUserRequest.reset_mfa in GraphQL — see schema.graphqls'\ndoc comment on that field for exact semantics (clears mfa_locked_at,\nis_multi_factor_auth_enabled, has_skipped_mfa_setup_at, deletes all\nauthenticators/passkeys)." } }, "description": "UpdateUserRequest mirrors model.UpdateUserRequest. Nullable GraphQL inputs use\nproto3 `optional` so an unset field is distinguishable from an empty value\n(e.g. clearing a name vs leaving it untouched, or email_verified=false)." diff --git a/go.mod b/go.mod index 7bcb2317..58133fa8 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/authorizerdev/authorizer -go 1.26.4 +go 1.26.5 require ( buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260415201107-50325440f8f2.1 @@ -42,8 +42,8 @@ require ( github.com/twilio/twilio-go v1.14.1 github.com/vektah/gqlparser/v2 v2.5.26 go.mongodb.org/mongo-driver v1.17.9 - golang.org/x/crypto v0.52.0 - golang.org/x/net v0.55.0 + golang.org/x/crypto v0.53.0 + golang.org/x/net v0.56.0 golang.org/x/oauth2 v0.36.0 golang.org/x/sync v0.21.0 golang.org/x/time v0.15.0 @@ -200,10 +200,10 @@ require ( go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/arch v0.3.0 // indirect golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect - golang.org/x/mod v0.35.0 // indirect - golang.org/x/sys v0.45.0 // indirect - golang.org/x/text v0.37.0 // indirect - golang.org/x/tools v0.44.0 // indirect + golang.org/x/mod v0.37.0 // indirect + golang.org/x/sys v0.46.0 // indirect + golang.org/x/text v0.39.0 // indirect + golang.org/x/tools v0.47.0 // indirect gonum.org/v1/gonum v0.17.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect diff --git a/go.sum b/go.sum index c9a0635d..cd4f41eb 100644 --- a/go.sum +++ b/go.sum @@ -599,8 +599,8 @@ golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58 golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0= golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw= -golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= -golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= +golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= +golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM= golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80= @@ -613,8 +613,8 @@ golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= -golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= +golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -633,8 +633,8 @@ golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI= -golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= -golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= @@ -669,8 +669,8 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= -golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= @@ -686,8 +686,8 @@ golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= -golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= -golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus= +golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= @@ -703,8 +703,8 @@ golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4f golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= -golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= -golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= +golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= +golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/gqlgen.yml b/gqlgen.yml index 2258e419..2f8fa25c 100644 --- a/gqlgen.yml +++ b/gqlgen.yml @@ -69,4 +69,13 @@ models: - github.com/99designs/gqlgen/graphql.Map Any: model: - - github.com/99designs/gqlgen/graphql.Any \ No newline at end of file + - github.com/99designs/gqlgen/graphql.Any + # Force a per-field resolver for User.enrolled_mfa_methods so it is NOT bound + # onto the generated model.User struct. This keeps it lazily computed: gqlgen + # only invokes the resolver when a query selects the field, so hot paths that + # construct User (login, signup, profile, every AuthResponse) never trigger + # the extra storage reads it needs. + User: + fields: + enrolled_mfa_methods: + resolver: true \ No newline at end of file diff --git a/internal/authenticators/providers.go b/internal/authenticators/providers.go index 6c4b97d7..0053c6f0 100644 --- a/internal/authenticators/providers.go +++ b/internal/authenticators/providers.go @@ -6,6 +6,7 @@ import ( ac "github.com/authorizerdev/authorizer/internal/authenticators/config" "github.com/authorizerdev/authorizer/internal/authenticators/totp" "github.com/authorizerdev/authorizer/internal/config" + "github.com/authorizerdev/authorizer/internal/memory_store" "github.com/authorizerdev/authorizer/internal/storage" "github.com/rs/zerolog" ) @@ -14,6 +15,10 @@ import ( type Dependencies struct { Log *zerolog.Logger StorageProvider storage.Provider + // MemoryStoreProvider backs the transient pending-secret store used to make + // TOTP re-enrollment safe (a re-setup can't desync a working authenticator + // until the new code is confirmed). See totp.Generate/Validate. + MemoryStoreProvider memory_store.Provider } // Provider defines authenticators provider @@ -32,8 +37,9 @@ func New(cfg *config.Config, deps *Dependencies) (Provider, error) { return nil, nil } return totp.NewProvider(&totp.Dependencies{ - Log: deps.Log, - StorageProvider: deps.StorageProvider, - EncryptionKey: cfg.JWTSecret, + Log: deps.Log, + StorageProvider: deps.StorageProvider, + MemoryStoreProvider: deps.MemoryStoreProvider, + EncryptionKey: cfg.JWTSecret, }) } diff --git a/internal/authenticators/totp/provider.go b/internal/authenticators/totp/provider.go index b58f98a0..0690c422 100644 --- a/internal/authenticators/totp/provider.go +++ b/internal/authenticators/totp/provider.go @@ -3,12 +3,17 @@ package totp import ( "github.com/rs/zerolog" + "github.com/authorizerdev/authorizer/internal/memory_store" "github.com/authorizerdev/authorizer/internal/storage" ) type Dependencies struct { Log *zerolog.Logger StorageProvider storage.Provider + // MemoryStoreProvider holds transient, not-yet-confirmed TOTP secrets + // during a re-enrollment (see Generate/Validate). Deliberately out of the + // DB so an abandoned re-setup can never desync a working authenticator. + MemoryStoreProvider memory_store.Provider // EncryptionKey is the server-side key used to encrypt TOTP shared // secrets at rest. Wired to Config.JWTSecret in internal/authenticators. EncryptionKey string diff --git a/internal/authenticators/totp/totp.go b/internal/authenticators/totp/totp.go index fa3b45ad..72a2a25a 100644 --- a/internal/authenticators/totp/totp.go +++ b/internal/authenticators/totp/totp.go @@ -18,6 +18,70 @@ import ( "github.com/authorizerdev/authorizer/internal/storage/schemas" ) +const ( + // totpPendingSecretPrefix namespaces a not-yet-confirmed TOTP secret held + // in the memory store during a re-enrollment of an already-verified + // authenticator. Deliberately kept out of the DB schema (no storage + // provider needs a new column) — the same transient-state pattern the + // per-user TOTP lockout counter uses. + totpPendingSecretPrefix = "totp_pending_secret:" + // totpPendingSecretTTLSeconds bounds how long a generated-but-unconfirmed + // secret lingers before the user must restart the re-setup. + totpPendingSecretTTLSeconds = 10 * 60 +) + +// pendingTOTPSecret is the memory-store payload for a re-enrollment awaiting +// confirmation: the new at-rest-encrypted secret and its hashed recovery-codes +// blob, promoted to the live Authenticator row only once the user confirms the +// new code via Validate. +type pendingTOTPSecret struct { + Secret string `json:"secret"` + RecoveryCodes string `json:"recovery_codes"` +} + +func totpPendingSecretKey(userID string) string { + return totpPendingSecretPrefix + userID +} + +// promotePendingSecret checks whether a pending (unconfirmed) re-enrollment +// secret is staged for userID and whether passcode validates against it. When +// it does, this call IS the user confirming their re-setup: the pending secret +// is promoted to the live row (secret, recovery codes, VerifiedAt) and cleared +// from the store. Returns (true, nil) when promoted. Until this moment the old +// secret keeps validating (via the normal path in Validate), so an abandoned +// re-setup never locks anyone out. +func (p *provider) promotePendingSecret(ctx context.Context, totpModel *schemas.Authenticator, passcode, userID string) (bool, error) { + if p.deps.MemoryStoreProvider == nil { + return false, nil + } + blob, err := p.deps.MemoryStoreProvider.GetCache(totpPendingSecretKey(userID)) + if err != nil || blob == "" { + return false, nil + } + var pending pendingTOTPSecret + if err := json.Unmarshal([]byte(blob), &pending); err != nil { + return false, nil + } + plainSecret, err := crypto.DecryptTOTPSecret(pending.Secret, p.deps.EncryptionKey) + if err != nil { + return false, nil + } + if !totp.Validate(passcode, plainSecret) { + // Not the new code. The caller may be logging in with the still-live old + // secret; leave the pending secret staged for a later confirmation. + return false, nil + } + now := time.Now().Unix() + totpModel.Secret = pending.Secret + totpModel.RecoveryCodes = refs.NewStringRef(pending.RecoveryCodes) + totpModel.VerifiedAt = &now + if _, err := p.deps.StorageProvider.UpdateAuthenticator(ctx, totpModel); err != nil { + return false, err + } + _ = p.deps.MemoryStoreProvider.DeleteCacheByPrefix(totpPendingSecretKey(userID)) + return true, nil +} + // Generate generates a Time-Based One-Time Password (TOTP) for a user and returns the base64-encoded QR code for frontend display. func (p *provider) Generate(ctx context.Context, id string) (*config.AuthenticatorConfig, error) { log := p.deps.Log.With().Str("func", "Generate (totp provider)").Logger() @@ -81,18 +145,36 @@ func (p *provider) Generate(ctx context.Context, id string) (*config.Authenticat log.Debug().Err(err).Msg("error getting authenticator details") // continue } - if authenticator == nil { - // if authenticator is nil then create new authenticator - _, err = p.deps.StorageProvider.AddAuthenticator(ctx, totpModel) - if err != nil { + switch { + case authenticator != nil && authenticator.VerifiedAt != nil && p.deps.MemoryStoreProvider != nil: + // Re-enrollment of an ALREADY-VERIFIED authenticator. Do NOT overwrite + // the live secret/recovery-codes/VerifiedAt in place: that would desync + // the user's working authenticator app the instant they click "set up", + // even if they abandon the flow before confirming the new QR — a real + // account-lockout risk. Stash the new secret as pending instead; it is + // promoted to the live row only when the user confirms the new code via + // Validate. Kept in the memory store (not the DB) so an abandoned or + // lost re-setup self-heals: the pending secret simply expires and the + // existing authenticator keeps working. + blob, mErr := json.Marshal(pendingTOTPSecret{Secret: encryptedSecret, RecoveryCodes: recoveryCodesString}) + if mErr != nil { + return nil, mErr + } + if err = p.deps.MemoryStoreProvider.SetCache(totpPendingSecretKey(user.ID), string(blob), totpPendingSecretTTLSeconds); err != nil { + return nil, err + } + case authenticator == nil: + // First-time enrollment: no working authenticator to protect. + if _, err = p.deps.StorageProvider.AddAuthenticator(ctx, totpModel); err != nil { return nil, err } - } else { + default: + // An existing but UNVERIFIED row (a prior enrollment never confirmed, or + // a verified row with no memory store wired) — nothing enrolled to lose, + // so overwrite it in place. authenticator.Secret = encryptedSecret authenticator.RecoveryCodes = refs.NewStringRef(recoveryCodesString) - // if authenticator is not nil then update authenticator - _, err = p.deps.StorageProvider.UpdateAuthenticator(ctx, authenticator) - if err != nil { + if _, err = p.deps.StorageProvider.UpdateAuthenticator(ctx, authenticator); err != nil { return nil, err } } @@ -142,6 +224,17 @@ func (p *provider) Validate(ctx context.Context, passcode string, userID string) return false, err } + // A pending re-enrollment secret takes precedence: if one is staged and the + // supplied code matches it, promote it now (the user is confirming their + // re-setup). Checked before the live secret so a successful confirmation + // switches over atomically; a non-matching code falls through to validate + // against the still-live old secret below. + if promoted, pErr := p.promotePendingSecret(ctx, totpModel, passcode, userID); pErr != nil { + return false, pErr + } else if promoted { + return true, nil + } + var ( plainSecret string // migrate is set when the stored value is legacy plaintext AND diff --git a/internal/authorization/engine/openfga/openfga.go b/internal/authorization/engine/openfga/openfga.go index 5626524c..14a383d1 100644 --- a/internal/authorization/engine/openfga/openfga.go +++ b/internal/authorization/engine/openfga/openfga.go @@ -35,6 +35,12 @@ const ( StoreMySQL = "mysql" ) +// maxBatchCheckSize is the per-request BatchCheck cap. It must match (or +// exceed) service.maxPermissionChecks (100), which check_permissions advertises +// and enforces. OpenFGA defaults this to 50; leaving it there fails 51-100 +// check requests closed. +const maxBatchCheckSize = 100 + // Config holds the parameters needed to construct the embedded OpenFGA engine. // // StoreID and ModelID are the OpenFGA-assigned ULIDs. They normally stay @@ -108,7 +114,16 @@ func New(cfg *Config, deps *Dependencies) (engine.AuthorizationEngine, error) { return nil, err } - srv, err := server.NewServerWithOpts(server.WithDatastore(ds)) + // Raise the per-request BatchCheck cap to match the limit the + // check_permissions API advertises and enforces (service.maxPermissionChecks + // = 100). OpenFGA's default is 50, so without this a request of 51-100 + // checks clears the API guard then fails the whole batch closed inside + // BatchCheck. Kept as a literal here to avoid the engine depending on the + // service package — the two must stay in sync. + srv, err := server.NewServerWithOpts( + server.WithDatastore(ds), + server.WithMaxChecksPerBatchCheck(maxBatchCheckSize), + ) if err != nil { ds.Close() return nil, fmt.Errorf("openfga.New: NewServerWithOpts: %w", err) diff --git a/internal/authorization/engine/openfga/openfga_test.go b/internal/authorization/engine/openfga/openfga_test.go index 62bf8f94..8b19bc9f 100644 --- a/internal/authorization/engine/openfga/openfga_test.go +++ b/internal/authorization/engine/openfga/openfga_test.go @@ -114,6 +114,34 @@ func TestOpenFGAEngine_BatchCheck(t *testing.T) { assert.False(t, results[2].Allowed, "bob no grant") } +// TestOpenFGAEngine_BatchCheck_AtAdvertisedLimit guards the regression where the +// embedded server's default BatchCheck cap (50) sat below the 100 that +// check_permissions advertises/enforces (service.maxPermissionChecks), failing +// 51-100 check requests closed. maxBatchCheckSize now raises the server cap to +// match; a full-size batch must resolve, not error. +func TestOpenFGAEngine_BatchCheck_AtAdvertisedLimit(t *testing.T) { + ctx := context.Background() + eng, _ := newTestEngine(t) + + _, err := eng.WriteModel(ctx, testModel) + require.NoError(t, err) + require.NoError(t, eng.WriteTuples(ctx, []engine.TupleKey{ + {User: "user:alice", Relation: "viewer", Object: "document:1"}, + })) + + requests := make([]engine.CheckRequest, maxBatchCheckSize) + for i := range requests { + requests[i] = engine.CheckRequest{User: "user:alice", Relation: "can_view", Object: "document:1"} + } + + results, err := eng.BatchCheck(ctx, requests) + require.NoError(t, err, "a batch at the advertised limit must not exceed the server cap") + require.Len(t, results, maxBatchCheckSize) + for i, r := range results { + assert.True(t, r.Allowed, "check %d should be allowed", i) + } +} + func TestOpenFGAEngine_ReadWriteDeleteTuples(t *testing.T) { ctx := context.Background() eng, _ := newTestEngine(t) diff --git a/internal/config/config.go b/internal/config/config.go index 5681512b..f018ab57 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -167,6 +167,11 @@ type Config struct { EnableEmailOTP bool // EnableSMSOTP boolean to enable SMS OTP. Derived from DisableSMSOTP. EnableSMSOTP bool + // EnableWebauthnMFA is derived from DisableWebauthnMFA — whether a + // registered WebAuthn/passkey credential counts as an MFA factor. This is + // the ONLY WebAuthn-MFA flag; "webauthn" and "passkey" are the same + // credential type in this codebase (see spec), not two separate factors. + EnableWebauthnMFA bool // DisableTOTPLogin opts out of TOTP MFA (enabled by default). DisableTOTPLogin bool // DisableEmailOTP opts out of email OTP MFA (enabled by default when the @@ -175,11 +180,16 @@ type Config struct { // DisableSMSOTP opts out of SMS OTP MFA (enabled by default when the SMS // service is configured). DisableSMSOTP bool + // DisableWebauthnMFA opts out of WebAuthn/passkey as an MFA factor + // (enabled by default). Does not affect WebAuthn/passkey as a PRIMARY + // login method (the passkey button on the login screen) — that is a + // separate, pre-existing feature, untouched by this flag. + DisableWebauthnMFA bool // DisableMFA is a one-way global kill switch: when set, Finalize forces // EnableMFA and EnforceMFA off regardless of the per-method flags. It can // only ever turn MFA off, so unlike the removed --enable-mfa it cannot - // contradict the per-method flags. Does not affect WebAuthn/passkey, which - // is a separate login recipe. + // contradict the per-method flags. Does not affect WebAuthn/passkey as a + // PRIMARY login method — only its role as an MFA factor. DisableMFA bool // EnableSignup boolean to enable signup EnableSignup bool @@ -390,18 +400,21 @@ func (c *Config) Finalize() { c.EnableTOTPLogin = !c.DisableTOTPLogin c.EnableEmailOTP = !c.DisableEmailOTP c.EnableSMSOTP = !c.DisableSMSOTP + c.EnableWebauthnMFA = !c.DisableWebauthnMFA // MFA is available when at least one method is usable. Email/SMS OTP need // their provider configured; TOTP has no external dependency. Deriving this // (rather than a standalone --enable-mfa flag) prevents the state where MFA // is "enabled" while every method is unavailable. c.EnableMFA = c.EnableTOTPLogin || + c.EnableWebauthnMFA || (c.EnableEmailOTP && c.IsEmailServiceEnabled) || (c.EnableSMSOTP && c.IsSMSServiceEnabled) // One-way global kill switch. Wins over everything: no MFA challenge is // possible and enforcement is neutralized so signup cannot flag users for - // an MFA they can never complete. WebAuthn/passkey is unaffected. + // an MFA they can never complete. Does not affect WebAuthn/passkey as a + // primary login method — only its role as an MFA factor. if c.DisableMFA { c.EnableMFA = false c.EnforceMFA = false diff --git a/internal/config/config_finalize_test.go b/internal/config/config_finalize_test.go index a67014e7..b06bf49c 100644 --- a/internal/config/config_finalize_test.go +++ b/internal/config/config_finalize_test.go @@ -22,58 +22,79 @@ func withTwilio(c *Config) { // EnableMFA is the OR of the usable methods. func TestFinalizeMFADerivation(t *testing.T) { tests := []struct { - name string - setup func(*Config) - wantTOTP bool - wantEmail bool - wantSMS bool - wantMFA bool + name string + setup func(*Config) + wantTOTP bool + wantWebauthn bool + wantEmail bool + wantSMS bool + wantMFA bool }{ { - name: "defaults: TOTP on, no providers -> MFA available via TOTP", - setup: func(c *Config) {}, - wantTOTP: true, - wantEmail: true, // flag on, but email service unavailable - wantSMS: true, // flag on, but SMS service unavailable - wantMFA: true, // TOTP alone makes MFA available + name: "defaults: TOTP+WebAuthn on, no providers -> MFA available", + setup: func(c *Config) {}, + wantTOTP: true, + wantWebauthn: true, + wantEmail: true, // flag on, but email service unavailable + wantSMS: true, // flag on, but SMS service unavailable + wantMFA: true, // TOTP/WebAuthn alone make MFA available }, { - name: "TOTP disabled, no providers -> no MFA available", - setup: func(c *Config) { c.DisableTOTPLogin = true }, - wantTOTP: false, - wantEmail: true, - wantSMS: true, - wantMFA: false, // email/SMS on but neither provider configured + name: "TOTP+WebAuthn disabled, no providers -> no MFA available", + setup: func(c *Config) { c.DisableTOTPLogin = true; c.DisableWebauthnMFA = true }, + wantTOTP: false, + wantWebauthn: false, + wantEmail: true, + wantSMS: true, + wantMFA: false, // email/SMS on but neither provider configured }, { - name: "TOTP disabled, email service configured -> MFA via email OTP", - setup: func(c *Config) { c.DisableTOTPLogin = true; withSMTP(c) }, - wantTOTP: false, - wantEmail: true, - wantSMS: true, - wantMFA: true, + name: "TOTP disabled but WebAuthn on -> MFA still available", + setup: func(c *Config) { c.DisableTOTPLogin = true }, + wantTOTP: false, + wantWebauthn: true, + wantEmail: true, + wantSMS: true, + wantMFA: true, }, { - name: "TOTP+email disabled, SMS service configured -> MFA via SMS OTP", - setup: func(c *Config) { c.DisableTOTPLogin = true; c.DisableEmailOTP = true; withTwilio(c) }, - wantTOTP: false, - wantEmail: false, - wantSMS: true, - wantMFA: true, + name: "TOTP+WebAuthn disabled, email service configured -> MFA via email OTP", + setup: func(c *Config) { c.DisableTOTPLogin = true; c.DisableWebauthnMFA = true; withSMTP(c) }, + wantTOTP: false, + wantWebauthn: false, + wantEmail: true, + wantSMS: true, + wantMFA: true, + }, + { + name: "TOTP+WebAuthn+email disabled, SMS service configured -> MFA via SMS OTP", + setup: func(c *Config) { + c.DisableTOTPLogin = true + c.DisableWebauthnMFA = true + c.DisableEmailOTP = true + withTwilio(c) + }, + wantTOTP: false, + wantWebauthn: false, + wantEmail: false, + wantSMS: true, + wantMFA: true, }, { name: "all methods disabled -> no MFA even with providers configured", setup: func(c *Config) { c.DisableTOTPLogin = true + c.DisableWebauthnMFA = true c.DisableEmailOTP = true c.DisableSMSOTP = true withSMTP(c) withTwilio(c) }, - wantTOTP: false, - wantEmail: false, - wantSMS: false, - wantMFA: false, + wantTOTP: false, + wantWebauthn: false, + wantEmail: false, + wantSMS: false, + wantMFA: false, }, { name: "DisableMFA kill switch forces MFA off despite usable methods", @@ -82,10 +103,11 @@ func TestFinalizeMFADerivation(t *testing.T) { withSMTP(c) withTwilio(c) }, - wantTOTP: true, // per-method flags still derive true... - wantEmail: true, - wantSMS: true, - wantMFA: false, // ...but the kill switch forces overall MFA off + wantTOTP: true, // per-method flags still derive true... + wantWebauthn: true, + wantEmail: true, + wantSMS: true, + wantMFA: false, // ...but the kill switch forces overall MFA off }, } @@ -98,6 +120,9 @@ func TestFinalizeMFADerivation(t *testing.T) { if c.EnableTOTPLogin != tt.wantTOTP { t.Errorf("EnableTOTPLogin = %v, want %v", c.EnableTOTPLogin, tt.wantTOTP) } + if c.EnableWebauthnMFA != tt.wantWebauthn { + t.Errorf("EnableWebauthnMFA = %v, want %v", c.EnableWebauthnMFA, tt.wantWebauthn) + } if c.EnableEmailOTP != tt.wantEmail { t.Errorf("EnableEmailOTP = %v, want %v", c.EnableEmailOTP, tt.wantEmail) } diff --git a/internal/constants/audit_event.go b/internal/constants/audit_event.go index bef16c98..0cdebedb 100644 --- a/internal/constants/audit_event.go +++ b/internal/constants/audit_event.go @@ -80,6 +80,9 @@ const ( AuditMFAEnabledEvent = "user.mfa_enabled" // AuditMFADisabledEvent is logged when a user disables multi-factor authentication. AuditMFADisabledEvent = "user.mfa_disabled" + // AuditMFALockedEvent is logged when a user locks their own account + // after losing access to their MFA factor(s). + AuditMFALockedEvent = "user.mfa_locked" // AuditProfileUpdatedEvent is logged when a user updates their profile. AuditProfileUpdatedEvent = "user.profile_updated" // AuditUserDeactivatedEvent is logged when a user deactivates their account. diff --git a/internal/constants/authenticator_method.go b/internal/constants/authenticator_method.go index 1310db3f..de09cf80 100644 --- a/internal/constants/authenticator_method.go +++ b/internal/constants/authenticator_method.go @@ -4,4 +4,8 @@ package constants const ( // EnvKeyTOTPAuthenticator key for env variable TOTP EnvKeyTOTPAuthenticator = "totp" + // EnvKeyEmailOTPAuthenticator key for email OTP used as an MFA factor. + EnvKeyEmailOTPAuthenticator = "email_otp" + // EnvKeySMSOTPAuthenticator key for SMS OTP used as an MFA factor. + EnvKeySMSOTPAuthenticator = "sms_otp" ) diff --git a/internal/constants/mfa_session.go b/internal/constants/mfa_session.go new file mode 100644 index 00000000..fc135aad --- /dev/null +++ b/internal/constants/mfa_session.go @@ -0,0 +1,16 @@ +package constants + +// MFA session purposes tag how a short-lived MFA session (cookie + memory-store +// row keyed by user ID) was obtained, so a consumer that acts on the strength +// of a bare session can tell a first-factor-verified caller from one who only +// triggered an OTP send. +const ( + // MFASessionPurposeVerified means the caller already completed a first + // factor (password/passkey/social login) for this exact user, or this is + // the user's own just-created account. + MFASessionPurposeVerified = "verified" + // MFASessionPurposeChallenge means the caller only proved they can trigger + // an OTP send to this email/phone. NOT sufficient to skip MFA setup or lock + // the account. + MFASessionPurposeChallenge = "challenge" +) diff --git a/internal/cookie/cookie_test.go b/internal/cookie/cookie_test.go index e788faa5..6316ea12 100644 --- a/internal/cookie/cookie_test.go +++ b/internal/cookie/cookie_test.go @@ -3,6 +3,7 @@ package cookie import ( "net/http" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -10,6 +11,13 @@ import ( "github.com/authorizerdev/authorizer/internal/constants" ) +// mfaCookieTestExpiry is a fixed, comfortably-in-the-future expiry the MFA +// cookie tests use to compute an expected MaxAge without a flaky exact-second +// comparison against time.Now() inside the assertion. +func mfaCookieTestExpiry(d time.Duration) int64 { + return time.Now().Add(d).Unix() +} + func TestBuildSessionCookies(t *testing.T) { tests := []struct { name string @@ -46,21 +54,37 @@ func TestBuildSessionCookies(t *testing.T) { } func TestBuildMfaSessionCookies(t *testing.T) { - cookies := BuildMfaSessionCookies("https://auth.example.com", "mfa-id", true) + expiresAt := mfaCookieTestExpiry(3 * time.Minute) + cookies := BuildMfaSessionCookies("https://auth.example.com", "mfa-id", true, expiresAt) require.Len(t, cookies, 2) for _, c := range cookies { assert.Equal(t, "mfa-id", c.Value) assert.True(t, c.Secure) assert.True(t, c.HttpOnly) assert.Equal(t, http.SameSiteNoneMode, c.SameSite, "secure → SameSite=None") - assert.Equal(t, 60, c.MaxAge, "MFA cookies are short-lived (60s)") + assert.InDelta(t, 180, c.MaxAge, 2, "MaxAge must track the caller's actual session expiry, not a hardcoded value") } assert.Equal(t, constants.MfaCookieName+"_session", cookies[0].Name) assert.Equal(t, constants.MfaCookieName+"_session_domain", cookies[1].Name) } +// TestBuildMfaSessionCookies_MaxAgeTracksExpiry guards the fix for a real bug +// this caught: the cookie's MaxAge used to be hardcoded to 60s regardless of +// the expiresAt passed to MemoryStoreProvider.SetMfaSession (1-3 minutes +// depending on caller). A user who took longer than 60s to act on an MFA +// offer/verify screen would get "invalid session" even though the underlying +// session was still valid - the cookie carrying it to the browser had already +// been deleted. MaxAge must vary with expiresAt, not stay constant. +func TestBuildMfaSessionCookies_MaxAgeTracksExpiry(t *testing.T) { + oneMinute := BuildMfaSessionCookies("https://auth.example.com", "mfa-id", true, mfaCookieTestExpiry(1*time.Minute)) + threeMinutes := BuildMfaSessionCookies("https://auth.example.com", "mfa-id", true, mfaCookieTestExpiry(3*time.Minute)) + assert.InDelta(t, 60, oneMinute[0].MaxAge, 2) + assert.InDelta(t, 180, threeMinutes[0].MaxAge, 2) + assert.Greater(t, threeMinutes[0].MaxAge, oneMinute[0].MaxAge, "a longer session expiry must produce a longer-lived cookie") +} + func TestBuildMfaSessionCookies_InsecureLaxSameSite(t *testing.T) { - cookies := BuildMfaSessionCookies("http://localhost:8080", "mfa-id", false) + cookies := BuildMfaSessionCookies("http://localhost:8080", "mfa-id", false, mfaCookieTestExpiry(3*time.Minute)) require.Len(t, cookies, 2) for _, c := range cookies { assert.False(t, c.Secure) diff --git a/internal/cookie/mfa_session.go b/internal/cookie/mfa_session.go index 3ebe8bb2..51704b0e 100644 --- a/internal/cookie/mfa_session.go +++ b/internal/cookie/mfa_session.go @@ -3,6 +3,7 @@ package cookie import ( "net/http" "net/url" + "time" "github.com/gin-gonic/gin" @@ -10,9 +11,14 @@ import ( "github.com/authorizerdev/authorizer/internal/parsers" ) -// SetMfaSession sets the mfa session cookie in the response. -func SetMfaSession(gc *gin.Context, sessionID string, appCookieSecure bool) { - for _, c := range BuildMfaSessionCookies(parsers.GetHost(gc), sessionID, appCookieSecure) { +// SetMfaSession sets the mfa session cookie in the response. expiresAt is the +// same absolute Unix timestamp passed to MemoryStoreProvider.SetMfaSession - +// the cookie's MaxAge must match the underlying session's actual TTL, or the +// browser deletes the cookie before the session it points to expires (e.g. a +// user who takes over a minute to read an MFA offer screen and click "Skip +// for now" would get a valid session but a browser-deleted cookie). +func SetMfaSession(gc *gin.Context, sessionID string, appCookieSecure bool, expiresAt int64) { + for _, c := range BuildMfaSessionCookies(parsers.GetHost(gc), sessionID, appCookieSecure, expiresAt) { gc.SetSameSite(c.SameSite) gc.SetCookie(c.Name, c.Value, c.MaxAge, c.Path, c.Domain, c.Secure, c.HttpOnly) } @@ -20,12 +26,13 @@ func SetMfaSession(gc *gin.Context, sessionID string, appCookieSecure bool) { // BuildMfaSessionCookies returns the MFA session cookies (host-scoped and // domain-scoped) to set on the response. Transport-agnostic mirror of -// SetMfaSession. +// SetMfaSession. expiresAt must match the caller's MemoryStoreProvider. +// SetMfaSession call - see that function's doc comment for why. // // SameSite policy mirrors the gin path: Lax when insecure (so cross-site UI // can still complete the flow), None when secure. See the SetMfaSession // comment for the historical reasoning and the configurability TODO. -func BuildMfaSessionCookies(hostname, sessionID string, appCookieSecure bool) []*http.Cookie { +func BuildMfaSessionCookies(hostname, sessionID string, appCookieSecure bool, expiresAt int64) []*http.Cookie { host, _ := parsers.GetHostParts(hostname) domain := parsers.GetDomainName(hostname) if domain != "localhost" { @@ -35,8 +42,7 @@ func BuildMfaSessionCookies(hostname, sessionID string, appCookieSecure bool) [] if !appCookieSecure { sameSite = http.SameSiteLaxMode } - // TODO allow configuring cookie max-age via config - age := 60 + age := int(time.Until(time.Unix(expiresAt, 0)).Seconds()) return []*http.Cookie{ { Name: constants.MfaCookieName + "_session", diff --git a/internal/e2e/smoke_test.go b/internal/e2e/smoke_test.go index 948f8b4e..3daa5546 100644 --- a/internal/e2e/smoke_test.go +++ b/internal/e2e/smoke_test.go @@ -75,6 +75,11 @@ func TestReleaseSmoke(t *testing.T) { fmt.Sprintf("--http-port=%d", httpPort), fmt.Sprintf("--metrics-port=%d", metricsPort), fmt.Sprintf("--grpc-port=%d", grpcPort), + // This scenario exercises FGA permission checks, not MFA. MFA is on by + // default (TOTP/WebAuthn need no external provider), which would + // withhold signup's token behind the MFA-setup gate instead of + // returning it directly. + "--disable-mfa", } stopServer := startServer(t, bin, serverArgs, baseURL) diff --git a/internal/graph/generated/generated.go b/internal/graph/generated/generated.go index a188a9f2..eafb7990 100644 --- a/internal/graph/generated/generated.go +++ b/internal/graph/generated/generated.go @@ -40,6 +40,7 @@ type Config struct { type ResolverRoot interface { Mutation() MutationResolver Query() QueryResolver + User() UserResolver } type DirectiveRoot struct { @@ -81,7 +82,10 @@ type ComplexityRoot struct { IDToken func(childComplexity int) int Message func(childComplexity int) int RefreshToken func(childComplexity int) int + ShouldOfferEmailOtpMfaSetup func(childComplexity int) int ShouldOfferMfaSetup func(childComplexity int) int + ShouldOfferSmsOtpMfaSetup func(childComplexity int) int + ShouldOfferWebauthnMfaSetup func(childComplexity int) int ShouldOfferWebauthnMfaVerify func(childComplexity int) int ShouldShowEmailOtpScreen func(childComplexity int) int ShouldShowMobileOtpScreen func(childComplexity int) int @@ -313,6 +317,7 @@ type ComplexityRoot struct { DeleteTrustedIssuer func(childComplexity int, params model.TrustedIssuerRequest) int DeleteUser func(childComplexity int, params model.DeleteUserRequest) int DeleteWebhook func(childComplexity int, params model.WebhookRequest) int + EmailOtpMfaSetup func(childComplexity int, params *model.OtpMfaSetupRequest) int EnableAccess func(childComplexity int, param model.UpdateAccessRequest) int FgaDeleteTuples func(childComplexity int, params model.FgaWriteTuplesInput) int FgaReset func(childComplexity int) int @@ -321,6 +326,7 @@ type ComplexityRoot struct { ForgotPassword func(childComplexity int, params model.ForgotPasswordRequest) int GenerateJwtKeys func(childComplexity int, params model.GenerateJWTKeysRequest) int InviteMembers func(childComplexity int, params model.InviteMemberRequest) int + LockMfa func(childComplexity int, params model.LockMfaRequest) int Login func(childComplexity int, params model.LoginRequest) int Logout func(childComplexity int) int MagicLinkLogin func(childComplexity int, params model.MagicLinkLoginRequest) int @@ -336,8 +342,10 @@ 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 + SkipMfaSetup func(childComplexity int, params model.SkipMfaSetupRequest) int + SmsOtpMfaSetup func(childComplexity int, params *model.OtpMfaSetupRequest) int TestEndpoint func(childComplexity int, params model.TestEndpointRequest) int + TotpMfaSetup func(childComplexity int, params *model.OtpMfaSetupRequest) int UpdateClient func(childComplexity int, params model.UpdateClientRequest) int UpdateEmailTemplate func(childComplexity int, params model.UpdateEmailTemplateRequest) int UpdateEnv func(childComplexity int, params model.UpdateEnvRequest) int @@ -354,7 +362,7 @@ type ComplexityRoot struct { WebauthnDeleteCredential func(childComplexity int, id string) int WebauthnLoginOptions func(childComplexity int, email *string) int WebauthnLoginVerify func(childComplexity int, params model.WebauthnLoginVerifyRequest) int - WebauthnRegistrationOptions func(childComplexity int, email *string) int + WebauthnRegistrationOptions func(childComplexity int, email *string, phoneNumber *string) int WebauthnRegistrationVerify func(childComplexity int, params model.WebauthnRegistrationVerifyRequest) int } @@ -540,12 +548,14 @@ type ComplexityRoot struct { CreatedAt func(childComplexity int) int Email func(childComplexity int) int EmailVerified func(childComplexity int) int + EnrolledMfaMethods func(childComplexity int) int 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 + MfaLockedAt func(childComplexity int) int MiddleName func(childComplexity int) int Nickname func(childComplexity int) int PhoneNumber func(childComplexity int) int @@ -664,9 +674,13 @@ 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) + SkipMfaSetup(ctx context.Context, params model.SkipMfaSetupRequest) (*model.AuthResponse, error) + LockMfa(ctx context.Context, params model.LockMfaRequest) (*model.Response, error) + EmailOtpMfaSetup(ctx context.Context, params *model.OtpMfaSetupRequest) (*model.Response, error) + SmsOtpMfaSetup(ctx context.Context, params *model.OtpMfaSetupRequest) (*model.Response, error) + TotpMfaSetup(ctx context.Context, params *model.OtpMfaSetupRequest) (*model.AuthResponse, error) + WebauthnRegistrationOptions(ctx context.Context, email *string, phoneNumber *string) (*model.WebauthnRegistrationOptionsResponse, error) + WebauthnRegistrationVerify(ctx context.Context, params model.WebauthnRegistrationVerifyRequest) (*model.AuthResponse, error) WebauthnLoginOptions(ctx context.Context, email *string) (*model.WebauthnLoginOptionsResponse, error) WebauthnLoginVerify(ctx context.Context, params model.WebauthnLoginVerifyRequest) (*model.AuthResponse, error) WebauthnDeleteCredential(ctx context.Context, id string) (*model.Response, error) @@ -755,6 +769,9 @@ type QueryResolver interface { CheckPermissions(ctx context.Context, params model.CheckPermissionsInput) (*model.CheckPermissionsResponse, error) ListPermissions(ctx context.Context, params model.ListPermissionsInput) (*model.ListPermissionsResponse, error) } +type UserResolver interface { + EnrolledMfaMethods(ctx context.Context, obj *model.User) ([]string, error) +} type executableSchema struct { schema *ast.Schema @@ -950,6 +967,13 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return e.complexity.AuthResponse.RefreshToken(childComplexity), true + case "AuthResponse.should_offer_email_otp_mfa_setup": + if e.complexity.AuthResponse.ShouldOfferEmailOtpMfaSetup == nil { + break + } + + return e.complexity.AuthResponse.ShouldOfferEmailOtpMfaSetup(childComplexity), true + case "AuthResponse.should_offer_mfa_setup": if e.complexity.AuthResponse.ShouldOfferMfaSetup == nil { break @@ -957,6 +981,20 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return e.complexity.AuthResponse.ShouldOfferMfaSetup(childComplexity), true + case "AuthResponse.should_offer_sms_otp_mfa_setup": + if e.complexity.AuthResponse.ShouldOfferSmsOtpMfaSetup == nil { + break + } + + return e.complexity.AuthResponse.ShouldOfferSmsOtpMfaSetup(childComplexity), true + + case "AuthResponse.should_offer_webauthn_mfa_setup": + if e.complexity.AuthResponse.ShouldOfferWebauthnMfaSetup == nil { + break + } + + return e.complexity.AuthResponse.ShouldOfferWebauthnMfaSetup(childComplexity), true + case "AuthResponse.should_offer_webauthn_mfa_verify": if e.complexity.AuthResponse.ShouldOfferWebauthnMfaVerify == nil { break @@ -2264,6 +2302,18 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return e.complexity.Mutation.DeleteWebhook(childComplexity, args["params"].(model.WebhookRequest)), true + case "Mutation.email_otp_mfa_setup": + if e.complexity.Mutation.EmailOtpMfaSetup == nil { + break + } + + args, err := ec.field_Mutation_email_otp_mfa_setup_args(ctx, rawArgs) + if err != nil { + return 0, false + } + + return e.complexity.Mutation.EmailOtpMfaSetup(childComplexity, args["params"].(*model.OtpMfaSetupRequest)), true + case "Mutation._enable_access": if e.complexity.Mutation.EnableAccess == nil { break @@ -2355,6 +2405,18 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return e.complexity.Mutation.InviteMembers(childComplexity, args["params"].(model.InviteMemberRequest)), true + case "Mutation.lock_mfa": + if e.complexity.Mutation.LockMfa == nil { + break + } + + args, err := ec.field_Mutation_lock_mfa_args(ctx, rawArgs) + if err != nil { + return 0, false + } + + return e.complexity.Mutation.LockMfa(childComplexity, args["params"].(model.LockMfaRequest)), true + case "Mutation.login": if e.complexity.Mutation.Login == nil { break @@ -2535,7 +2597,24 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin break } - return e.complexity.Mutation.SkipMfaSetup(childComplexity), true + args, err := ec.field_Mutation_skip_mfa_setup_args(ctx, rawArgs) + if err != nil { + return 0, false + } + + return e.complexity.Mutation.SkipMfaSetup(childComplexity, args["params"].(model.SkipMfaSetupRequest)), true + + case "Mutation.sms_otp_mfa_setup": + if e.complexity.Mutation.SmsOtpMfaSetup == nil { + break + } + + args, err := ec.field_Mutation_sms_otp_mfa_setup_args(ctx, rawArgs) + if err != nil { + return 0, false + } + + return e.complexity.Mutation.SmsOtpMfaSetup(childComplexity, args["params"].(*model.OtpMfaSetupRequest)), true case "Mutation._test_endpoint": if e.complexity.Mutation.TestEndpoint == nil { @@ -2549,6 +2628,18 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return e.complexity.Mutation.TestEndpoint(childComplexity, args["params"].(model.TestEndpointRequest)), true + case "Mutation.totp_mfa_setup": + if e.complexity.Mutation.TotpMfaSetup == nil { + break + } + + args, err := ec.field_Mutation_totp_mfa_setup_args(ctx, rawArgs) + if err != nil { + return 0, false + } + + return e.complexity.Mutation.TotpMfaSetup(childComplexity, args["params"].(*model.OtpMfaSetupRequest)), true + case "Mutation._update_client": if e.complexity.Mutation.UpdateClient == nil { break @@ -2751,7 +2842,7 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return 0, false } - return e.complexity.Mutation.WebauthnRegistrationOptions(childComplexity, args["email"].(*string)), true + return e.complexity.Mutation.WebauthnRegistrationOptions(childComplexity, args["email"].(*string), args["phone_number"].(*string)), true case "Mutation.webauthn_registration_verify": if e.complexity.Mutation.WebauthnRegistrationVerify == nil { @@ -3794,6 +3885,13 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return e.complexity.User.EmailVerified(childComplexity), true + case "User.enrolled_mfa_methods": + if e.complexity.User.EnrolledMfaMethods == nil { + break + } + + return e.complexity.User.EnrolledMfaMethods(childComplexity), true + case "User.family_name": if e.complexity.User.FamilyName == nil { break @@ -3836,6 +3934,13 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return e.complexity.User.IsMultiFactorAuthEnabled(childComplexity), true + case "User.mfa_locked_at": + if e.complexity.User.MfaLockedAt == nil { + break + } + + return e.complexity.User.MfaLockedAt(childComplexity), true + case "User.middle_name": if e.complexity.User.MiddleName == nil { break @@ -4287,6 +4392,7 @@ func (e *executableSchema) Exec(ctx context.Context) graphql.ResponseHandler { ec.unmarshalInputListTrustedIssuersRequest, ec.unmarshalInputListUsersRequest, ec.unmarshalInputListWebhookLogRequest, + ec.unmarshalInputLockMfaRequest, ec.unmarshalInputLoginRequest, ec.unmarshalInputMagicLinkLoginRequest, ec.unmarshalInputMobileLoginRequest, @@ -4295,6 +4401,7 @@ func (e *executableSchema) Exec(ctx context.Context) graphql.ResponseHandler { ec.unmarshalInputOrgOIDCConnectionRequest, ec.unmarshalInputOrgSAMLConnectionRequest, ec.unmarshalInputOrganizationRequest, + ec.unmarshalInputOtpMfaSetupRequest, ec.unmarshalInputPaginatedRequest, ec.unmarshalInputPaginationRequest, ec.unmarshalInputPermissionCheckInput, @@ -4306,6 +4413,7 @@ func (e *executableSchema) Exec(ctx context.Context) graphql.ResponseHandler { ec.unmarshalInputScimEndpointRequest, ec.unmarshalInputSessionQueryRequest, ec.unmarshalInputSignUpRequest, + ec.unmarshalInputSkipMfaSetupRequest, ec.unmarshalInputTestEndpointRequest, ec.unmarshalInputTrustedIssuerRequest, ec.unmarshalInputUpdateAccessRequest, @@ -4517,6 +4625,17 @@ type User { # 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 + # mfa_locked_at is set once the user reports losing access to their only + # MFA factor(s) with no OTP fallback enrolled. Null means not locked. Only + # an admin can clear it (_update_user with reset_mfa: true). + mfa_locked_at: Int64 + # enrolled_mfa_methods lists the MFA factors this user has actually + # verified/enrolled: any of "totp", "webauthn", "email_otp", "sms_otp". + # Distinct from is_multi_factor_auth_enabled (a required-at-login flag) — + # this reflects real enrollment rows in storage. Lazily resolved: computed + # only when selected, so login/profile/auth responses that don't ask for it + # pay nothing. Empty list means nothing enrolled. + enrolled_mfa_methods: [String!]! app_data: Map } @@ -4559,12 +4678,23 @@ type AuthResponse { # 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 is DEPRECATED and never set to true anymore: the + # first-time-offer case now withholds access_token (see + # should_show_totp_screen/should_offer_webauthn_mfa_setup) instead of + # issuing a token alongside an "offer" flag. Field kept (unset) because + # older integration tests still assert its zero value; do not read it. should_offer_mfa_setup: Boolean + # should_offer_webauthn_mfa_setup is true, alongside should_show_totp_screen, + # when this is a first-time optional-MFA offer (mfaGateOfferAll) and + # WebAuthn is enabled server-wide as an MFA factor. access_token is NOT + # populated alongside this flag — unlike the old should_offer_mfa_setup, + # the token is withheld until the user completes a method or skips. + should_offer_webauthn_mfa_setup: Boolean + # should_offer_email_otp_mfa_setup / should_offer_sms_otp_mfa_setup: same + # shape, for email/SMS OTP — true only when that method's provider is + # configured AND the user has no verified enrollment for it yet. + should_offer_email_otp_mfa_setup: Boolean + should_offer_sms_otp_mfa_setup: Boolean access_token: String id_token: String refresh_token: String @@ -4614,6 +4744,13 @@ input WebauthnRegistrationVerifyRequest { # JSON-encoded PublicKeyCredential attestation response from # navigator.credentials.create(). credential: String! + # The following three fields are only used on the MFA-session-cookie path + # (mfaGateOfferAll/mfaGateBlockEnroll passkey enrollment) — ignored for an + # ordinary bearer-token-authenticated settings-page caller. Same shape as + # SkipMfaSetupRequest/VerifyOTPRequest. + email: String + phone_number: String + state: String } input WebauthnLoginVerifyRequest { @@ -5249,6 +5386,11 @@ input UpdateUserRequest { picture: String roles: [String] is_multi_factor_auth_enabled: Boolean + # reset_mfa, when true, clears the user's entire MFA state: mfa_locked_at, + # is_multi_factor_auth_enabled, has_skipped_mfa_setup_at, and deletes all + # enrolled authenticators/passkeys. The user's next login lands back on + # the first-time setup screen, same as a brand-new account. + reset_mfa: Boolean app_data: Map } @@ -5652,6 +5794,33 @@ input VerifyOTPRequest { state: String } +input SkipMfaSetupRequest { + # either email or phone_number is required, whichever the pending login + # used, to resolve which user's MFA session cookie this is. + email: String + phone_number: String + # state is used for authorization code grant flow, same as VerifyOTPRequest. + state: String +} + +input LockMfaRequest { + # either email or phone_number is required, to resolve which user's MFA + # session cookie this is — same pattern as SkipMfaSetupRequest. + email: String + phone_number: String +} + +input OtpMfaSetupRequest { + # Only used in the MFA-session-cookie mode of email_otp_mfa_setup / + # sms_otp_mfa_setup (a caller in the withheld first-time-offer state, with + # no bearer token yet) to resolve which user's MFA session cookie this is + # — same pattern as SkipMfaSetupRequest/LockMfaRequest. Ignored when the + # caller has a valid bearer token/session, which already identifies the + # user (the existing settings-screen "add a second factor" flow). + email: String + phone_number: String +} + input ResendOTPRequest { email: String phone_number: String @@ -5767,17 +5936,57 @@ 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! + # skip_mfa_setup completes an in-progress, token-withheld MFA offer by + # recording that the caller explicitly declined it, then issues the + # access token that was withheld. Identified by the MFA session cookie + # (set when the offer screen was returned) plus email/phone_number to + # resolve the pending user — same identification pattern as verify_otp. + # Fails with FAILED_PRECONDITION if MFA is organization-enforced + # (enforce-mfa) — enforcement is never skippable. + skip_mfa_setup(params: SkipMfaSetupRequest!): AuthResponse! + # lock_mfa records that the caller lost access to their only MFA + # factor(s). Only allowed when the caller has NO verified Email/SMS OTP + # fallback enrolled — if one exists, use it instead of locking. Does not + # issue a token; the account requires admin recovery afterward. + lock_mfa(params: LockMfaRequest!): Response! + # email_otp_mfa_setup sends a one-time code to the caller's own email and + # creates an unverified email-OTP MFA enrollment. Dual-mode permissions: + # (a) an authenticated caller (bearer token) — the settings-screen action + # for an ALREADY-logged-in user adding a second factor; params is unused + # in this mode. (b) a caller in the withheld first-time-offer state, with + # no bearer token yet — identified by the MFA session cookie plus + # params.email/phone_number, same pattern as skip_mfa_setup. Either mode + # reuses the same underlying Authenticator row once verify_otp marks it + # verified. + email_otp_mfa_setup(params: OtpMfaSetupRequest): Response! + # sms_otp_mfa_setup sends a one-time code to the caller's own phone number + # and creates an unverified SMS-OTP MFA enrollment. Same dual-mode + # permissions and relationship to verify_otp as email_otp_mfa_setup. + sms_otp_mfa_setup(params: OtpMfaSetupRequest): Response! + # totp_mfa_setup generates a fresh TOTP secret/QR/recovery-codes for the + # caller to enroll as an MFA method. Same dual-mode permissions as + # email_otp_mfa_setup/sms_otp_mfa_setup. Unlike those, nothing is sent + # anywhere - the enrollment payload is returned directly (same + # authenticator_scanner_image/authenticator_secret/authenticator_recovery_codes + # fields the login/signup gate response uses) so the caller scans the + # QR/enters the code, then completes enrollment via verify_otp(is_totp: true). + totp_mfa_setup(params: OtpMfaSetupRequest): AuthResponse! # WebAuthn / passkey self-service ceremonies (no admin ` + "`" + `_` + "`" + ` prefix). + # + # Both also accept an MFA-session-cookie caller in addition to a bearer + # token: during a token-withheld mfaGateOfferAll/mfaGateBlockEnroll offer + # (see mfa_gate.go), registering a passkey completes the gate the same way + # totp_mfa_setup + verify_otp(is_totp: true) does, so + # webauthn_registration_verify returns AuthResponse (access_token set only + # on that session-resolved path; nil, message-only, for the ordinary + # authenticated-settings-page caller). webauthn_registration_options( email: String + phone_number: String ): WebauthnRegistrationOptionsResponse! webauthn_registration_verify( params: WebauthnRegistrationVerifyRequest! - ): Response! + ): AuthResponse! webauthn_login_options(email: String): WebauthnLoginOptionsResponse! webauthn_login_verify(params: WebauthnLoginVerifyRequest!): AuthResponse! webauthn_delete_credential(id: ID!): Response! @@ -7135,6 +7344,34 @@ func (ec *executionContext) field_Mutation__verify_org_domain_argsParams( return zeroVal, nil } +func (ec *executionContext) field_Mutation_email_otp_mfa_setup_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { + var err error + args := map[string]any{} + arg0, err := ec.field_Mutation_email_otp_mfa_setup_argsParams(ctx, rawArgs) + if err != nil { + return nil, err + } + args["params"] = arg0 + return args, nil +} +func (ec *executionContext) field_Mutation_email_otp_mfa_setup_argsParams( + ctx context.Context, + rawArgs map[string]any, +) (*model.OtpMfaSetupRequest, error) { + if _, ok := rawArgs["params"]; !ok { + var zeroVal *model.OtpMfaSetupRequest + return zeroVal, nil + } + + ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("params")) + if tmp, ok := rawArgs["params"]; ok { + return ec.unmarshalOOtpMfaSetupRequest2ᚖgithubᚗcomᚋauthorizerdevᚋauthorizerᚋinternalᚋgraphᚋmodelᚐOtpMfaSetupRequest(ctx, tmp) + } + + var zeroVal *model.OtpMfaSetupRequest + return zeroVal, nil +} + func (ec *executionContext) field_Mutation_forgot_password_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { var err error args := map[string]any{} @@ -7163,6 +7400,34 @@ func (ec *executionContext) field_Mutation_forgot_password_argsParams( return zeroVal, nil } +func (ec *executionContext) field_Mutation_lock_mfa_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { + var err error + args := map[string]any{} + arg0, err := ec.field_Mutation_lock_mfa_argsParams(ctx, rawArgs) + if err != nil { + return nil, err + } + args["params"] = arg0 + return args, nil +} +func (ec *executionContext) field_Mutation_lock_mfa_argsParams( + ctx context.Context, + rawArgs map[string]any, +) (model.LockMfaRequest, error) { + if _, ok := rawArgs["params"]; !ok { + var zeroVal model.LockMfaRequest + return zeroVal, nil + } + + ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("params")) + if tmp, ok := rawArgs["params"]; ok { + return ec.unmarshalNLockMfaRequest2githubᚗcomᚋauthorizerdevᚋauthorizerᚋinternalᚋgraphᚋmodelᚐLockMfaRequest(ctx, tmp) + } + + var zeroVal model.LockMfaRequest + return zeroVal, nil +} + func (ec *executionContext) field_Mutation_login_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { var err error args := map[string]any{} @@ -7415,6 +7680,90 @@ func (ec *executionContext) field_Mutation_signup_argsParams( return zeroVal, nil } +func (ec *executionContext) field_Mutation_skip_mfa_setup_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { + var err error + args := map[string]any{} + arg0, err := ec.field_Mutation_skip_mfa_setup_argsParams(ctx, rawArgs) + if err != nil { + return nil, err + } + args["params"] = arg0 + return args, nil +} +func (ec *executionContext) field_Mutation_skip_mfa_setup_argsParams( + ctx context.Context, + rawArgs map[string]any, +) (model.SkipMfaSetupRequest, error) { + if _, ok := rawArgs["params"]; !ok { + var zeroVal model.SkipMfaSetupRequest + return zeroVal, nil + } + + ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("params")) + if tmp, ok := rawArgs["params"]; ok { + return ec.unmarshalNSkipMfaSetupRequest2githubᚗcomᚋauthorizerdevᚋauthorizerᚋinternalᚋgraphᚋmodelᚐSkipMfaSetupRequest(ctx, tmp) + } + + var zeroVal model.SkipMfaSetupRequest + return zeroVal, nil +} + +func (ec *executionContext) field_Mutation_sms_otp_mfa_setup_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { + var err error + args := map[string]any{} + arg0, err := ec.field_Mutation_sms_otp_mfa_setup_argsParams(ctx, rawArgs) + if err != nil { + return nil, err + } + args["params"] = arg0 + return args, nil +} +func (ec *executionContext) field_Mutation_sms_otp_mfa_setup_argsParams( + ctx context.Context, + rawArgs map[string]any, +) (*model.OtpMfaSetupRequest, error) { + if _, ok := rawArgs["params"]; !ok { + var zeroVal *model.OtpMfaSetupRequest + return zeroVal, nil + } + + ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("params")) + if tmp, ok := rawArgs["params"]; ok { + return ec.unmarshalOOtpMfaSetupRequest2ᚖgithubᚗcomᚋauthorizerdevᚋauthorizerᚋinternalᚋgraphᚋmodelᚐOtpMfaSetupRequest(ctx, tmp) + } + + var zeroVal *model.OtpMfaSetupRequest + return zeroVal, nil +} + +func (ec *executionContext) field_Mutation_totp_mfa_setup_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { + var err error + args := map[string]any{} + arg0, err := ec.field_Mutation_totp_mfa_setup_argsParams(ctx, rawArgs) + if err != nil { + return nil, err + } + args["params"] = arg0 + return args, nil +} +func (ec *executionContext) field_Mutation_totp_mfa_setup_argsParams( + ctx context.Context, + rawArgs map[string]any, +) (*model.OtpMfaSetupRequest, error) { + if _, ok := rawArgs["params"]; !ok { + var zeroVal *model.OtpMfaSetupRequest + return zeroVal, nil + } + + ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("params")) + if tmp, ok := rawArgs["params"]; ok { + return ec.unmarshalOOtpMfaSetupRequest2ᚖgithubᚗcomᚋauthorizerdevᚋauthorizerᚋinternalᚋgraphᚋmodelᚐOtpMfaSetupRequest(ctx, tmp) + } + + var zeroVal *model.OtpMfaSetupRequest + return zeroVal, nil +} + func (ec *executionContext) field_Mutation_update_profile_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { var err error args := map[string]any{} @@ -7591,6 +7940,11 @@ func (ec *executionContext) field_Mutation_webauthn_registration_options_args(ct return nil, err } args["email"] = arg0 + arg1, err := ec.field_Mutation_webauthn_registration_options_argsPhoneNumber(ctx, rawArgs) + if err != nil { + return nil, err + } + args["phone_number"] = arg1 return args, nil } func (ec *executionContext) field_Mutation_webauthn_registration_options_argsEmail( @@ -7611,6 +7965,24 @@ func (ec *executionContext) field_Mutation_webauthn_registration_options_argsEma return zeroVal, nil } +func (ec *executionContext) field_Mutation_webauthn_registration_options_argsPhoneNumber( + ctx context.Context, + rawArgs map[string]any, +) (*string, error) { + if _, ok := rawArgs["phone_number"]; !ok { + var zeroVal *string + return zeroVal, nil + } + + ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("phone_number")) + if tmp, ok := rawArgs["phone_number"]; ok { + return ec.unmarshalOString2ᚖstring(ctx, tmp) + } + + var zeroVal *string + return zeroVal, nil +} + func (ec *executionContext) field_Mutation_webauthn_registration_verify_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { var err error args := map[string]any{} @@ -9572,6 +9944,129 @@ func (ec *executionContext) fieldContext_AuthResponse_should_offer_mfa_setup(_ c return fc, nil } +func (ec *executionContext) _AuthResponse_should_offer_webauthn_mfa_setup(ctx context.Context, field graphql.CollectedField, obj *model.AuthResponse) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_AuthResponse_should_offer_webauthn_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.ShouldOfferWebauthnMfaSetup, 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_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_should_offer_email_otp_mfa_setup(ctx context.Context, field graphql.CollectedField, obj *model.AuthResponse) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_AuthResponse_should_offer_email_otp_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.ShouldOfferEmailOtpMfaSetup, 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_email_otp_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_should_offer_sms_otp_mfa_setup(ctx context.Context, field graphql.CollectedField, obj *model.AuthResponse) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_AuthResponse_should_offer_sms_otp_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.ShouldOfferSmsOtpMfaSetup, 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_sms_otp_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 { @@ -9812,6 +10307,10 @@ func (ec *executionContext) fieldContext_AuthResponse_user(_ context.Context, fi 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 "mfa_locked_at": + return ec.fieldContext_User_mfa_locked_at(ctx, field) + case "enrolled_mfa_methods": + return ec.fieldContext_User_enrolled_mfa_methods(ctx, field) case "app_data": return ec.fieldContext_User_app_data(ctx, field) } @@ -14859,6 +15358,10 @@ func (ec *executionContext) fieldContext_InviteMembersResponse_Users(_ context.C 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 "mfa_locked_at": + return ec.fieldContext_User_mfa_locked_at(ctx, field) + case "enrolled_mfa_methods": + return ec.fieldContext_User_enrolled_mfa_methods(ctx, field) case "app_data": return ec.fieldContext_User_app_data(ctx, field) } @@ -16201,6 +16704,12 @@ func (ec *executionContext) fieldContext_Mutation_signup(ctx context.Context, fi 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 "should_offer_webauthn_mfa_setup": + return ec.fieldContext_AuthResponse_should_offer_webauthn_mfa_setup(ctx, field) + case "should_offer_email_otp_mfa_setup": + return ec.fieldContext_AuthResponse_should_offer_email_otp_mfa_setup(ctx, field) + case "should_offer_sms_otp_mfa_setup": + return ec.fieldContext_AuthResponse_should_offer_sms_otp_mfa_setup(ctx, field) case "access_token": return ec.fieldContext_AuthResponse_access_token(ctx, field) case "id_token": @@ -16286,6 +16795,12 @@ func (ec *executionContext) fieldContext_Mutation_mobile_signup(ctx context.Cont 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 "should_offer_webauthn_mfa_setup": + return ec.fieldContext_AuthResponse_should_offer_webauthn_mfa_setup(ctx, field) + case "should_offer_email_otp_mfa_setup": + return ec.fieldContext_AuthResponse_should_offer_email_otp_mfa_setup(ctx, field) + case "should_offer_sms_otp_mfa_setup": + return ec.fieldContext_AuthResponse_should_offer_sms_otp_mfa_setup(ctx, field) case "access_token": return ec.fieldContext_AuthResponse_access_token(ctx, field) case "id_token": @@ -16371,6 +16886,12 @@ func (ec *executionContext) fieldContext_Mutation_login(ctx context.Context, fie 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 "should_offer_webauthn_mfa_setup": + return ec.fieldContext_AuthResponse_should_offer_webauthn_mfa_setup(ctx, field) + case "should_offer_email_otp_mfa_setup": + return ec.fieldContext_AuthResponse_should_offer_email_otp_mfa_setup(ctx, field) + case "should_offer_sms_otp_mfa_setup": + return ec.fieldContext_AuthResponse_should_offer_sms_otp_mfa_setup(ctx, field) case "access_token": return ec.fieldContext_AuthResponse_access_token(ctx, field) case "id_token": @@ -16456,6 +16977,12 @@ func (ec *executionContext) fieldContext_Mutation_mobile_login(ctx context.Conte 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 "should_offer_webauthn_mfa_setup": + return ec.fieldContext_AuthResponse_should_offer_webauthn_mfa_setup(ctx, field) + case "should_offer_email_otp_mfa_setup": + return ec.fieldContext_AuthResponse_should_offer_email_otp_mfa_setup(ctx, field) + case "should_offer_sms_otp_mfa_setup": + return ec.fieldContext_AuthResponse_should_offer_sms_otp_mfa_setup(ctx, field) case "access_token": return ec.fieldContext_AuthResponse_access_token(ctx, field) case "id_token": @@ -16707,6 +17234,12 @@ func (ec *executionContext) fieldContext_Mutation_verify_email(ctx context.Conte 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 "should_offer_webauthn_mfa_setup": + return ec.fieldContext_AuthResponse_should_offer_webauthn_mfa_setup(ctx, field) + case "should_offer_email_otp_mfa_setup": + return ec.fieldContext_AuthResponse_should_offer_email_otp_mfa_setup(ctx, field) + case "should_offer_sms_otp_mfa_setup": + return ec.fieldContext_AuthResponse_should_offer_sms_otp_mfa_setup(ctx, field) case "access_token": return ec.fieldContext_AuthResponse_access_token(ctx, field) case "id_token": @@ -17030,6 +17563,12 @@ func (ec *executionContext) fieldContext_Mutation_verify_otp(ctx context.Context 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 "should_offer_webauthn_mfa_setup": + return ec.fieldContext_AuthResponse_should_offer_webauthn_mfa_setup(ctx, field) + case "should_offer_email_otp_mfa_setup": + return ec.fieldContext_AuthResponse_should_offer_email_otp_mfa_setup(ctx, field) + case "should_offer_sms_otp_mfa_setup": + return ec.fieldContext_AuthResponse_should_offer_sms_otp_mfa_setup(ctx, field) case "access_token": return ec.fieldContext_AuthResponse_access_token(ctx, field) case "id_token": @@ -17137,7 +17676,216 @@ func (ec *executionContext) _Mutation_skip_mfa_setup(ctx context.Context, field }() 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) + return ec.resolvers.Mutation().SkipMfaSetup(rctx, fc.Args["params"].(model.SkipMfaSetupRequest)) + }) + 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.AuthResponse) + fc.Result = res + return ec.marshalNAuthResponse2ᚖgithubᚗcomᚋauthorizerdevᚋauthorizerᚋinternalᚋgraphᚋmodelᚐAuthResponse(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_Mutation_skip_mfa_setup(ctx 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_AuthResponse_message(ctx, field) + case "should_show_email_otp_screen": + return ec.fieldContext_AuthResponse_should_show_email_otp_screen(ctx, field) + case "should_show_mobile_otp_screen": + 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 "should_offer_webauthn_mfa_setup": + return ec.fieldContext_AuthResponse_should_offer_webauthn_mfa_setup(ctx, field) + case "should_offer_email_otp_mfa_setup": + return ec.fieldContext_AuthResponse_should_offer_email_otp_mfa_setup(ctx, field) + case "should_offer_sms_otp_mfa_setup": + return ec.fieldContext_AuthResponse_should_offer_sms_otp_mfa_setup(ctx, field) + case "access_token": + return ec.fieldContext_AuthResponse_access_token(ctx, field) + case "id_token": + return ec.fieldContext_AuthResponse_id_token(ctx, field) + case "refresh_token": + return ec.fieldContext_AuthResponse_refresh_token(ctx, field) + case "expires_in": + return ec.fieldContext_AuthResponse_expires_in(ctx, field) + case "user": + return ec.fieldContext_AuthResponse_user(ctx, field) + case "authenticator_scanner_image": + return ec.fieldContext_AuthResponse_authenticator_scanner_image(ctx, field) + case "authenticator_secret": + return ec.fieldContext_AuthResponse_authenticator_secret(ctx, field) + case "authenticator_recovery_codes": + return ec.fieldContext_AuthResponse_authenticator_recovery_codes(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type AuthResponse", field.Name) + }, + } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Mutation_skip_mfa_setup_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + +func (ec *executionContext) _Mutation_lock_mfa(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_Mutation_lock_mfa(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().LockMfa(rctx, fc.Args["params"].(model.LockMfaRequest)) + }) + 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_lock_mfa(ctx 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) + }, + } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Mutation_lock_mfa_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + +func (ec *executionContext) _Mutation_email_otp_mfa_setup(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_Mutation_email_otp_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().EmailOtpMfaSetup(rctx, fc.Args["params"].(*model.OtpMfaSetupRequest)) + }) + 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_email_otp_mfa_setup(ctx 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) + }, + } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Mutation_email_otp_mfa_setup_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + +func (ec *executionContext) _Mutation_sms_otp_mfa_setup(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_Mutation_sms_otp_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().SmsOtpMfaSetup(rctx, fc.Args["params"].(*model.OtpMfaSetupRequest)) }) if err != nil { ec.Error(ctx, err) @@ -17154,7 +17902,7 @@ func (ec *executionContext) _Mutation_skip_mfa_setup(ctx context.Context, field 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) { +func (ec *executionContext) fieldContext_Mutation_sms_otp_mfa_setup(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { fc = &graphql.FieldContext{ Object: "Mutation", Field: field, @@ -17168,6 +17916,108 @@ func (ec *executionContext) fieldContext_Mutation_skip_mfa_setup(_ context.Conte return nil, fmt.Errorf("no field named %q was found under type Response", field.Name) }, } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Mutation_sms_otp_mfa_setup_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + +func (ec *executionContext) _Mutation_totp_mfa_setup(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_Mutation_totp_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().TotpMfaSetup(rctx, fc.Args["params"].(*model.OtpMfaSetupRequest)) + }) + 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.AuthResponse) + fc.Result = res + return ec.marshalNAuthResponse2ᚖgithubᚗcomᚋauthorizerdevᚋauthorizerᚋinternalᚋgraphᚋmodelᚐAuthResponse(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_Mutation_totp_mfa_setup(ctx 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_AuthResponse_message(ctx, field) + case "should_show_email_otp_screen": + return ec.fieldContext_AuthResponse_should_show_email_otp_screen(ctx, field) + case "should_show_mobile_otp_screen": + 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 "should_offer_webauthn_mfa_setup": + return ec.fieldContext_AuthResponse_should_offer_webauthn_mfa_setup(ctx, field) + case "should_offer_email_otp_mfa_setup": + return ec.fieldContext_AuthResponse_should_offer_email_otp_mfa_setup(ctx, field) + case "should_offer_sms_otp_mfa_setup": + return ec.fieldContext_AuthResponse_should_offer_sms_otp_mfa_setup(ctx, field) + case "access_token": + return ec.fieldContext_AuthResponse_access_token(ctx, field) + case "id_token": + return ec.fieldContext_AuthResponse_id_token(ctx, field) + case "refresh_token": + return ec.fieldContext_AuthResponse_refresh_token(ctx, field) + case "expires_in": + return ec.fieldContext_AuthResponse_expires_in(ctx, field) + case "user": + return ec.fieldContext_AuthResponse_user(ctx, field) + case "authenticator_scanner_image": + return ec.fieldContext_AuthResponse_authenticator_scanner_image(ctx, field) + case "authenticator_secret": + return ec.fieldContext_AuthResponse_authenticator_secret(ctx, field) + case "authenticator_recovery_codes": + return ec.fieldContext_AuthResponse_authenticator_recovery_codes(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type AuthResponse", field.Name) + }, + } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Mutation_totp_mfa_setup_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } return fc, nil } @@ -17185,7 +18035,7 @@ func (ec *executionContext) _Mutation_webauthn_registration_options(ctx context. }() resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) { ctx = rctx // use context from middleware stack in children - return ec.resolvers.Mutation().WebauthnRegistrationOptions(rctx, fc.Args["email"].(*string)) + return ec.resolvers.Mutation().WebauthnRegistrationOptions(rctx, fc.Args["email"].(*string), fc.Args["phone_number"].(*string)) }) if err != nil { ec.Error(ctx, err) @@ -17256,9 +18106,9 @@ func (ec *executionContext) _Mutation_webauthn_registration_verify(ctx context.C } return graphql.Null } - res := resTmp.(*model.Response) + res := resTmp.(*model.AuthResponse) fc.Result = res - return ec.marshalNResponse2ᚖgithubᚗcomᚋauthorizerdevᚋauthorizerᚋinternalᚋgraphᚋmodelᚐResponse(ctx, field.Selections, res) + return ec.marshalNAuthResponse2ᚖgithubᚗcomᚋauthorizerdevᚋauthorizerᚋinternalᚋgraphᚋmodelᚐAuthResponse(ctx, field.Selections, res) } func (ec *executionContext) fieldContext_Mutation_webauthn_registration_verify(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { @@ -17270,9 +18120,41 @@ func (ec *executionContext) fieldContext_Mutation_webauthn_registration_verify(c Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { switch field.Name { case "message": - return ec.fieldContext_Response_message(ctx, field) + return ec.fieldContext_AuthResponse_message(ctx, field) + case "should_show_email_otp_screen": + return ec.fieldContext_AuthResponse_should_show_email_otp_screen(ctx, field) + case "should_show_mobile_otp_screen": + 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 "should_offer_webauthn_mfa_setup": + return ec.fieldContext_AuthResponse_should_offer_webauthn_mfa_setup(ctx, field) + case "should_offer_email_otp_mfa_setup": + return ec.fieldContext_AuthResponse_should_offer_email_otp_mfa_setup(ctx, field) + case "should_offer_sms_otp_mfa_setup": + return ec.fieldContext_AuthResponse_should_offer_sms_otp_mfa_setup(ctx, field) + case "access_token": + return ec.fieldContext_AuthResponse_access_token(ctx, field) + case "id_token": + return ec.fieldContext_AuthResponse_id_token(ctx, field) + case "refresh_token": + return ec.fieldContext_AuthResponse_refresh_token(ctx, field) + case "expires_in": + return ec.fieldContext_AuthResponse_expires_in(ctx, field) + case "user": + return ec.fieldContext_AuthResponse_user(ctx, field) + case "authenticator_scanner_image": + return ec.fieldContext_AuthResponse_authenticator_scanner_image(ctx, field) + case "authenticator_secret": + return ec.fieldContext_AuthResponse_authenticator_secret(ctx, field) + case "authenticator_recovery_codes": + return ec.fieldContext_AuthResponse_authenticator_recovery_codes(ctx, field) } - return nil, fmt.Errorf("no field named %q was found under type Response", field.Name) + return nil, fmt.Errorf("no field named %q was found under type AuthResponse", field.Name) }, } defer func() { @@ -17399,6 +18281,12 @@ func (ec *executionContext) fieldContext_Mutation_webauthn_login_verify(ctx cont 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 "should_offer_webauthn_mfa_setup": + return ec.fieldContext_AuthResponse_should_offer_webauthn_mfa_setup(ctx, field) + case "should_offer_email_otp_mfa_setup": + return ec.fieldContext_AuthResponse_should_offer_email_otp_mfa_setup(ctx, field) + case "should_offer_sms_otp_mfa_setup": + return ec.fieldContext_AuthResponse_should_offer_sms_otp_mfa_setup(ctx, field) case "access_token": return ec.fieldContext_AuthResponse_access_token(ctx, field) case "id_token": @@ -17678,6 +18566,10 @@ func (ec *executionContext) fieldContext_Mutation__update_user(ctx context.Conte 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 "mfa_locked_at": + return ec.fieldContext_User_mfa_locked_at(ctx, field) + case "enrolled_mfa_methods": + return ec.fieldContext_User_enrolled_mfa_methods(ctx, field) case "app_data": return ec.fieldContext_User_app_data(ctx, field) } @@ -23348,6 +24240,12 @@ func (ec *executionContext) fieldContext_Query_session(ctx context.Context, fiel 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 "should_offer_webauthn_mfa_setup": + return ec.fieldContext_AuthResponse_should_offer_webauthn_mfa_setup(ctx, field) + case "should_offer_email_otp_mfa_setup": + return ec.fieldContext_AuthResponse_should_offer_email_otp_mfa_setup(ctx, field) + case "should_offer_sms_otp_mfa_setup": + return ec.fieldContext_AuthResponse_should_offer_sms_otp_mfa_setup(ctx, field) case "access_token": return ec.fieldContext_AuthResponse_access_token(ctx, field) case "id_token": @@ -23461,6 +24359,10 @@ func (ec *executionContext) fieldContext_Query_profile(_ context.Context, field 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 "mfa_locked_at": + return ec.fieldContext_User_mfa_locked_at(ctx, field) + case "enrolled_mfa_methods": + return ec.fieldContext_User_enrolled_mfa_methods(ctx, field) case "app_data": return ec.fieldContext_User_app_data(ctx, field) } @@ -23790,6 +24692,10 @@ func (ec *executionContext) fieldContext_Query__user(ctx context.Context, field 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 "mfa_locked_at": + return ec.fieldContext_User_mfa_locked_at(ctx, field) + case "enrolled_mfa_methods": + return ec.fieldContext_User_enrolled_mfa_methods(ctx, field) case "app_data": return ec.fieldContext_User_app_data(ctx, field) } @@ -27775,6 +28681,91 @@ func (ec *executionContext) fieldContext_User_has_skipped_mfa_setup_at(_ context return fc, nil } +func (ec *executionContext) _User_mfa_locked_at(ctx context.Context, field graphql.CollectedField, obj *model.User) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_User_mfa_locked_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.MfaLockedAt, 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_mfa_locked_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_enrolled_mfa_methods(ctx context.Context, field graphql.CollectedField, obj *model.User) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_User_enrolled_mfa_methods(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.User().EnrolledMfaMethods(rctx, obj) + }) + 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.([]string) + fc.Result = res + return ec.marshalNString2ᚕstringᚄ(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_User_enrolled_mfa_methods(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "User", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String 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 { @@ -28155,6 +29146,10 @@ func (ec *executionContext) fieldContext_Users_users(_ context.Context, field gr 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 "mfa_locked_at": + return ec.fieldContext_User_mfa_locked_at(ctx, field) + case "enrolled_mfa_methods": + return ec.fieldContext_User_enrolled_mfa_methods(ctx, field) case "app_data": return ec.fieldContext_User_app_data(ctx, field) } @@ -28372,6 +29367,10 @@ func (ec *executionContext) fieldContext_ValidateSessionResponse_user(_ context. 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 "mfa_locked_at": + return ec.fieldContext_User_mfa_locked_at(ctx, field) + case "enrolled_mfa_methods": + return ec.fieldContext_User_enrolled_mfa_methods(ctx, field) case "app_data": return ec.fieldContext_User_app_data(ctx, field) } @@ -33481,6 +34480,40 @@ func (ec *executionContext) unmarshalInputListWebhookLogRequest(ctx context.Cont return it, nil } +func (ec *executionContext) unmarshalInputLockMfaRequest(ctx context.Context, obj any) (model.LockMfaRequest, error) { + var it model.LockMfaRequest + asMap := map[string]any{} + for k, v := range obj.(map[string]any) { + asMap[k] = v + } + + fieldsInOrder := [...]string{"email", "phone_number"} + for _, k := range fieldsInOrder { + v, ok := asMap[k] + if !ok { + continue + } + switch k { + case "email": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("email")) + data, err := ec.unmarshalOString2ᚖstring(ctx, v) + if err != nil { + return it, err + } + it.Email = data + case "phone_number": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("phone_number")) + data, err := ec.unmarshalOString2ᚖstring(ctx, v) + if err != nil { + return it, err + } + it.PhoneNumber = data + } + } + + return it, nil +} + func (ec *executionContext) unmarshalInputLoginRequest(ctx context.Context, obj any) (model.LoginRequest, error) { var it model.LoginRequest asMap := map[string]any{} @@ -33914,6 +34947,40 @@ func (ec *executionContext) unmarshalInputOrganizationRequest(ctx context.Contex return it, nil } +func (ec *executionContext) unmarshalInputOtpMfaSetupRequest(ctx context.Context, obj any) (model.OtpMfaSetupRequest, error) { + var it model.OtpMfaSetupRequest + asMap := map[string]any{} + for k, v := range obj.(map[string]any) { + asMap[k] = v + } + + fieldsInOrder := [...]string{"email", "phone_number"} + for _, k := range fieldsInOrder { + v, ok := asMap[k] + if !ok { + continue + } + switch k { + case "email": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("email")) + data, err := ec.unmarshalOString2ᚖstring(ctx, v) + if err != nil { + return it, err + } + it.Email = data + case "phone_number": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("phone_number")) + data, err := ec.unmarshalOString2ᚖstring(ctx, v) + if err != nil { + return it, err + } + it.PhoneNumber = data + } + } + + return it, nil +} + func (ec *executionContext) unmarshalInputPaginatedRequest(ctx context.Context, obj any) (model.PaginatedRequest, error) { var it model.PaginatedRequest asMap := map[string]any{} @@ -34435,6 +35502,47 @@ func (ec *executionContext) unmarshalInputSignUpRequest(ctx context.Context, obj return it, nil } +func (ec *executionContext) unmarshalInputSkipMfaSetupRequest(ctx context.Context, obj any) (model.SkipMfaSetupRequest, error) { + var it model.SkipMfaSetupRequest + asMap := map[string]any{} + for k, v := range obj.(map[string]any) { + asMap[k] = v + } + + fieldsInOrder := [...]string{"email", "phone_number", "state"} + for _, k := range fieldsInOrder { + v, ok := asMap[k] + if !ok { + continue + } + switch k { + case "email": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("email")) + data, err := ec.unmarshalOString2ᚖstring(ctx, v) + if err != nil { + return it, err + } + it.Email = data + case "phone_number": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("phone_number")) + data, err := ec.unmarshalOString2ᚖstring(ctx, v) + if err != nil { + return it, err + } + it.PhoneNumber = data + case "state": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("state")) + data, err := ec.unmarshalOString2ᚖstring(ctx, v) + if err != nil { + return it, err + } + it.State = data + } + } + + return it, nil +} + func (ec *executionContext) unmarshalInputTestEndpointRequest(ctx context.Context, obj any) (model.TestEndpointRequest, error) { var it model.TestEndpointRequest asMap := map[string]any{} @@ -35523,7 +36631,7 @@ func (ec *executionContext) unmarshalInputUpdateUserRequest(ctx context.Context, asMap[k] = v } - fieldsInOrder := [...]string{"id", "email", "email_verified", "given_name", "family_name", "middle_name", "nickname", "gender", "birthdate", "phone_number", "phone_number_verified", "picture", "roles", "is_multi_factor_auth_enabled", "app_data"} + fieldsInOrder := [...]string{"id", "email", "email_verified", "given_name", "family_name", "middle_name", "nickname", "gender", "birthdate", "phone_number", "phone_number_verified", "picture", "roles", "is_multi_factor_auth_enabled", "reset_mfa", "app_data"} for _, k := range fieldsInOrder { v, ok := asMap[k] if !ok { @@ -35628,6 +36736,13 @@ func (ec *executionContext) unmarshalInputUpdateUserRequest(ctx context.Context, return it, err } it.IsMultiFactorAuthEnabled = data + case "reset_mfa": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("reset_mfa")) + data, err := ec.unmarshalOBoolean2ᚖbool(ctx, v) + if err != nil { + return it, err + } + it.ResetMfa = data case "app_data": ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("app_data")) data, err := ec.unmarshalOMap2map(ctx, v) @@ -35990,7 +37105,7 @@ func (ec *executionContext) unmarshalInputWebauthnRegistrationVerifyRequest(ctx asMap[k] = v } - fieldsInOrder := [...]string{"name", "credential"} + fieldsInOrder := [...]string{"name", "credential", "email", "phone_number", "state"} for _, k := range fieldsInOrder { v, ok := asMap[k] if !ok { @@ -36011,6 +37126,27 @@ func (ec *executionContext) unmarshalInputWebauthnRegistrationVerifyRequest(ctx return it, err } it.Credential = data + case "email": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("email")) + data, err := ec.unmarshalOString2ᚖstring(ctx, v) + if err != nil { + return it, err + } + it.Email = data + case "phone_number": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("phone_number")) + data, err := ec.unmarshalOString2ᚖstring(ctx, v) + if err != nil { + return it, err + } + it.PhoneNumber = data + case "state": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("state")) + data, err := ec.unmarshalOString2ᚖstring(ctx, v) + if err != nil { + return it, err + } + it.State = data } } @@ -36235,6 +37371,12 @@ func (ec *executionContext) _AuthResponse(ctx context.Context, sel ast.Selection 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 "should_offer_webauthn_mfa_setup": + out.Values[i] = ec._AuthResponse_should_offer_webauthn_mfa_setup(ctx, field, obj) + case "should_offer_email_otp_mfa_setup": + out.Values[i] = ec._AuthResponse_should_offer_email_otp_mfa_setup(ctx, field, obj) + case "should_offer_sms_otp_mfa_setup": + out.Values[i] = ec._AuthResponse_should_offer_sms_otp_mfa_setup(ctx, field, obj) case "access_token": out.Values[i] = ec._AuthResponse_access_token(ctx, field, obj) case "id_token": @@ -37562,6 +38704,34 @@ func (ec *executionContext) _Mutation(ctx context.Context, sel ast.SelectionSet) if out.Values[i] == graphql.Null { out.Invalids++ } + case "lock_mfa": + out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { + return ec._Mutation_lock_mfa(ctx, field) + }) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "email_otp_mfa_setup": + out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { + return ec._Mutation_email_otp_mfa_setup(ctx, field) + }) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "sms_otp_mfa_setup": + out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { + return ec._Mutation_sms_otp_mfa_setup(ctx, field) + }) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "totp_mfa_setup": + out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { + return ec._Mutation_totp_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) @@ -39703,19 +40873,19 @@ func (ec *executionContext) _User(ctx context.Context, sel ast.SelectionSet, obj case "id": out.Values[i] = ec._User_id(ctx, field, obj) if out.Values[i] == graphql.Null { - out.Invalids++ + atomic.AddUint32(&out.Invalids, 1) } case "email": out.Values[i] = ec._User_email(ctx, field, obj) case "email_verified": out.Values[i] = ec._User_email_verified(ctx, field, obj) if out.Values[i] == graphql.Null { - out.Invalids++ + atomic.AddUint32(&out.Invalids, 1) } case "signup_methods": out.Values[i] = ec._User_signup_methods(ctx, field, obj) if out.Values[i] == graphql.Null { - out.Invalids++ + atomic.AddUint32(&out.Invalids, 1) } case "given_name": out.Values[i] = ec._User_given_name(ctx, field, obj) @@ -39736,14 +40906,14 @@ func (ec *executionContext) _User(ctx context.Context, sel ast.SelectionSet, obj case "phone_number_verified": out.Values[i] = ec._User_phone_number_verified(ctx, field, obj) if out.Values[i] == graphql.Null { - out.Invalids++ + atomic.AddUint32(&out.Invalids, 1) } case "picture": out.Values[i] = ec._User_picture(ctx, field, obj) case "roles": out.Values[i] = ec._User_roles(ctx, field, obj) if out.Values[i] == graphql.Null { - out.Invalids++ + atomic.AddUint32(&out.Invalids, 1) } case "created_at": out.Values[i] = ec._User_created_at(ctx, field, obj) @@ -39755,6 +40925,44 @@ func (ec *executionContext) _User(ctx context.Context, sel ast.SelectionSet, obj 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 "mfa_locked_at": + out.Values[i] = ec._User_mfa_locked_at(ctx, field, obj) + case "enrolled_mfa_methods": + field := field + + innerFunc := func(ctx context.Context, fs *graphql.FieldSet) (res graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + } + }() + res = ec._User_enrolled_mfa_methods(ctx, field, obj) + if res == graphql.Null { + atomic.AddUint32(&fs.Invalids, 1) + } + return res + } + + if field.Deferrable != nil { + dfs, ok := deferred[field.Deferrable.Label] + di := 0 + if ok { + dfs.AddField(field) + di = len(dfs.Values) - 1 + } else { + dfs = graphql.NewFieldSet([]graphql.CollectedField{field}) + deferred[field.Deferrable.Label] = dfs + } + dfs.Concurrently(di, func(ctx context.Context) graphql.Marshaler { + return innerFunc(ctx, dfs) + }) + + // don't run the out.Concurrently() call below + out.Values[i] = graphql.Null + continue + } + + out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) case "app_data": out.Values[i] = ec._User_app_data(ctx, field, obj) default: @@ -41429,6 +42637,11 @@ func (ec *executionContext) marshalNListPermissionsResponse2ᚖgithubᚗcomᚋau return ec._ListPermissionsResponse(ctx, sel, v) } +func (ec *executionContext) unmarshalNLockMfaRequest2githubᚗcomᚋauthorizerdevᚋauthorizerᚋinternalᚋgraphᚋmodelᚐLockMfaRequest(ctx context.Context, v any) (model.LockMfaRequest, error) { + res, err := ec.unmarshalInputLockMfaRequest(ctx, v) + return res, graphql.ErrorOnPath(ctx, err) +} + func (ec *executionContext) unmarshalNLoginRequest2githubᚗcomᚋauthorizerdevᚋauthorizerᚋinternalᚋgraphᚋmodelᚐLoginRequest(ctx context.Context, v any) (model.LoginRequest, error) { res, err := ec.unmarshalInputLoginRequest(ctx, v) return res, graphql.ErrorOnPath(ctx, err) @@ -41937,6 +43150,11 @@ func (ec *executionContext) unmarshalNSignUpRequest2githubᚗcomᚋauthorizerdev return res, graphql.ErrorOnPath(ctx, err) } +func (ec *executionContext) unmarshalNSkipMfaSetupRequest2githubᚗcomᚋauthorizerdevᚋauthorizerᚋinternalᚋgraphᚋmodelᚐSkipMfaSetupRequest(ctx context.Context, v any) (model.SkipMfaSetupRequest, error) { + res, err := ec.unmarshalInputSkipMfaSetupRequest(ctx, v) + return res, graphql.ErrorOnPath(ctx, err) +} + func (ec *executionContext) unmarshalNString2string(ctx context.Context, v any) (string, error) { res, err := graphql.UnmarshalString(v) return res, graphql.ErrorOnPath(ctx, err) @@ -43061,6 +44279,14 @@ func (ec *executionContext) unmarshalOMobileSignUpRequest2ᚖgithubᚗcomᚋauth return &res, graphql.ErrorOnPath(ctx, err) } +func (ec *executionContext) unmarshalOOtpMfaSetupRequest2ᚖgithubᚗcomᚋauthorizerdevᚋauthorizerᚋinternalᚋgraphᚋmodelᚐOtpMfaSetupRequest(ctx context.Context, v any) (*model.OtpMfaSetupRequest, error) { + if v == nil { + return nil, nil + } + res, err := ec.unmarshalInputOtpMfaSetupRequest(ctx, v) + return &res, graphql.ErrorOnPath(ctx, err) +} + func (ec *executionContext) unmarshalOPaginatedRequest2ᚖgithubᚗcomᚋauthorizerdevᚋauthorizerᚋinternalᚋgraphᚋmodelᚐPaginatedRequest(ctx context.Context, v any) (*model.PaginatedRequest, error) { if v == nil { return nil, nil diff --git a/internal/graph/model/models_gen.go b/internal/graph/model/models_gen.go index 4c9ee7aa..2c9c39e2 100644 --- a/internal/graph/model/models_gen.go +++ b/internal/graph/model/models_gen.go @@ -84,6 +84,9 @@ type AuthResponse struct { 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"` + ShouldOfferWebauthnMfaSetup *bool `json:"should_offer_webauthn_mfa_setup,omitempty"` + ShouldOfferEmailOtpMfaSetup *bool `json:"should_offer_email_otp_mfa_setup,omitempty"` + ShouldOfferSmsOtpMfaSetup *bool `json:"should_offer_sms_otp_mfa_setup,omitempty"` AccessToken *string `json:"access_token,omitempty"` IDToken *string `json:"id_token,omitempty"` RefreshToken *string `json:"refresh_token,omitempty"` @@ -430,6 +433,11 @@ type ListWebhookLogRequest struct { WebhookID *string `json:"webhook_id,omitempty"` } +type LockMfaRequest struct { + Email *string `json:"email,omitempty"` + PhoneNumber *string `json:"phone_number,omitempty"` +} + type LoginRequest struct { Email *string `json:"email,omitempty"` PhoneNumber *string `json:"phone_number,omitempty"` @@ -604,6 +612,11 @@ type Organizations struct { Organizations []*Organization `json:"organizations"` } +type OtpMfaSetupRequest struct { + Email *string `json:"email,omitempty"` + PhoneNumber *string `json:"phone_number,omitempty"` +} + type PaginatedRequest struct { Pagination *PaginationRequest `json:"pagination,omitempty"` } @@ -713,6 +726,12 @@ type SignUpRequest struct { AppData map[string]any `json:"app_data,omitempty"` } +type SkipMfaSetupRequest struct { + Email *string `json:"email,omitempty"` + PhoneNumber *string `json:"phone_number,omitempty"` + State *string `json:"state,omitempty"` +} + type TestEndpointRequest struct { Endpoint string `json:"endpoint"` EventName string `json:"event_name"` @@ -913,6 +932,7 @@ type UpdateUserRequest struct { Picture *string `json:"picture,omitempty"` Roles []*string `json:"roles,omitempty"` IsMultiFactorAuthEnabled *bool `json:"is_multi_factor_auth_enabled,omitempty"` + ResetMfa *bool `json:"reset_mfa,omitempty"` AppData map[string]any `json:"app_data,omitempty"` } @@ -946,6 +966,8 @@ type User struct { RevokedTimestamp *int64 `json:"revoked_timestamp,omitempty"` IsMultiFactorAuthEnabled *bool `json:"is_multi_factor_auth_enabled,omitempty"` HasSkippedMfaSetupAt *int64 `json:"has_skipped_mfa_setup_at,omitempty"` + MfaLockedAt *int64 `json:"mfa_locked_at,omitempty"` + EnrolledMfaMethods []string `json:"enrolled_mfa_methods"` AppData map[string]any `json:"app_data,omitempty"` } @@ -1050,8 +1072,11 @@ type WebauthnRegistrationOptionsResponse struct { } type WebauthnRegistrationVerifyRequest struct { - Name *string `json:"name,omitempty"` - Credential string `json:"credential"` + Name *string `json:"name,omitempty"` + Credential string `json:"credential"` + Email *string `json:"email,omitempty"` + PhoneNumber *string `json:"phone_number,omitempty"` + State *string `json:"state,omitempty"` } type Webhook struct { diff --git a/internal/graph/schema.graphqls b/internal/graph/schema.graphqls index 8c7f3d42..564fa561 100644 --- a/internal/graph/schema.graphqls +++ b/internal/graph/schema.graphqls @@ -90,6 +90,17 @@ type User { # 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 + # mfa_locked_at is set once the user reports losing access to their only + # MFA factor(s) with no OTP fallback enrolled. Null means not locked. Only + # an admin can clear it (_update_user with reset_mfa: true). + mfa_locked_at: Int64 + # enrolled_mfa_methods lists the MFA factors this user has actually + # verified/enrolled: any of "totp", "webauthn", "email_otp", "sms_otp". + # Distinct from is_multi_factor_auth_enabled (a required-at-login flag) — + # this reflects real enrollment rows in storage. Lazily resolved: computed + # only when selected, so login/profile/auth responses that don't ask for it + # pay nothing. Empty list means nothing enrolled. + enrolled_mfa_methods: [String!]! app_data: Map } @@ -132,12 +143,23 @@ type AuthResponse { # 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 is DEPRECATED and never set to true anymore: the + # first-time-offer case now withholds access_token (see + # should_show_totp_screen/should_offer_webauthn_mfa_setup) instead of + # issuing a token alongside an "offer" flag. Field kept (unset) because + # older integration tests still assert its zero value; do not read it. should_offer_mfa_setup: Boolean + # should_offer_webauthn_mfa_setup is true, alongside should_show_totp_screen, + # when this is a first-time optional-MFA offer (mfaGateOfferAll) and + # WebAuthn is enabled server-wide as an MFA factor. access_token is NOT + # populated alongside this flag — unlike the old should_offer_mfa_setup, + # the token is withheld until the user completes a method or skips. + should_offer_webauthn_mfa_setup: Boolean + # should_offer_email_otp_mfa_setup / should_offer_sms_otp_mfa_setup: same + # shape, for email/SMS OTP — true only when that method's provider is + # configured AND the user has no verified enrollment for it yet. + should_offer_email_otp_mfa_setup: Boolean + should_offer_sms_otp_mfa_setup: Boolean access_token: String id_token: String refresh_token: String @@ -187,6 +209,13 @@ input WebauthnRegistrationVerifyRequest { # JSON-encoded PublicKeyCredential attestation response from # navigator.credentials.create(). credential: String! + # The following three fields are only used on the MFA-session-cookie path + # (mfaGateOfferAll/mfaGateBlockEnroll passkey enrollment) — ignored for an + # ordinary bearer-token-authenticated settings-page caller. Same shape as + # SkipMfaSetupRequest/VerifyOTPRequest. + email: String + phone_number: String + state: String } input WebauthnLoginVerifyRequest { @@ -822,6 +851,11 @@ input UpdateUserRequest { picture: String roles: [String] is_multi_factor_auth_enabled: Boolean + # reset_mfa, when true, clears the user's entire MFA state: mfa_locked_at, + # is_multi_factor_auth_enabled, has_skipped_mfa_setup_at, and deletes all + # enrolled authenticators/passkeys. The user's next login lands back on + # the first-time setup screen, same as a brand-new account. + reset_mfa: Boolean app_data: Map } @@ -1225,6 +1259,33 @@ input VerifyOTPRequest { state: String } +input SkipMfaSetupRequest { + # either email or phone_number is required, whichever the pending login + # used, to resolve which user's MFA session cookie this is. + email: String + phone_number: String + # state is used for authorization code grant flow, same as VerifyOTPRequest. + state: String +} + +input LockMfaRequest { + # either email or phone_number is required, to resolve which user's MFA + # session cookie this is — same pattern as SkipMfaSetupRequest. + email: String + phone_number: String +} + +input OtpMfaSetupRequest { + # Only used in the MFA-session-cookie mode of email_otp_mfa_setup / + # sms_otp_mfa_setup (a caller in the withheld first-time-offer state, with + # no bearer token yet) to resolve which user's MFA session cookie this is + # — same pattern as SkipMfaSetupRequest/LockMfaRequest. Ignored when the + # caller has a valid bearer token/session, which already identifies the + # user (the existing settings-screen "add a second factor" flow). + email: String + phone_number: String +} + input ResendOTPRequest { email: String phone_number: String @@ -1340,17 +1401,57 @@ 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! + # skip_mfa_setup completes an in-progress, token-withheld MFA offer by + # recording that the caller explicitly declined it, then issues the + # access token that was withheld. Identified by the MFA session cookie + # (set when the offer screen was returned) plus email/phone_number to + # resolve the pending user — same identification pattern as verify_otp. + # Fails with FAILED_PRECONDITION if MFA is organization-enforced + # (enforce-mfa) — enforcement is never skippable. + skip_mfa_setup(params: SkipMfaSetupRequest!): AuthResponse! + # lock_mfa records that the caller lost access to their only MFA + # factor(s). Only allowed when the caller has NO verified Email/SMS OTP + # fallback enrolled — if one exists, use it instead of locking. Does not + # issue a token; the account requires admin recovery afterward. + lock_mfa(params: LockMfaRequest!): Response! + # email_otp_mfa_setup sends a one-time code to the caller's own email and + # creates an unverified email-OTP MFA enrollment. Dual-mode permissions: + # (a) an authenticated caller (bearer token) — the settings-screen action + # for an ALREADY-logged-in user adding a second factor; params is unused + # in this mode. (b) a caller in the withheld first-time-offer state, with + # no bearer token yet — identified by the MFA session cookie plus + # params.email/phone_number, same pattern as skip_mfa_setup. Either mode + # reuses the same underlying Authenticator row once verify_otp marks it + # verified. + email_otp_mfa_setup(params: OtpMfaSetupRequest): Response! + # sms_otp_mfa_setup sends a one-time code to the caller's own phone number + # and creates an unverified SMS-OTP MFA enrollment. Same dual-mode + # permissions and relationship to verify_otp as email_otp_mfa_setup. + sms_otp_mfa_setup(params: OtpMfaSetupRequest): Response! + # totp_mfa_setup generates a fresh TOTP secret/QR/recovery-codes for the + # caller to enroll as an MFA method. Same dual-mode permissions as + # email_otp_mfa_setup/sms_otp_mfa_setup. Unlike those, nothing is sent + # anywhere - the enrollment payload is returned directly (same + # authenticator_scanner_image/authenticator_secret/authenticator_recovery_codes + # fields the login/signup gate response uses) so the caller scans the + # QR/enters the code, then completes enrollment via verify_otp(is_totp: true). + totp_mfa_setup(params: OtpMfaSetupRequest): AuthResponse! # WebAuthn / passkey self-service ceremonies (no admin `_` prefix). + # + # Both also accept an MFA-session-cookie caller in addition to a bearer + # token: during a token-withheld mfaGateOfferAll/mfaGateBlockEnroll offer + # (see mfa_gate.go), registering a passkey completes the gate the same way + # totp_mfa_setup + verify_otp(is_totp: true) does, so + # webauthn_registration_verify returns AuthResponse (access_token set only + # on that session-resolved path; nil, message-only, for the ordinary + # authenticated-settings-page caller). webauthn_registration_options( email: String + phone_number: String ): WebauthnRegistrationOptionsResponse! webauthn_registration_verify( params: WebauthnRegistrationVerifyRequest! - ): Response! + ): AuthResponse! webauthn_login_options(email: String): WebauthnLoginOptionsResponse! webauthn_login_verify(params: WebauthnLoginVerifyRequest!): AuthResponse! webauthn_delete_credential(id: ID!): Response! diff --git a/internal/graph/schema.resolvers.go b/internal/graph/schema.resolvers.go index fba97056..b7835df1 100644 --- a/internal/graph/schema.resolvers.go +++ b/internal/graph/schema.resolvers.go @@ -83,17 +83,37 @@ func (r *mutationResolver) ResendOtp(ctx context.Context, params model.ResendOTP } // 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) +func (r *mutationResolver) SkipMfaSetup(ctx context.Context, params model.SkipMfaSetupRequest) (*model.AuthResponse, error) { + return r.GraphQLProvider.SkipMFASetup(ctx, ¶ms) +} + +// LockMfa is the resolver for the lock_mfa field. +func (r *mutationResolver) LockMfa(ctx context.Context, params model.LockMfaRequest) (*model.Response, error) { + return r.GraphQLProvider.LockMFA(ctx, ¶ms) +} + +// EmailOtpMfaSetup is the resolver for the email_otp_mfa_setup field. +func (r *mutationResolver) EmailOtpMfaSetup(ctx context.Context, params *model.OtpMfaSetupRequest) (*model.Response, error) { + return r.GraphQLProvider.EmailOTPMFASetup(ctx, params) +} + +// SmsOtpMfaSetup is the resolver for the sms_otp_mfa_setup field. +func (r *mutationResolver) SmsOtpMfaSetup(ctx context.Context, params *model.OtpMfaSetupRequest) (*model.Response, error) { + return r.GraphQLProvider.SMSOTPMFASetup(ctx, params) +} + +// TotpMfaSetup is the resolver for the totp_mfa_setup field. +func (r *mutationResolver) TotpMfaSetup(ctx context.Context, params *model.OtpMfaSetupRequest) (*model.AuthResponse, error) { + return r.GraphQLProvider.TOTPMFASetup(ctx, params) } // 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) +func (r *mutationResolver) WebauthnRegistrationOptions(ctx context.Context, email *string, phoneNumber *string) (*model.WebauthnRegistrationOptionsResponse, error) { + return r.GraphQLProvider.WebauthnRegistrationOptions(ctx, email, phoneNumber) } // WebauthnRegistrationVerify is the resolver for the webauthn_registration_verify field. -func (r *mutationResolver) WebauthnRegistrationVerify(ctx context.Context, params model.WebauthnRegistrationVerifyRequest) (*model.Response, error) { +func (r *mutationResolver) WebauthnRegistrationVerify(ctx context.Context, params model.WebauthnRegistrationVerifyRequest) (*model.AuthResponse, error) { return r.GraphQLProvider.WebauthnRegistrationVerify(ctx, ¶ms) } @@ -522,11 +542,20 @@ func (r *queryResolver) ListPermissions(ctx context.Context, params model.ListPe return r.GraphQLProvider.ListPermissions(ctx, ¶ms) } +// EnrolledMfaMethods is the resolver for the enrolled_mfa_methods field. +func (r *userResolver) EnrolledMfaMethods(ctx context.Context, obj *model.User) ([]string, error) { + return r.GraphQLProvider.EnrolledMFAMethods(ctx, obj) +} + // Mutation returns generated.MutationResolver implementation. func (r *Resolver) Mutation() generated.MutationResolver { return &mutationResolver{r} } // Query returns generated.QueryResolver implementation. func (r *Resolver) Query() generated.QueryResolver { return &queryResolver{r} } +// User returns generated.UserResolver implementation. +func (r *Resolver) User() generated.UserResolver { return &userResolver{r} } + type mutationResolver struct{ *Resolver } type queryResolver struct{ *Resolver } +type userResolver struct{ *Resolver } diff --git a/internal/graphql/lock_mfa.go b/internal/graphql/lock_mfa.go new file mode 100644 index 00000000..77d6cd27 --- /dev/null +++ b/internal/graphql/lock_mfa.go @@ -0,0 +1,28 @@ +// internal/graphql/lock_mfa.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" +) + +// LockMFA delegates to the transport-agnostic service layer. +// Permissions: none. +func (g *graphqlProvider) LockMFA(ctx context.Context, params *model.LockMfaRequest) (*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.LockMFA(ctx, service.MetaFromGin(gc), params) + if err != nil { + return nil, err + } + service.ApplyToGin(gc, side) + return res, nil +} diff --git a/internal/graphql/otp_mfa_setup.go b/internal/graphql/otp_mfa_setup.go new file mode 100644 index 00000000..ac51ca60 --- /dev/null +++ b/internal/graphql/otp_mfa_setup.go @@ -0,0 +1,62 @@ +// internal/graphql/otp_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" +) + +// EmailOTPMFASetup delegates to the transport-agnostic service layer. +// Permissions: authenticated user (bearer token) OR MFA session cookie. +func (g *graphqlProvider) EmailOTPMFASetup(ctx context.Context, params *model.OtpMfaSetupRequest) (*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.EmailOTPMFASetup(ctx, service.MetaFromGin(gc), params) + if err != nil { + return nil, err + } + service.ApplyToGin(gc, side) + return res, nil +} + +// SMSOTPMFASetup delegates to the transport-agnostic service layer. +// Permissions: authenticated user (bearer token) OR MFA session cookie. +func (g *graphqlProvider) SMSOTPMFASetup(ctx context.Context, params *model.OtpMfaSetupRequest) (*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.SMSOTPMFASetup(ctx, service.MetaFromGin(gc), params) + if err != nil { + return nil, err + } + service.ApplyToGin(gc, side) + return res, nil +} + +// TOTPMFASetup delegates to the transport-agnostic service layer. +// Permissions: authenticated user (bearer token) OR MFA session cookie. +func (g *graphqlProvider) TOTPMFASetup(ctx context.Context, params *model.OtpMfaSetupRequest) (*model.AuthResponse, 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.TOTPMFASetup(ctx, service.MetaFromGin(gc), params) + if err != nil { + return nil, err + } + service.ApplyToGin(gc, side) + return res, nil +} diff --git a/internal/graphql/provider.go b/internal/graphql/provider.go index 11d7ea71..2933587c 100644 --- a/internal/graphql/provider.go +++ b/internal/graphql/provider.go @@ -96,9 +96,30 @@ 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) + // SkipMFASetup completes a token-withheld first-time MFA offer by + // recording the decline and issuing the previously-withheld token. + // Permissions: none — identified via the MFA session cookie, not a + // bearer token. + SkipMFASetup(ctx context.Context, params *model.SkipMfaSetupRequest) (*model.AuthResponse, error) + // LockMFA records that the caller lost access to their only MFA + // factor(s), refusing when a verified OTP fallback exists. Does not + // issue a token. + // Permissions: none — identified via the MFA session cookie, not a + // bearer token. + LockMFA(ctx context.Context, params *model.LockMfaRequest) (*model.Response, error) + // EmailOTPMFASetup sends a one-time code to the caller's own email and + // begins an email-OTP MFA enrollment. Verified via VerifyOtp. Dual-mode: + // bearer token (params ignored) or, absent a token, the MFA session + // cookie plus params.email/phone_number. + // Permissions: authorized user (bearer token) OR MFA session cookie. + EmailOTPMFASetup(ctx context.Context, params *model.OtpMfaSetupRequest) (*model.Response, error) + // SMSOTPMFASetup is EmailOTPMFASetup's SMS twin. + // Permissions: authorized user (bearer token) OR MFA session cookie. + SMSOTPMFASetup(ctx context.Context, params *model.OtpMfaSetupRequest) (*model.Response, error) + // TOTPMFASetup generates a fresh TOTP secret/QR/recovery-codes for the + // caller to enroll as an MFA method. Same dual-mode permissions as + // EmailOTPMFASetup/SMSOTPMFASetup. + TOTPMFASetup(ctx context.Context, params *model.OtpMfaSetupRequest) (*model.AuthResponse, error) // DeleteEmailTemplate is the method to delete email template. // Permissions: authorizer:admin DeleteEmailTemplate(ctx context.Context, params *model.DeleteEmailTemplateRequest) (*model.Response, error) @@ -140,6 +161,10 @@ type Provider interface { // Profile is the method to get profile. // Permissions: authorized user Profile(ctx context.Context) (*model.User, error) + // EnrolledMFAMethods resolves the lazily-computed User.enrolled_mfa_methods + // field for a resolved User. Only invoked when the field is selected. + // Permissions: inherited from the parent User query. + EnrolledMFAMethods(ctx context.Context, user *model.User) ([]string, error) // ResendOTP is the method to resend OTP. // Permissions: none ResendOTP(ctx context.Context, params *model.ResendOTPRequest) (*model.Response, error) @@ -202,10 +227,10 @@ type Provider interface { VerifyOTP(ctx context.Context, params *model.VerifyOTPRequest) (*model.AuthResponse, error) // WebauthnRegistrationOptions begins a passkey registration ceremony. // Permissions: authenticated:user - WebauthnRegistrationOptions(ctx context.Context, email *string) (*model.WebauthnRegistrationOptionsResponse, error) + WebauthnRegistrationOptions(ctx context.Context, email, phoneNumber *string) (*model.WebauthnRegistrationOptionsResponse, error) // WebauthnRegistrationVerify stores a newly registered passkey. // Permissions: authenticated:user - WebauthnRegistrationVerify(ctx context.Context, params *model.WebauthnRegistrationVerifyRequest) (*model.Response, error) + WebauthnRegistrationVerify(ctx context.Context, params *model.WebauthnRegistrationVerifyRequest) (*model.AuthResponse, error) // WebauthnLoginOptions begins a passkey login ceremony. // Permissions: none WebauthnLoginOptions(ctx context.Context, email *string) (*model.WebauthnLoginOptionsResponse, error) diff --git a/internal/graphql/skip_mfa_setup.go b/internal/graphql/skip_mfa_setup.go index 8e15a13c..58655695 100644 --- a/internal/graphql/skip_mfa_setup.go +++ b/internal/graphql/skip_mfa_setup.go @@ -10,14 +10,16 @@ import ( "github.com/authorizerdev/authorizer/internal/utils" ) -func (g *graphqlProvider) SkipMFASetup(ctx context.Context) (*model.Response, error) { +// SkipMFASetup delegates to the transport-agnostic service layer. +// Permissions: none. +func (g *graphqlProvider) SkipMFASetup(ctx context.Context, params *model.SkipMfaSetupRequest) (*model.AuthResponse, 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)) + res, side, err := g.ServiceProvider.SkipMFASetup(ctx, service.MetaFromGin(gc), params) if err != nil { return nil, err } diff --git a/internal/graphql/user.go b/internal/graphql/user.go index 5bc2a88b..d83e5e07 100644 --- a/internal/graphql/user.go +++ b/internal/graphql/user.go @@ -23,3 +23,20 @@ func (g *graphqlProvider) User(ctx context.Context, params *model.GetUserRequest res, _, err := g.adminService().User(ctx, service.MetaFromGin(gc), params) return res, err } + +// EnrolledMFAMethods backs the lazily-resolved User.enrolled_mfa_methods field. +// It is only invoked when a query selects that field, so it never runs on the +// login/signup/profile paths that don't ask for it. +// +// No auth guard here: the field takes no argument and only ever resolves the +// already-authorized parent User (self via profile/session, or an admin via +// _users/_user, which are guarded at the parent query). There is no +// user-supplied ID and thus no account-enumeration vector. +// +// Permissions: inherited from the parent User query. +func (g *graphqlProvider) EnrolledMFAMethods(ctx context.Context, user *model.User) ([]string, error) { + if user == nil { + return []string{}, nil + } + return g.ServiceProvider.EnrolledMFAMethods(ctx, user.ID) +} diff --git a/internal/graphql/webauthn.go b/internal/graphql/webauthn.go index b247dbc0..e2d33daf 100644 --- a/internal/graphql/webauthn.go +++ b/internal/graphql/webauthn.go @@ -10,27 +10,32 @@ import ( ) // WebauthnRegistrationOptions delegates to the transport-agnostic service layer. -// Permissions: authenticated:user. -func (g *graphqlProvider) WebauthnRegistrationOptions(ctx context.Context, email *string) (*model.WebauthnRegistrationOptionsResponse, error) { +// Permissions: authenticated:user, or an MFA-session-cookie caller mid-offer. +func (g *graphqlProvider) WebauthnRegistrationOptions(ctx context.Context, email, phoneNumber *string) (*model.WebauthnRegistrationOptionsResponse, 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 } - return g.ServiceProvider.WebauthnRegistrationOptions(ctx, service.MetaFromGin(gc), email) + return g.ServiceProvider.WebauthnRegistrationOptions(ctx, service.MetaFromGin(gc), email, phoneNumber) } // WebauthnRegistrationVerify delegates to the transport-agnostic service layer. -// Permissions: authenticated:user. -func (g *graphqlProvider) WebauthnRegistrationVerify(ctx context.Context, params *model.WebauthnRegistrationVerifyRequest) (*model.Response, error) { +// Permissions: authenticated:user, or an MFA-session-cookie caller mid-offer. +func (g *graphqlProvider) WebauthnRegistrationVerify(ctx context.Context, params *model.WebauthnRegistrationVerifyRequest) (*model.AuthResponse, 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 } - return g.ServiceProvider.WebauthnRegistrationVerify(ctx, service.MetaFromGin(gc), params) + res, side, err := g.ServiceProvider.WebauthnRegistrationVerify(ctx, service.MetaFromGin(gc), params) + if err != nil { + return nil, err + } + service.ApplyToGin(gc, side) + return res, nil } // WebauthnLoginOptions delegates to the transport-agnostic service layer. diff --git a/internal/grpcsrv/handlers/admin.go b/internal/grpcsrv/handlers/admin.go index b6c3de08..ccc6e1d8 100644 --- a/internal/grpcsrv/handlers/admin.go +++ b/internal/grpcsrv/handlers/admin.go @@ -115,6 +115,7 @@ func (h *AdminHandler) UpdateUser(ctx context.Context, req *authorizerv1.UpdateU Picture: req.Picture, Roles: protoToModelStringSlice(req.GetRoles()), IsMultiFactorAuthEnabled: req.IsMultiFactorAuthEnabled, + ResetMfa: req.ResetMfa, AppData: appDataToMap(req.GetAppData()), }) if err != nil { diff --git a/internal/grpcsrv/handlers/authorizer.go b/internal/grpcsrv/handlers/authorizer.go index 3f9977bd..dec82c9f 100644 --- a/internal/grpcsrv/handlers/authorizer.go +++ b/internal/grpcsrv/handlers/authorizer.go @@ -231,6 +231,68 @@ func (h *AuthorizerHandler) ResendOtp(ctx context.Context, req *authorizerv1.Res return &authorizerv1.ResendOtpResponse{Message: res.Message}, nil } +// SkipMfaSetup delegates to service.SkipMFASetup, applies the withheld-token +// cookie side-effect to the outgoing stream, and projects the AuthResponse. +// Public — identified by the MFA session cookie plus email/phone_number, same +// as VerifyOtp. +func (h *AuthorizerHandler) SkipMfaSetup(ctx context.Context, req *authorizerv1.SkipMfaSetupRequest) (*authorizerv1.AuthResponse, error) { + res, side, err := h.Service.SkipMFASetup(ctx, transport.MetaFromGRPC(ctx), &model.SkipMfaSetupRequest{ + Email: optionalString(req.Email), + PhoneNumber: optionalString(req.PhoneNumber), + State: optionalString(req.State), + }) + if err != nil { + return nil, err + } + _ = transport.ApplyToGRPC(ctx, side) + return projectAuthResponse(res), nil +} + +// LockMfa delegates to service.LockMFA. Public — identified by the MFA +// session cookie plus email/phone_number, same as SkipMfaSetup. +func (h *AuthorizerHandler) LockMfa(ctx context.Context, req *authorizerv1.LockMfaRequest) (*authorizerv1.LockMfaResponse, error) { + res, side, err := h.Service.LockMFA(ctx, transport.MetaFromGRPC(ctx), &model.LockMfaRequest{ + Email: optionalString(req.Email), + PhoneNumber: optionalString(req.PhoneNumber), + }) + if err != nil { + return nil, err + } + _ = transport.ApplyToGRPC(ctx, side) + return &authorizerv1.LockMfaResponse{Message: res.Message}, nil +} + +// EmailOtpMfaSetup delegates to service.EmailOTPMFASetup. Public at the +// transport layer — the service resolves the caller from either a bearer +// token/session (settings-screen "add a second factor") or, absent one, the +// MFA session cookie plus email/phone_number (withheld first-time-offer +// state), same dual-mode pattern as the GraphQL resolver. +func (h *AuthorizerHandler) EmailOtpMfaSetup(ctx context.Context, req *authorizerv1.EmailOtpMfaSetupRequest) (*authorizerv1.EmailOtpMfaSetupResponse, error) { + res, side, err := h.Service.EmailOTPMFASetup(ctx, transport.MetaFromGRPC(ctx), &model.OtpMfaSetupRequest{ + Email: optionalString(req.Email), + PhoneNumber: optionalString(req.PhoneNumber), + }) + if err != nil { + return nil, err + } + _ = transport.ApplyToGRPC(ctx, side) + return &authorizerv1.EmailOtpMfaSetupResponse{Message: res.Message}, nil +} + +// SmsOtpMfaSetup delegates to service.SMSOTPMFASetup. Same dual-mode +// permissions and relationship to VerifyOtp as EmailOtpMfaSetup. +func (h *AuthorizerHandler) SmsOtpMfaSetup(ctx context.Context, req *authorizerv1.SmsOtpMfaSetupRequest) (*authorizerv1.SmsOtpMfaSetupResponse, error) { + res, side, err := h.Service.SMSOTPMFASetup(ctx, transport.MetaFromGRPC(ctx), &model.OtpMfaSetupRequest{ + Email: optionalString(req.Email), + PhoneNumber: optionalString(req.PhoneNumber), + }) + if err != nil { + return nil, err + } + _ = transport.ApplyToGRPC(ctx, side) + return &authorizerv1.SmsOtpMfaSetupResponse{Message: res.Message}, nil +} + // ForgotPassword delegates to service.ForgotPassword. Public — the response is // generic to avoid account enumeration. Applies any MFA-session cookie // side-effects (SMS flow) to the outgoing stream. @@ -405,5 +467,6 @@ func (h *AuthorizerHandler) Meta(ctx context.Context, _ *authorizerv1.MetaReques IsEmailOtpMfaEnabled: m.IsEmailOtpMfaEnabled, IsSmsOtpMfaEnabled: m.IsSmsOtpMfaEnabled, IsWebauthnEnabled: m.IsWebauthnEnabled, + IsMfaEnforced: m.IsMfaEnforced, }, nil } diff --git a/internal/grpcsrv/handlers/project.go b/internal/grpcsrv/handlers/project.go index 42c406e2..b314ee1d 100644 --- a/internal/grpcsrv/handlers/project.go +++ b/internal/grpcsrv/handlers/project.go @@ -42,6 +42,8 @@ func projectUser(u *model.User) *authorizerv1.User { UpdatedAt: refs.Int64Value(u.UpdatedAt), RevokedTimestamp: refs.Int64Value(u.RevokedTimestamp), IsMultiFactorAuthEnabled: refs.BoolValue(u.IsMultiFactorAuthEnabled), + HasSkippedMfaSetupAt: refs.Int64Value(u.HasSkippedMfaSetupAt), + MfaLockedAt: refs.Int64Value(u.MfaLockedAt), } if u.AppData != nil { out.AppData = mapToAppData(u.AppData) @@ -59,18 +61,22 @@ func projectAuthResponse(a *model.AuthResponse) *authorizerv1.AuthResponse { return nil } return &authorizerv1.AuthResponse{ - Message: a.Message, - ShouldShowEmailOtpScreen: refs.BoolValue(a.ShouldShowEmailOtpScreen), - ShouldShowMobileOtpScreen: refs.BoolValue(a.ShouldShowMobileOtpScreen), - ShouldShowTotpScreen: refs.BoolValue(a.ShouldShowTotpScreen), - AccessToken: refs.StringValue(a.AccessToken), - IdToken: refs.StringValue(a.IDToken), - RefreshToken: refs.StringValue(a.RefreshToken), - ExpiresIn: refs.Int64Value(a.ExpiresIn), - User: projectUser(a.User), - AuthenticatorScannerImage: refs.StringValue(a.AuthenticatorScannerImage), - AuthenticatorSecret: refs.StringValue(a.AuthenticatorSecret), - AuthenticatorRecoveryCodes: derefStringSlice(a.AuthenticatorRecoveryCodes), + Message: a.Message, + ShouldShowEmailOtpScreen: refs.BoolValue(a.ShouldShowEmailOtpScreen), + ShouldShowMobileOtpScreen: refs.BoolValue(a.ShouldShowMobileOtpScreen), + ShouldShowTotpScreen: refs.BoolValue(a.ShouldShowTotpScreen), + AccessToken: refs.StringValue(a.AccessToken), + IdToken: refs.StringValue(a.IDToken), + RefreshToken: refs.StringValue(a.RefreshToken), + ExpiresIn: refs.Int64Value(a.ExpiresIn), + User: projectUser(a.User), + AuthenticatorScannerImage: refs.StringValue(a.AuthenticatorScannerImage), + AuthenticatorSecret: refs.StringValue(a.AuthenticatorSecret), + AuthenticatorRecoveryCodes: derefStringSlice(a.AuthenticatorRecoveryCodes), + ShouldOfferWebauthnMfaVerify: refs.BoolValue(a.ShouldOfferWebauthnMfaVerify), + ShouldOfferWebauthnMfaSetup: refs.BoolValue(a.ShouldOfferWebauthnMfaSetup), + ShouldOfferEmailOtpMfaSetup: refs.BoolValue(a.ShouldOfferEmailOtpMfaSetup), + ShouldOfferSmsOtpMfaSetup: refs.BoolValue(a.ShouldOfferSmsOtpMfaSetup), } } diff --git a/internal/http_handlers/authorize.go b/internal/http_handlers/authorize.go index 53ea2eb9..d81bf825 100644 --- a/internal/http_handlers/authorize.go +++ b/internal/http_handlers/authorize.go @@ -76,23 +76,31 @@ const ( func (h *httpProvider) AuthorizeHandler() gin.HandlerFunc { log := h.Log.With().Str("func", "AuthorizeHandler").Logger() return func(gc *gin.Context) { - redirectURI := strings.TrimSpace(gc.Query("redirect_uri")) - responseType := strings.TrimSpace(gc.Query("response_type")) - state := strings.TrimSpace(gc.Query("state")) - codeChallenge := strings.TrimSpace(gc.Query("code_challenge")) - scopeString := strings.TrimSpace(gc.Query("scope")) - clientID := strings.TrimSpace(gc.Query("client_id")) - responseMode := strings.TrimSpace(gc.Query("response_mode")) + // RFC 6749 §3.1 / OIDC Core §3.1.2.1: the authorization endpoint + // MUST support GET and MAY support POST (form-urlencoded body). + // FormValue reads the POST body first, falling back to the query + // string, so the same handler serves both methods. + param := func(key string) string { + return strings.TrimSpace(gc.Request.FormValue(key)) + } + + redirectURI := param("redirect_uri") + responseType := param("response_type") + state := param("state") + codeChallenge := param("code_challenge") + scopeString := param("scope") + clientID := param("client_id") + responseMode := param("response_mode") rawResponseMode := responseMode - nonce := strings.TrimSpace(gc.Query("nonce")) - screenHint := strings.TrimSpace(gc.Query("screen_hint")) + nonce := param("nonce") + screenHint := param("screen_hint") // OIDC Core §3.1.2.1 standard authorization request parameters. - loginHint := strings.TrimSpace(gc.Query("login_hint")) - uiLocales := strings.TrimSpace(gc.Query("ui_locales")) - prompt := strings.TrimSpace(gc.Query("prompt")) - maxAgeStr := strings.TrimSpace(gc.Query("max_age")) - idTokenHint := strings.TrimSpace(gc.Query("id_token_hint")) + loginHint := param("login_hint") + uiLocales := param("ui_locales") + prompt := param("prompt") + maxAgeStr := param("max_age") + idTokenHint := param("id_token_hint") // max_age is advisory. Parse per OIDC Core §3.1.2.1: // - negative or non-integer → treat as absent (no constraint) @@ -137,7 +145,27 @@ func (h *httpProvider) AuthorizeHandler() gin.HandlerFunc { redirectURI = "/app" } else { hostname := parsers.GetHost(gc) - if !validators.IsValidRedirectURI(redirectURI, h.Config.AllowedOrigins, hostname) { + validRedirect := validators.IsValidRedirectURI(redirectURI, h.Config.AllowedOrigins, hostname) + // RFC 6749 §3.1.2.3 / OAuth 2.0 Security BCP (RFC 9700): once a + // client has registered exact redirect URIs, the presented + // redirect_uri MUST exact-match one of them — never a prefix or + // origin match. IsValidRedirectURI above only checks + // scheme+host+port against the global AllowedOrigins allowlist, + // which alone would let any path under an allowed host through + // (including a suffix appended to another client's callback); + // it remains the fallback for clients with no registered URIs. + if client, err := h.StorageProvider.GetClientByClientID(gc.Request.Context(), clientID); err == nil && client != nil { + if registered := client.ParsedRedirectURIs(); len(registered) > 0 { + validRedirect = false + for _, r := range registered { + if r == redirectURI { + validRedirect = true + break + } + } + } + } + if !validRedirect { log.Debug().Msg("Invalid redirect URI") gc.JSON(http.StatusBadRequest, gin.H{ "error": "invalid_request", @@ -147,11 +175,33 @@ func (h *httpProvider) AuthorizeHandler() gin.HandlerFunc { } } + // OIDCC-3.1.2.6 (JAR, RFC 9101): the server must either process a + // `request`/`request_uri` object or reject it with + // request_not_supported / request_uri_not_supported. We don't parse + // request objects, so reject explicitly rather than silently + // ignoring the parameter and falling through to a confusing + // generic validation error. + if requestObject, requestURI := param("request"), param("request_uri"); requestObject != "" || requestURI != "" { + errCode := "request_not_supported" + errDesc := "the request parameter is not supported" + if requestURI != "" { + errCode = "request_uri_not_supported" + errDesc = "the request_uri parameter is not supported" + } + redirectErrorToRP(gc, responseMode, redirectURI, state, errCode, errDesc) + return + } + + // RFC 6749 §3.1.1: response_type is REQUIRED. gin's Query() can't + // distinguish an absent parameter from an empty one, so both land + // here — reject rather than silently defaulting to an implicit-flow + // token grant the client never asked for. if responseType == "" { - responseType = h.Config.DefaultAuthorizeResponseType + redirectErrorToRP(gc, responseMode, redirectURI, state, "invalid_request", "response_type is required") + return } - codeChallengeMethod := strings.TrimSpace(gc.Query("code_challenge_method")) + codeChallengeMethod := param("code_challenge_method") // RFC 7636 §4.2: "If the client is capable of using // "S256", it MUST use "S256" [...] If the server does not // support the transformation, [...] it MUST return [...]. @@ -335,25 +385,43 @@ func (h *httpProvider) AuthorizeHandler() gin.HandlerFunc { if decryptedFingerPrint, decErr := crypto.DecryptAES(h.ClientSecret, sessionToken); decErr == nil { var sd token.SessionData if jsonErr := json.Unmarshal([]byte(decryptedFingerPrint), &sd); jsonErr == nil { - if time.Now().Unix()-sd.IssuedAt > int64(maxAge) { - log.Debug().Int("max_age", maxAge).Int64("session_age", time.Now().Unix()-sd.IssuedAt).Msg("session exceeds max_age — forcing re-auth") + // Measured against AuthTime (the End-User's actual last + // authentication), not IssuedAt (which refreshes on + // every silent rollover) — otherwise a client can keep + // a session alive past max_age indefinitely by polling + // faster than the max_age window. + authTime := sd.EffectiveAuthTime() + if time.Now().Unix()-authTime > int64(maxAge) { + log.Debug().Int("max_age", maxAge).Int64("session_age", time.Now().Unix()-authTime).Msg("session exceeds max_age — forcing re-auth") forceReauth = true } } } } - // When prompt=login and a valid session cookie exists, don't discard - // it. The normal flow below validates it, performs a session rollover, - // stores the authorization code state, and redirects to the RP. - // Discarding the session here would send the user to the login UI - // where the React SDK auto-detects the still-valid cookie, redirects - // immediately, but the authorization code state is never stored - // because the login mutation is never called. - // - // For max_age=0 or max_age-exceeded, we DO discard the session - // because the spec requires actual re-authentication based on time. - if forceReauth && (prompt != "login" || err != nil || sessionToken == "") { + // OIDC Core §3.1.2.1: prompt=login and an exceeded max_age both + // require actually re-authenticating the user, not just ignoring + // the local `sessionToken` variable for this one request — the + // browser's session cookie stays valid, so the login UI's own + // session check (getSession) still sees a logged-in user and + // immediately bounces back to /authorize without ever rendering a + // login form, and the authorization code state is never stored. + // Revoke the session for real (memory store + cookie) so the login + // UI genuinely sees a logged-out user and forces fresh credentials. + if forceReauth && err == nil && sessionToken != "" { + if decryptedFingerPrint, decErr := crypto.DecryptAES(h.ClientSecret, sessionToken); decErr == nil { + var sd token.SessionData + if jsonErr := json.Unmarshal([]byte(decryptedFingerPrint), &sd); jsonErr == nil { + revokeKey := sd.Subject + if sd.LoginMethod != "" { + revokeKey = sd.LoginMethod + ":" + sd.Subject + } + _ = h.MemoryStoreProvider.DeleteUserSession(revokeKey, sd.Nonce) + } + } + cookie.DeleteSession(gc, h.Config.AppCookieSecure, cookie.ParseSameSite(h.Config.AppCookieSameSite)) + } + if forceReauth { err = errors.New("force reauth") sessionToken = "" } @@ -472,7 +540,8 @@ func (h *httpProvider) AuthorizeHandler() gin.HandlerFunc { Scope: scope, LoginMethod: claims.LoginMethod, HostName: hostname, - AuthTime: claims.IssuedAt, + AuthTime: claims.EffectiveAuthTime(), + ClientID: clientID, }) if err != nil { log.Debug().Err(err).Msg("Error creating auth token for hybrid response") @@ -564,7 +633,8 @@ func (h *httpProvider) AuthorizeHandler() gin.HandlerFunc { Scope: scope, LoginMethod: claims.LoginMethod, HostName: hostname, - AuthTime: claims.IssuedAt, + AuthTime: claims.EffectiveAuthTime(), + ClientID: clientID, }) if err != nil { log.Debug().Err(err).Msg("Error creating auth token for id_token token response") @@ -625,7 +695,7 @@ func (h *httpProvider) AuthorizeHandler() gin.HandlerFunc { Roles: claims.Roles, Scope: scope, LoginMethod: claims.LoginMethod, - AuthTime: claims.IssuedAt, + AuthTime: claims.EffectiveAuthTime(), }) if err != nil { log.Debug().Err(err).Msg("Error creating session token") @@ -711,7 +781,8 @@ func (h *httpProvider) AuthorizeHandler() gin.HandlerFunc { Scope: scope, LoginMethod: claims.LoginMethod, HostName: hostname, - AuthTime: claims.IssuedAt, + AuthTime: claims.EffectiveAuthTime(), + ClientID: clientID, }) if err != nil { log.Debug().Err(err).Msg("Error creating auth token") @@ -763,7 +834,8 @@ func (h *httpProvider) AuthorizeHandler() gin.HandlerFunc { Scope: scope, LoginMethod: claims.LoginMethod, HostName: hostname, - AuthTime: claims.IssuedAt, + AuthTime: claims.EffectiveAuthTime(), + ClientID: clientID, }) if err != nil { log.Debug().Err(err).Msg("Error creating auth token") diff --git a/internal/http_handlers/authorize_force_reauth_test.go b/internal/http_handlers/authorize_force_reauth_test.go new file mode 100644 index 00000000..a1ecae00 --- /dev/null +++ b/internal/http_handlers/authorize_force_reauth_test.go @@ -0,0 +1,123 @@ +package http_handlers + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/rs/zerolog" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/authorizerdev/authorizer/internal/config" + "github.com/authorizerdev/authorizer/internal/constants" + "github.com/authorizerdev/authorizer/internal/crypto" + "github.com/authorizerdev/authorizer/internal/token" +) + +// validSessionCookie builds a cookie value identical in shape to a real +// session: AES-encrypted token.SessionData, matching what cookie.GetSession +// / crypto.DecryptAES on the handler side expect. +func validSessionCookie(t *testing.T, clientSecret string) string { + t.Helper() + sd := token.SessionData{ + Subject: "user-1", + LoginMethod: "basic_auth", + Nonce: "nonce-1", + IssuedAt: time.Now().Unix(), + } + raw, err := json.Marshal(sd) + require.NoError(t, err) + encrypted, err := crypto.EncryptAES(clientSecret, string(raw)) + require.NoError(t, err) + return encrypted +} + +// TestAuthorize_PromptLogin_RevokesExistingSession is a regression test for +// the oidcc-prompt-login conformance failure: prompt=login must force real +// re-authentication. Merely ignoring the session locally left the browser's +// cookie intact, so the login UI's own session check still saw the user as +// logged in and bounced straight back to /authorize without ever showing a +// login form. The fix must revoke the session (memory store) and clear the +// cookie so the login UI genuinely sees a logged-out user. +func TestAuthorize_PromptLogin_RevokesExistingSession(t *testing.T) { + logger := zerolog.Nop() + clientSecret := "test-client-secret" + ms := &fakeMemoryStore{} + h := &httpProvider{ + Config: &config.Config{ + ClientSecret: clientSecret, + AllowedOrigins: []string{"*"}, + }, + Dependencies: Dependencies{Log: &logger, MemoryStoreProvider: ms, StorageProvider: &redirectURIClientStore{}}, + } + + gin.SetMode(gin.TestMode) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodGet, + "/authorize?client_id=abc&state=xyz&response_type=code&response_mode=query&scope=openid&prompt=login&redirect_uri=http%3A%2F%2Fexample.com%2Fapp%2Fcallback", nil) + c.Request.AddCookie(&http.Cookie{ + Name: constants.AppCookieName + "_session", + Value: validSessionCookie(t, clientSecret), + }) + + h.AuthorizeHandler()(c) + + require.Len(t, ms.deletedSessions, 1, "the existing session must be revoked server-side") + assert.Equal(t, [2]string{"basic_auth:user-1", "nonce-1"}, ms.deletedSessions[0]) + + // The old session cookie must be cleared on the response (MaxAge < 0), + // not left for the login UI's session check to find valid. + cleared := false + for _, ck := range rec.Result().Cookies() { + if ck.Name == constants.AppCookieName+"_session" && ck.MaxAge < 0 { + cleared = true + } + } + assert.True(t, cleared, "session cookie must be cleared, not merely ignored") + + // Must redirect to the login UI, not silently complete the authorization. + assert.Equal(t, http.StatusFound, rec.Code) +} + +// TestAuthorize_NoPrompt_ExistingSession_NotRevoked proves the fix is scoped +// to forced-reauth cases only — a plain /authorize request (no prompt, no +// max_age) with a valid session must NOT revoke it; that would break normal +// SSO continuation for every other flow. +func TestAuthorize_NoPrompt_ExistingSession_NotRevoked(t *testing.T) { + logger := zerolog.Nop() + clientSecret := "test-client-secret" + ms := &fakeMemoryStore{} + h := &httpProvider{ + Config: &config.Config{ + ClientSecret: clientSecret, + AllowedOrigins: []string{"*"}, + }, + Dependencies: Dependencies{Log: &logger, MemoryStoreProvider: ms, StorageProvider: &redirectURIClientStore{}}, + } + + gin.SetMode(gin.TestMode) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodGet, + "/authorize?client_id=abc&state=xyz&response_type=code&response_mode=query&scope=openid&redirect_uri=http%3A%2F%2Fexample.com%2Fapp%2Fcallback", nil) + c.Request.AddCookie(&http.Cookie{ + Name: constants.AppCookieName + "_session", + Value: validSessionCookie(t, clientSecret), + }) + + // A plain (non-forceReauth) request proceeds past session revocation + // into normal SSO continuation, which needs a TokenProvider this + // minimal test doesn't construct — irrelevant to what's under test + // here (that revocation is skipped), so recover and only check that. + func() { + defer func() { _ = recover() }() + h.AuthorizeHandler()(c) + }() + + assert.Empty(t, ms.deletedSessions, "a plain request must not revoke an existing session") +} diff --git a/internal/http_handlers/authorize_post_and_jar_test.go b/internal/http_handlers/authorize_post_and_jar_test.go new file mode 100644 index 00000000..e37e76a6 --- /dev/null +++ b/internal/http_handlers/authorize_post_and_jar_test.go @@ -0,0 +1,89 @@ +package http_handlers + +import ( + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "github.com/rs/zerolog" + "github.com/stretchr/testify/assert" + + "github.com/authorizerdev/authorizer/internal/config" +) + +func authorizePostCtx(form url.Values) (*gin.Context, *httptest.ResponseRecorder) { + gin.SetMode(gin.TestMode) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/authorize", strings.NewReader(form.Encode())) + c.Request.Header.Set("Content-Type", "application/x-www-form-urlencoded") + return c, rec +} + +// RFC 6749 §3.1 / OIDC Core §3.1.2.1: the authorization endpoint MUST +// support POST as well as GET (regression test for the +// oidcc-ensure-post-request-succeeds conformance failure — the handler +// used to read parameters via gc.Query() only, so a POST body was +// silently ignored). +func TestAuthorize_POSTFormBody_ParamsAreRead(t *testing.T) { + logger := zerolog.Nop() + h := &httpProvider{ + Config: &config.Config{}, + Dependencies: Dependencies{Log: &logger}, + } + + form := url.Values{ + "client_id": {"abc"}, + "state": {"xyz"}, + "response_type": {"not_a_real_response_type"}, + } + c, rec := authorizePostCtx(form) + h.AuthorizeHandler()(c) + + // If the POST body were ignored, response_type would read as empty and + // the handler would reject with "response_type is required" instead. + // Getting unsupported_response_type proves the value was read from body. + assert.Equal(t, http.StatusBadRequest, rec.Code) + assert.Contains(t, rec.Body.String(), "unsupported_response_type") +} + +// OIDCC-3.1.2.6 (JAR / RFC 9101): a server that does not process request +// objects MUST reject a request/request_uri parameter with +// request_not_supported / request_uri_not_supported rather than silently +// ignoring it and falling through to a confusing generic validation error +// (regression test for the oidcc-unsigned-request-object-supported- +// correctly-or-rejected-as-unsupported conformance failure). +func TestAuthorize_RequestObjectParam_RejectedAsUnsupported(t *testing.T) { + logger := zerolog.Nop() + h := &httpProvider{ + Config: &config.Config{AllowedOrigins: []string{"*"}}, + Dependencies: Dependencies{Log: &logger, StorageProvider: &redirectURIClientStore{}}, + } + + c, rec := authorizeRedirectCtx("client_id=abc&state=xyz&response_type=code&redirect_uri=" + + "http%3A%2F%2Fexample.com%2Fapp%2Fcallback&request=some.jwt.value") + h.AuthorizeHandler()(c) + + assert.Equal(t, http.StatusFound, rec.Code) + location := rec.Header().Get("Location") + assert.Contains(t, location, "error=request_not_supported") +} + +func TestAuthorize_RequestURIParam_RejectedAsUnsupported(t *testing.T) { + logger := zerolog.Nop() + h := &httpProvider{ + Config: &config.Config{AllowedOrigins: []string{"*"}}, + Dependencies: Dependencies{Log: &logger, StorageProvider: &redirectURIClientStore{}}, + } + + c, rec := authorizeRedirectCtx("client_id=abc&state=xyz&response_type=code&redirect_uri=" + + "http%3A%2F%2Fexample.com%2Fapp%2Fcallback&request_uri=https%3A%2F%2Fexample.com%2Frequest.jwt") + h.AuthorizeHandler()(c) + + assert.Equal(t, http.StatusFound, rec.Code) + location := rec.Header().Get("Location") + assert.Contains(t, location, "error=request_uri_not_supported") +} diff --git a/internal/http_handlers/authorize_redirect_uri_test.go b/internal/http_handlers/authorize_redirect_uri_test.go new file mode 100644 index 00000000..52af6b0f --- /dev/null +++ b/internal/http_handlers/authorize_redirect_uri_test.go @@ -0,0 +1,108 @@ +package http_handlers + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/rs/zerolog" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/authorizerdev/authorizer/internal/config" + inmemorystore "github.com/authorizerdev/authorizer/internal/memory_store/in_memory" + "github.com/authorizerdev/authorizer/internal/storage" + "github.com/authorizerdev/authorizer/internal/storage/schemas" +) + +// redirectURIClientStore is a minimal storage.Provider stub serving only +// GetClientByClientID, for exercising AuthorizeHandler's per-client +// redirect_uri exact-match enforcement. +type redirectURIClientStore struct { + storage.Provider + client *schemas.Client +} + +func (s *redirectURIClientStore) GetClientByClientID(_ context.Context, clientID string) (*schemas.Client, error) { + if s.client == nil || s.client.ClientID != clientID { + return nil, errors.New("not found") + } + return s.client, nil +} + +func newRedirectURITestProvider(t *testing.T, client *schemas.Client) *httpProvider { + t.Helper() + logger := zerolog.Nop() + cfg := &config.Config{AllowedOrigins: []string{"*"}} + ms, err := inmemorystore.NewInMemoryProvider(cfg, &inmemorystore.Dependencies{Log: &logger}) + require.NoError(t, err) + return &httpProvider{ + Config: cfg, + Dependencies: Dependencies{ + Log: &logger, + StorageProvider: &redirectURIClientStore{client: client}, + MemoryStoreProvider: ms, + }, + } +} + +func authorizeRedirectCtx(query string) (*gin.Context, *httptest.ResponseRecorder) { + gin.SetMode(gin.TestMode) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodGet, "/authorize?"+query, nil) + return c, rec +} + +// TestAuthorize_RegisteredRedirectURI_RejectsUnregisteredSuffix is a +// regression test for a real security bug caught by the OIDF conformance +// suite (oidcc-ensure-registered-redirect-uri): a client with registered +// redirect URIs must reject a presented redirect_uri that isn't an exact +// match, even when it's just the registered URI plus an extra path +// segment under the same allowed host. +func TestAuthorize_RegisteredRedirectURI_RejectsUnregisteredSuffix(t *testing.T) { + client := &schemas.Client{ + ClientID: "client-1", + RedirectURIs: "http://example.com/app/callback", + } + h := newRedirectURITestProvider(t, client) + + c, rec := authorizeRedirectCtx("client_id=client-1&state=xyz&response_type=code&response_mode=query&scope=openid&redirect_uri=" + + "http%3A%2F%2Fexample.com%2Fapp%2Fcallback%2Funregistered-suffix") + h.AuthorizeHandler()(c) + + assert.Equal(t, http.StatusBadRequest, rec.Code, "an unregistered path suffix must be rejected even under an allowed host") + assert.Contains(t, rec.Body.String(), "invalid_request") +} + +func TestAuthorize_RegisteredRedirectURI_AcceptsExactMatch(t *testing.T) { + client := &schemas.Client{ + ClientID: "client-1", + RedirectURIs: "http://example.com/app/callback", + } + h := newRedirectURITestProvider(t, client) + + c, rec := authorizeRedirectCtx("client_id=client-1&state=xyz&response_type=code&response_mode=query&scope=openid&redirect_uri=" + + "http%3A%2F%2Fexample.com%2Fapp%2Fcallback") + h.AuthorizeHandler()(c) + + assert.NotEqual(t, http.StatusBadRequest, rec.Code, "an exact-matching registered redirect_uri must be accepted") +} + +// TestAuthorize_NoRegisteredRedirectURIs_FallsBackToOriginCheck preserves +// today's behavior for clients that have never registered redirect_uris +// (the only kind that exist today, since no API surface sets the field) — +// this fix must not break them. +func TestAuthorize_NoRegisteredRedirectURIs_FallsBackToOriginCheck(t *testing.T) { + client := &schemas.Client{ClientID: "client-1"} // RedirectURIs empty + h := newRedirectURITestProvider(t, client) + + c, rec := authorizeRedirectCtx("client_id=client-1&state=xyz&response_type=code&response_mode=query&scope=openid&redirect_uri=" + + "http%3A%2F%2Fexample.com%2Fapp%2Fanything") + h.AuthorizeHandler()(c) + + assert.NotEqual(t, http.StatusBadRequest, rec.Code, "clients with no registered redirect_uris keep the existing origin-only check") +} diff --git a/internal/http_handlers/authorize_response_type_test.go b/internal/http_handlers/authorize_response_type_test.go new file mode 100644 index 00000000..d204bc11 --- /dev/null +++ b/internal/http_handlers/authorize_response_type_test.go @@ -0,0 +1,57 @@ +package http_handlers + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/rs/zerolog" + "github.com/stretchr/testify/assert" + + "github.com/authorizerdev/authorizer/internal/config" +) + +func authorizeCtx(query string) (*gin.Context, *httptest.ResponseRecorder) { + gin.SetMode(gin.TestMode) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodGet, "/authorize?"+query, nil) + return c, rec +} + +// RFC 6749 §3.1.1: response_type is REQUIRED. A request that omits it must +// be rejected, not silently defaulted to an implicit-flow token grant the +// client never asked for (regression test for the oidcc-response-type-missing +// conformance failure). +func TestAuthorize_MissingResponseType_NoRedirectURI_ReturnsJSONError(t *testing.T) { + logger := zerolog.Nop() + h := &httpProvider{ + Config: &config.Config{}, + Dependencies: Dependencies{Log: &logger}, + } + + c, rec := authorizeCtx("client_id=abc&state=xyz") + h.AuthorizeHandler()(c) + + assert.Equal(t, http.StatusBadRequest, rec.Code) + assert.Contains(t, rec.Body.String(), "invalid_request") + assert.Contains(t, rec.Body.String(), "response_type is required") + assert.NotContains(t, rec.Body.String(), "access_token") +} + +func TestAuthorize_MissingResponseType_WithRedirectURI_RedirectsWithError(t *testing.T) { + logger := zerolog.Nop() + h := &httpProvider{ + Config: &config.Config{AllowedOrigins: []string{"*"}}, + Dependencies: Dependencies{Log: &logger, StorageProvider: &redirectURIClientStore{}}, + } + + c, rec := authorizeCtx("client_id=abc&state=xyz&redirect_uri=" + "http%3A%2F%2Fexample.com%2Fapp%2Fcallback") + h.AuthorizeHandler()(c) + + assert.Equal(t, http.StatusFound, rec.Code) + location := rec.Header().Get("Location") + assert.Contains(t, location, "error=invalid_request") + assert.NotContains(t, location, "access_token") +} diff --git a/internal/http_handlers/csrf.go b/internal/http_handlers/csrf.go index 9792205d..6ecc0e94 100644 --- a/internal/http_handlers/csrf.go +++ b/internal/http_handlers/csrf.go @@ -58,6 +58,27 @@ func (h *httpProvider) CSRFMiddleware() gin.HandlerFunc { return } + // Exempt POST /userinfo (OIDC Core §5.3.1 requires the UserInfo + // endpoint to accept POST). Authenticated via a bearer access token, + // not cookies, so CSRF does not apply — same rationale as + // /oauth/token above. + if c.Request.URL.Path == "/userinfo" { + c.Next() + return + } + + // Exempt POST /authorize (RFC 6749 §3.1 / OIDC Core §3.1.2.1 require + // the authorization endpoint to accept POST). This endpoint is + // reached via top-level navigation/form-submit from arbitrary + // third-party RPs — the request is not cookie-authenticated the way + // GraphQL/admin mutations are (an existing session cookie is only + // consulted to skip re-login; anonymous requests are the common + // case), so CSRF does not apply. + if c.Request.URL.Path == "/authorize" { + c.Next() + return + } + // Exempt the SAML ACS endpoint (POST /oauth/saml/:org_slug/acs). The // SAML POST binding delivers the assertion via an auto-submitting form // from the IdP's origin — a legitimate cross-origin POST that Origin diff --git a/internal/http_handlers/oauth_authorize_state_test.go b/internal/http_handlers/oauth_authorize_state_test.go index ed5adcf2..ceacd86f 100644 --- a/internal/http_handlers/oauth_authorize_state_test.go +++ b/internal/http_handlers/oauth_authorize_state_test.go @@ -208,21 +208,31 @@ type fakeMemoryStore struct { getStateErr error removedKeys []string + + deletedSessions [][2]string } func (f *fakeMemoryStore) SetUserSession(userId, key, token string, expiration int64) error { return nil } -func (f *fakeMemoryStore) GetUserSession(userId, key string) (string, error) { return "", nil } -func (f *fakeMemoryStore) DeleteUserSession(userId, key string) error { return nil } -func (f *fakeMemoryStore) DeleteAllUserSessions(userId string) error { return nil } -func (f *fakeMemoryStore) DeleteSessionForNamespace(namespace string) error { return nil } -func (f *fakeMemoryStore) SetMfaSession(userId, key string, expiration int64) error { return nil } -func (f *fakeMemoryStore) GetMfaSession(userId, key string) (string, error) { return "", nil } -func (f *fakeMemoryStore) GetAllMfaSessions(userId string) ([]string, error) { return nil, nil } -func (f *fakeMemoryStore) DeleteMfaSession(userId, key string) error { return nil } -func (f *fakeMemoryStore) SetState(key, state string) error { return nil } -func (f *fakeMemoryStore) GetState(key string) (string, error) { return f.getStateVal, f.getStateErr } +func (f *fakeMemoryStore) GetUserSession(userId, key string) (string, error) { return "", nil } +func (f *fakeMemoryStore) DeleteUserSession(userId, key string) error { + f.deletedSessions = append(f.deletedSessions, [2]string{userId, key}) + return nil +} +func (f *fakeMemoryStore) DeleteAllUserSessions(userId string) error { return nil } +func (f *fakeMemoryStore) DeleteSessionForNamespace(namespace string) error { return nil } +func (f *fakeMemoryStore) SetMfaSession(userId, key, purpose string, expiration int64) error { + return nil +} +func (f *fakeMemoryStore) GetMfaSession(userId, key string) (string, error) { return "", nil } +func (f *fakeMemoryStore) GetAllMfaSessions(userId string) ([]string, error) { return nil, nil } +func (f *fakeMemoryStore) DeleteMfaSession(userId, key string) error { return nil } +func (f *fakeMemoryStore) GetMfaSessionOwner(key string) (string, string, error) { + return "", "", nil +} +func (f *fakeMemoryStore) SetState(key, state string) error { return nil } +func (f *fakeMemoryStore) GetState(key string) (string, error) { return f.getStateVal, f.getStateErr } func (f *fakeMemoryStore) RemoveState(key string) error { f.removedKeys = append(f.removedKeys, key) return nil diff --git a/internal/http_handlers/oauth_callback.go b/internal/http_handlers/oauth_callback.go index 0d931cb2..430aa6ca 100644 --- a/internal/http_handlers/oauth_callback.go +++ b/internal/http_handlers/oauth_callback.go @@ -24,6 +24,7 @@ import ( "github.com/authorizerdev/authorizer/internal/metrics" "github.com/authorizerdev/authorizer/internal/parsers" "github.com/authorizerdev/authorizer/internal/refs" + "github.com/authorizerdev/authorizer/internal/service" "github.com/authorizerdev/authorizer/internal/storage/schemas" "github.com/authorizerdev/authorizer/internal/token" "github.com/authorizerdev/authorizer/internal/utils" @@ -333,15 +334,6 @@ func (h *httpProvider) OAuthCallbackHandler() gin.HandlerFunc { return } - // Code challenge could be optional if PKCE flow is not used - if code != "" { - if err := h.MemoryStoreProvider.SetState(code, codeChallenge+"@@"+authToken.FingerPrintHash+"@@"+nonce+"@@"+url.QueryEscape(authorizeRedirectURI)); err != nil { - log.Debug().Err(err).Msg("Failed to set state") - ctx.JSON(500, gin.H{"error": "failed to process OAuth login"}) - return - } - } - // expiresIn := authToken.AccessToken.ExpiresAt - time.Now().Unix() // if expiresIn <= 0 { expiresIn = 1 } // params := "access_token=" + authToken.AccessToken.Token + "&token_type=bearer&expires_in=" + strconv.FormatInt(expiresIn, 10) + "&state=" + stateValue + "&id_token=" + authToken.IDToken.Token + "&nonce=" + nonce @@ -351,6 +343,49 @@ func (h *httpProvider) OAuthCallbackHandler() gin.HandlerFunc { params += "&code=" + code } + // MFA gate: matches password/passkey login (resolveMFAGate) before the + // browser session cookie is established. A withheld-group outcome sets + // the MFA session cookie (via `side`) instead and redirects with + // mfa_required=1 rather than the normal state/code params. + side := &service.ResponseSideEffects{} + meta := service.RequestMetadata{ + HostURL: hostname, + IPAddress: utils.GetIP(ctx.Request), + UserAgent: utils.GetUserAgent(ctx.Request), + Request: ctx.Request, + } + withheld, redirectSuffix, gateErr := h.ServiceProvider.EvaluateMFAGateForOAuth(ctx, meta, side, user) + if gateErr != nil { + log.Debug().Err(gateErr).Msg("MFA gate rejected OAuth callback") + ctx.JSON(400, gin.H{"error": gateErr.Error()}) + return + } + if withheld { + service.ApplyToGin(ctx, side) + if strings.Contains(redirectURL, "?") { + redirectURL = redirectURL + "&" + redirectSuffix + } else { + redirectURL = redirectURL + "?" + strings.TrimPrefix(redirectSuffix, "&") + } + ctx.Redirect(http.StatusFound, redirectURL) + return + } + + // Code challenge could be optional if PKCE flow is not used. Set only on + // the normal (non-withheld) path: the `code` is never disclosed to the + // browser on a withheld redirect (it carries mfa_required=1, not + // state/code), so setting this state entry before the gate check would + // leave an orphaned, unreachable entry that just self-expires — not + // exploitable, but there's no reason to write it before we know the + // login actually proceeds. + if code != "" { + if err := h.MemoryStoreProvider.SetState(code, codeChallenge+"@@"+authToken.FingerPrintHash+"@@"+nonce+"@@"+url.QueryEscape(authorizeRedirectURI)); err != nil { + log.Debug().Err(err).Msg("Failed to set state") + ctx.JSON(500, gin.H{"error": "failed to process OAuth login"}) + return + } + } + sessionKey := provider + ":" + user.ID cookie.SetSession(ctx, authToken.FingerPrintHash, h.Config.AppCookieSecure, cookie.ParseSameSite(h.Config.AppCookieSameSite)) _ = h.MemoryStoreProvider.SetUserSession(sessionKey, constants.TokenTypeSessionToken+"_"+authToken.FingerPrint, authToken.FingerPrintHash, authToken.SessionTokenExpiresAt) diff --git a/internal/http_handlers/oauth_sso.go b/internal/http_handlers/oauth_sso.go index 885b2320..b753acb2 100644 --- a/internal/http_handlers/oauth_sso.go +++ b/internal/http_handlers/oauth_sso.go @@ -269,6 +269,16 @@ func (h *httpProvider) SSOCallbackHandler() gin.HandlerFunc { ssoFail(c, &log, "sso_not_configured", "connection unavailable") return } + // SSOLoginHandler checked org.Enabled at dispatch time (resolveActiveOIDCConnection), + // but the state TTL window (~30-90s) gives an admin time to disable the org + // before the callback lands. Re-check here so an in-flight login into a + // just-disabled org cannot complete. + org, err := h.StorageProvider.GetOrganizationByID(ctx, conn.OrgID) + if err != nil || org == nil || !org.Enabled { + log.Debug().Err(err).Str("org", slug).Msg("organization disabled or missing since login was initiated") + ssoFail(c, &log, "sso_not_configured", "organization disabled") + return + } secret, err := crypto.DecryptAES(h.Config.ClientSecret, conn.SSOClientSecretEnc) if err != nil { log.Debug().Err(err).Msg("failed to decrypt upstream client secret") diff --git a/internal/http_handlers/oauth_sso_callback_test.go b/internal/http_handlers/oauth_sso_callback_test.go index 97787824..e05bd632 100644 --- a/internal/http_handlers/oauth_sso_callback_test.go +++ b/internal/http_handlers/oauth_sso_callback_test.go @@ -1,7 +1,9 @@ package http_handlers import ( + "context" "encoding/json" + "errors" "net/http" "net/http/httptest" "testing" @@ -12,7 +14,10 @@ import ( "github.com/stretchr/testify/require" "github.com/authorizerdev/authorizer/internal/config" + "github.com/authorizerdev/authorizer/internal/constants" "github.com/authorizerdev/authorizer/internal/memory_store" + "github.com/authorizerdev/authorizer/internal/storage" + "github.com/authorizerdev/authorizer/internal/storage/schemas" ) // ssoFakeStore serves a single preset flow entry via GetAndRemoveState and @@ -97,3 +102,91 @@ func TestSSOCallback_MixupIssParamRejected(t *testing.T) { require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body)) assert.Equal(t, "invalid_issuer", body["error"]) } + +// ssoOrgStore is a minimal storage.Provider stub serving only what +// SSOCallbackHandler's connection/org lookups touch: GetTrustedIssuerByID and +// GetOrganizationByID. Every other method panics via the embedded nil. +type ssoOrgStore struct { + storage.Provider + conn *schemas.TrustedIssuer + org *schemas.Organization +} + +func (s *ssoOrgStore) GetTrustedIssuerByID(_ context.Context, id string) (*schemas.TrustedIssuer, error) { + if s.conn != nil && s.conn.ID == id { + return s.conn, nil + } + return nil, errors.New("not found") +} + +func (s *ssoOrgStore) GetOrganizationByID(_ context.Context, id string) (*schemas.Organization, error) { + if s.org != nil && s.org.ID == id { + return s.org, nil + } + return nil, errors.New("not found") +} + +// REGRESSION (org-disabled mid-flow race): SSOLoginHandler checks org.Enabled +// only at dispatch time (resolveActiveOIDCConnection). The callback re-fetches +// the connection by ID directly and, before this fix, never re-checked the +// owning org — so a login started before an admin disables the org still +// completed successfully within the state TTL window. Simulate that race: +// seed a flow (as SSOLoginHandler would have), then disable the org "mid-flow" +// via storage before the callback lands, and confirm the callback rejects +// instead of proceeding to code exchange / session issuance. +func TestSSOCallback_OrgDisabledMidFlightRejected(t *testing.T) { + flow := ssoFlowState{ + ConnID: "conn-1", + OrgID: "org-1", + OrgSlug: "acme", + ExpectedIssuer: ssoTestIssuer, + } + raw, _ := json.Marshal(flow) + memStore := &ssoFakeStore{entries: map[string]string{ssoFlowPrefix + "s3": string(raw)}} + + conn := &schemas.TrustedIssuer{ID: "conn-1", OrgID: "org-1", Kind: constants.TrustKindSSOOIDC, IsActive: true} + // Org was enabled when SSOLoginHandler dispatched the flow, but an admin + // disabled it before the callback arrived. + org := &schemas.Organization{ID: "org-1", Name: "acme", Enabled: false} + + h := newSSOCallbackProvider(memStore) + h.StorageProvider = &ssoOrgStore{conn: conn, org: org} + + c, rec := callbackCtx("acme", "state=s3&code=abc") + h.SSOCallbackHandler()(c) + + assert.Equal(t, http.StatusBadRequest, rec.Code, "a disabled org must reject the callback, not issue a session") + var body map[string]string + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body)) + assert.Equal(t, "sso_not_configured", body["error"]) +} + +// Sanity counterpart: the same setup with the org still enabled must pass the +// org-enabled gate and proceed past it (fails later on the secret decrypt +// step since conn.SSOClientSecretEnc is empty here — that's fine, it proves +// the org check itself did not fire). +func TestSSOCallback_OrgEnabledPassesOrgGate(t *testing.T) { + flow := ssoFlowState{ + ConnID: "conn-1", + OrgID: "org-1", + OrgSlug: "acme", + ExpectedIssuer: ssoTestIssuer, + } + raw, _ := json.Marshal(flow) + memStore := &ssoFakeStore{entries: map[string]string{ssoFlowPrefix + "s4": string(raw)}} + + conn := &schemas.TrustedIssuer{ID: "conn-1", OrgID: "org-1", Kind: constants.TrustKindSSOOIDC, IsActive: true} + org := &schemas.Organization{ID: "org-1", Name: "acme", Enabled: true} + + h := newSSOCallbackProvider(memStore) + h.StorageProvider = &ssoOrgStore{conn: conn, org: org} + h.Config = &config.Config{} + + c, rec := callbackCtx("acme", "state=s4&code=abc") + h.SSOCallbackHandler()(c) + + assert.Equal(t, http.StatusBadRequest, rec.Code) + var body map[string]string + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body)) + assert.NotEqual(t, "sso_not_configured", body["error"], "an enabled org must not be rejected by the org gate") +} diff --git a/internal/http_handlers/oauth_sso_jit_test.go b/internal/http_handlers/oauth_sso_jit_test.go index d82c4050..58b3a91f 100644 --- a/internal/http_handlers/oauth_sso_jit_test.go +++ b/internal/http_handlers/oauth_sso_jit_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "testing" + "time" "github.com/golang-jwt/jwt/v4" "github.com/google/uuid" @@ -196,3 +197,25 @@ func TestSSOJIT_SignupDisabledRejected(t *testing.T) { require.Error(t, err) assert.Nil(t, user) } + +// REGRESSION: a revoked user reaching the SSO callback via the RETURNING-user +// path (resolved through an existing FederatedIdentity row, not first-time JIT +// provisioning) must be rejected, not silently handed a session. Confirms +// jitProvisionFederatedUser's RevokedTimestamp check fires on this path too. +func TestSSOJIT_RevokedReturningUserRejected(t *testing.T) { + store := newJITStore() + revokedAt := time.Now().Unix() + user := &schemas.User{ID: "revoked-user", Email: refs.NewStringRef("evicted@corp.example.com"), RevokedTimestamp: &revokedAt} + store.usersByID[user.ID] = user + store.usersByEmail["evicted@corp.example.com"] = user + store.federated[fedKey("org-1", ssoTestIssuer, "upstream-revoked")] = &schemas.FederatedIdentity{ + OrgID: "org-1", Issuer: ssoTestIssuer, Subject: "upstream-revoked", UserID: user.ID, + } + + h := newJITProvider(store, true) + got, isSignUp, err := h.jitProvisionSSOUser(context.Background(), jitFlow(), jitClaims("upstream-revoked", "evicted@corp.example.com")) + require.Error(t, err) + assert.Nil(t, got) + assert.False(t, isSignUp) + assert.Contains(t, err.Error(), "revoked") +} diff --git a/internal/http_handlers/oauth_sso_login_test.go b/internal/http_handlers/oauth_sso_login_test.go new file mode 100644 index 00000000..55677b21 --- /dev/null +++ b/internal/http_handlers/oauth_sso_login_test.go @@ -0,0 +1,126 @@ +package http_handlers + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/rs/zerolog" + "github.com/stretchr/testify/assert" + + "github.com/authorizerdev/authorizer/internal/config" + "github.com/authorizerdev/authorizer/internal/storage" + "github.com/authorizerdev/authorizer/internal/storage/schemas" +) + +// ssoLoginStore is a minimal storage.Provider stub serving only what +// resolveActiveOIDCConnection touches: GetOrganizationByName and +// GetTrustedIssuerByOrgIDAndKind. Every other method panics via the embedded nil. +type ssoLoginStore struct { + storage.Provider + orgsByName map[string]*schemas.Organization + connsByOrg map[string]*schemas.TrustedIssuer +} + +func (s *ssoLoginStore) GetOrganizationByName(_ context.Context, name string) (*schemas.Organization, error) { + if org, ok := s.orgsByName[name]; ok { + return org, nil + } + return nil, errors.New("not found") +} + +func (s *ssoLoginStore) GetTrustedIssuerByOrgIDAndKind(_ context.Context, orgID, _ string) (*schemas.TrustedIssuer, error) { + if conn, ok := s.connsByOrg[orgID]; ok { + return conn, nil + } + return nil, errors.New("not found") +} + +func newSSOLoginProvider(store *ssoLoginStore) *httpProvider { + logger := zerolog.Nop() + return &httpProvider{ + Config: &config.Config{}, + // SSOLoginHandler checks MemoryStoreProvider != nil before resolving the + // connection; an empty fake state store is enough to clear that gate for + // these org-resolution tests (SetState is only reached on the success path). + Dependencies: Dependencies{Log: &logger, StorageProvider: store, MemoryStoreProvider: &ssoFakeStore{entries: map[string]string{}}}, + } +} + +func loginCtx(orgSlug, query string) (*gin.Context, *httptest.ResponseRecorder) { + gin.SetMode(gin.TestMode) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodGet, "/oauth/sso/"+orgSlug+"/login?"+query, nil) + c.Params = gin.Params{{Key: "org_slug", Value: orgSlug}} + return c, rec +} + +// validSSORedirectQuery is a redirect_uri that passes IsValidRedirectURI against +// the default (wildcard) AllowedOrigins: httptest.NewRequest defaults Host to +// "example.com", and the wildcard rule restricts redirects to the server's own +// hostname. +const validSSORedirectQuery = "redirect_uri=http%3A%2F%2Fexample.com%2Fapp%2Fcallback&state=xyz" + +// An unknown org slug must reject with an error response, never redirect to an +// upstream IdP. +func TestSSOLogin_UnknownOrgRejected(t *testing.T) { + store := &ssoLoginStore{orgsByName: map[string]*schemas.Organization{}} + h := newSSOLoginProvider(store) + c, rec := loginCtx("no-such-org", validSSORedirectQuery) + h.SSOLoginHandler()(c) + + assert.Equal(t, http.StatusNotFound, rec.Code) + assert.NotEqual(t, http.StatusTemporaryRedirect, rec.Code) +} + +// A disabled org must reject with an error response, never redirect to an +// upstream IdP — this is the same org.Enabled gate SSOCallbackHandler now +// re-checks mid-flow; here it's the initiation-time check that was already +// correct (resolveActiveOIDCConnection). +func TestSSOLogin_DisabledOrgRejected(t *testing.T) { + store := &ssoLoginStore{ + orgsByName: map[string]*schemas.Organization{ + "acme": {ID: "org-1", Name: "acme", Enabled: false}, + }, + } + h := newSSOLoginProvider(store) + c, rec := loginCtx("acme", validSSORedirectQuery) + h.SSOLoginHandler()(c) + + assert.Equal(t, http.StatusForbidden, rec.Code) + assert.NotEqual(t, http.StatusTemporaryRedirect, rec.Code) +} + +// An enabled org with no active sso_oidc connection must also reject, not +// redirect (distinguishes "org disabled" from "SSO not configured"). +func TestSSOLogin_EnabledOrgNoConnectionRejected(t *testing.T) { + store := &ssoLoginStore{ + orgsByName: map[string]*schemas.Organization{ + "acme": {ID: "org-1", Name: "acme", Enabled: true}, + }, + connsByOrg: map[string]*schemas.TrustedIssuer{}, + } + h := newSSOLoginProvider(store) + c, rec := loginCtx("acme", validSSORedirectQuery) + h.SSOLoginHandler()(c) + + assert.Equal(t, http.StatusNotFound, rec.Code) + assert.NotEqual(t, http.StatusTemporaryRedirect, rec.Code) +} + +// An enabled org with an active connection passes the resolveActiveOIDCConnection +// gate and proceeds to upstream discovery, which fails fast here since +// conn.IssuerURL is not a real reachable IdP — proving the org/connection gate +// itself did not reject the request. +func TestSSOLogin_MissingRedirectURIRejected(t *testing.T) { + store := &ssoLoginStore{orgsByName: map[string]*schemas.Organization{}} + h := newSSOLoginProvider(store) + c, rec := loginCtx("acme", "state=xyz") + h.SSOLoginHandler()(c) + + assert.Equal(t, http.StatusBadRequest, rec.Code) +} diff --git a/internal/http_handlers/openid_config.go b/internal/http_handlers/openid_config.go index 13a061ba..29263d34 100644 --- a/internal/http_handlers/openid_config.go +++ b/internal/http_handlers/openid_config.go @@ -46,9 +46,14 @@ func (h *httpProvider) OpenIDConfigurationHandler() gin.HandlerFunc { "id_token_signing_alg_values_supported": signingAlgs, // RECOMMENDED fields - "token_endpoint": issuer + "/oauth/token", - "userinfo_endpoint": issuer + "/userinfo", - "scopes_supported": []string{"openid", "email", "profile", "offline_access"}, + "token_endpoint": issuer + "/oauth/token", + "userinfo_endpoint": issuer + "/userinfo", + // "phone" is advertised because phone_number/phone_number_verified + // are real, populated claims (see claims_supported below). "address" + // is deliberately omitted — the User schema has no address fields + // (street_address/locality/region/postal_code/country), so claiming + // support for it would be false advertising. + "scopes_supported": []string{"openid", "email", "profile", "phone", "offline_access"}, "claims_supported": []string{"aud", "exp", "iss", "iat", "sub", "given_name", "family_name", "middle_name", "nickname", "preferred_username", "picture", "email", "email_verified", "roles", "role", "gender", "birthdate", "phone_number", "phone_number_verified", "nonce", "updated_at", "created_at", "auth_time", "amr", "acr", "at_hash", "c_hash"}, "response_modes_supported": []string{"query", "fragment", "form_post", "web_message"}, "grant_types_supported": grantTypes, diff --git a/internal/http_handlers/saml_sp_verify_test.go b/internal/http_handlers/saml_sp_verify_test.go index b0a3906d..ebc7e0b6 100644 --- a/internal/http_handlers/saml_sp_verify_test.go +++ b/internal/http_handlers/saml_sp_verify_test.go @@ -284,6 +284,20 @@ func TestSAML_WrongRecipientRejected(t *testing.T) { require.Error(t, err) } +// Destination (the attribute) is checked independently of +// SubjectConfirmationData/Recipient — a response signed for a *different* +// org's ACS URL but replayed at org A, with Recipient left correct, must +// still be rejected. Closes a coverage gap: TestSAML_WrongRecipientRejected +// only varied Recipient, never isolated Destination. +func TestSAML_WrongDestinationRejected(t *testing.T) { + idp := newSAMLIdP(t) + p := defaultAssertionParams() + p.destination = "https://auth.example.com/oauth/saml/other/acs" + raw := makeSAMLResponse(t, idp, p) + _, err := parseWith(t, samlTestConn(idp), raw, []string{samlTestRequestID}) + require.Error(t, err) +} + func TestSAML_ExpiredAssertionRejected(t *testing.T) { idp := newSAMLIdP(t) p := defaultAssertionParams() diff --git a/internal/http_handlers/token.go b/internal/http_handlers/token.go index ff007d85..6fb793b4 100644 --- a/internal/http_handlers/token.go +++ b/internal/http_handlers/token.go @@ -52,6 +52,18 @@ type RequestBody struct { ActorTokenType string `form:"actor_token_type" json:"actor_token_type"` } +// formURLDecodeOrKeep undoes the application/x-www-form-urlencoded encoding +// RFC 6749 §2.3.1 requires clients to apply to client_id/client_secret before +// placing them in the HTTP Basic Authorization header. Returns the decoded +// value, or the original string unchanged if it isn't validly encoded (a raw +// secret may legitimately contain a literal "%"). +func formURLDecodeOrKeep(s string) string { + if decoded, err := url.QueryUnescape(s); err == nil { + return decoded + } + return s +} + // TokenHandler to handle /oauth/token requests // grant type required func (h *httpProvider) TokenHandler() gin.HandlerFunc { @@ -104,7 +116,16 @@ func (h *httpProvider) TokenHandler() gin.HandlerFunc { // / refresh_token treat a missing secret as "no secret presented" and let // the PKCE checks below gate the request. secretPresented drives the // no-PKCE "secret required" rule further down. + // RFC 6749 §2.3.1: the client_id and client_secret carried in HTTP Basic + // credentials MUST each be encoded with the application/x-www-form-urlencoded + // algorithm before being placed in the Authorization header. Go's + // BasicAuth() only base64-decodes the header — it does not undo that + // encoding — so a secret containing a character the encoder escapes + // (e.g. "!", "*", "'", "(", ")", space) arrives here still percent-encoded + // and fails comparison against the stored plaintext/hash. Undo it here. basicClientID, basicClientSecret, hasBasicAuth := gc.Request.BasicAuth() + basicClientID = formURLDecodeOrKeep(basicClientID) + basicClientSecret = formURLDecodeOrKeep(basicClientSecret) secretPresented := bodyClientSecret != "" || (hasBasicAuth && basicClientSecret != "") clientAssertion := strings.TrimSpace(reqBody.ClientAssertion) clientAssertionType := strings.TrimSpace(reqBody.ClientAssertionType) @@ -156,7 +177,8 @@ func (h *httpProvider) TokenHandler() gin.HandlerFunc { var roles, scope []string loginMethod := "" sessionKey := "" - oidcNonce := "" // OIDC nonce from the original /authorize request + oidcNonce := "" // OIDC nonce from the original /authorize request + authTime := int64(0) // End-User's actual last authentication (OIDC Core §2 auth_time); 0 = unknown, CreateIDToken falls back to time.Now() if isAuthorizationCodeGrant { if code == "" { @@ -306,6 +328,7 @@ func (h *httpProvider) TokenHandler() gin.HandlerFunc { roles = claims.Roles scope = claims.Scope loginMethod = claims.LoginMethod + authTime = claims.EffectiveAuthTime() // Extract OIDC nonce from stored code data (third @@-separated part). if len(sessionDataSplit) > 2 { @@ -336,10 +359,14 @@ func (h *httpProvider) TokenHandler() gin.HandlerFunc { return } - claims, err := h.TokenProvider.ValidateRefreshToken(gc, refreshToken) + // RFC 6749 §5.2: an invalid_grant error (expired/tampered token, or + // — via the audience check inside ValidateRefreshToken — a token + // bound to a different client, RFC 6749 §6) MUST be returned with + // HTTP 400, not 401. + claims, err := h.TokenProvider.ValidateRefreshToken(gc, refreshToken, resolvedClient.ClientID) if err != nil { log.Debug().Err(err).Msg("Error validating refresh token") - gc.JSON(http.StatusUnauthorized, gin.H{ + gc.JSON(http.StatusBadRequest, gin.H{ "error": "invalid_grant", "error_description": "The refresh token is invalid or has expired", }) @@ -408,6 +435,13 @@ func (h *httpProvider) TokenHandler() gin.HandlerFunc { loginMethod = lm } + // auth_time survives token refresh unchanged — a refresh must + // not reset "when the user last actually authenticated". + // JWT numeric claims decode as float64. + if at, ok := claims["auth_time"].(float64); ok { + authTime = int64(at) + } + nonce, ok := claims["nonce"].(string) if !ok || nonce == "" { log.Debug().Msg("Invalid nonce in refresh token") @@ -418,6 +452,13 @@ func (h *httpProvider) TokenHandler() gin.HandlerFunc { return } + // RFC 6749 §6: "the authorization server MUST verify the binding + // between the refresh token and client identity whenever + // possible." Enforced above via ValidateRefreshToken's audience + // check (the token's "aud" claim, set to the issuing client, must + // match resolvedClient.ClientID) — a token issued to one client + // is rejected before reaching this point if presented by another. + // remove older refresh token and rotate it for security if err := h.MemoryStoreProvider.DeleteUserSession(sessionKey, nonce); err != nil { log.Debug().Err(err).Str("session_key", sessionKey).Msg("Failed to delete old session during token refresh") @@ -467,6 +508,8 @@ func (h *httpProvider) TokenHandler() gin.HandlerFunc { Nonce: nonce, OIDCNonce: oidcNonce, HostName: hostname, + AuthTime: authTime, + ClientID: resolvedClient.ClientID, }) if err != nil { log.Debug().Err(err).Msg("Error creating auth token") diff --git a/internal/http_handlers/token_exchange.go b/internal/http_handlers/token_exchange.go index 4453d462..cf46b102 100644 --- a/internal/http_handlers/token_exchange.go +++ b/internal/http_handlers/token_exchange.go @@ -129,6 +129,31 @@ func (h *httpProvider) handleTokenExchangeGrant(gc *gin.Context, agent *schemas. onBehalfOfType := constants.AuditActorTypeUser if lm, _ := subjectClaims["login_method"].(string); lm == constants.AuthRecipeMethodServiceAccount { onBehalfOfType = "agent" + // validateExchangeToken only checks the JWT's own signature/exp — it + // has no session-store lookup, so a service-account subject_token + // stays cryptographically valid until its natural expiry even after + // the account is deactivated. Without this check, a still-unexpired + // token from a just-deactivated service account could keep seeding + // fresh delegated tokens through a willing downstream agent, + // extending its effective lifetime past deactivation. Same + // fail-closed contract as the user branch below: a subject we + // cannot load or confirm active must not seed a delegation. + subjectClient, cErr := h.StorageProvider.GetClientByID(gc, subject) + if cErr != nil || subjectClient == nil { + log.Debug().Err(cErr).Msg("subject service account could not be verified") + gc.JSON(http.StatusBadRequest, gin.H{ + "error": "invalid_grant", + "error_description": "The subject could not be verified", + }) + return + } + if !subjectClient.IsActive { + gc.JSON(http.StatusBadRequest, gin.H{ + "error": "invalid_grant", + "error_description": "The subject is no longer active", + }) + return + } } else { user, uErr := h.StorageProvider.GetUserByID(gc, subject) if uErr != nil || user == nil { diff --git a/internal/http_handlers/userinfo.go b/internal/http_handlers/userinfo.go index 2b1b0be0..7067cb77 100644 --- a/internal/http_handlers/userinfo.go +++ b/internal/http_handlers/userinfo.go @@ -77,18 +77,20 @@ func filterUserInfoByScopes(full map[string]interface{}, scopes map[string]struc "sub": full["sub"], } // allow copies every claim key from the granted scope group into the - // filtered response. Per OIDC Core §5.4 the keys associated with a - // granted scope are part of the response shape; if the underlying user - // object has no value for a claim we still emit the key with a JSON - // null (explicitly permitted by OIDC Core §5.3.2) so callers can rely - // on a stable schema. + // filtered response, but only when there's an actual value. Per OIDC + // Core §5.3.2/§5.1 a claim with no value must be OMITTED, not emitted + // as JSON null or an empty string — conformance validators reject both + // as "not a string with content". allow := func(group []string) { for _, k := range group { - if v, ok := full[k]; ok { - filtered[k] = v - } else { - filtered[k] = nil + v, ok := full[k] + if !ok || v == nil { + continue } + if s, isStr := v.(string); isStr && strings.TrimSpace(s) == "" { + continue + } + filtered[k] = v } } if _, ok := scopes["profile"]; ok { diff --git a/internal/http_handlers/userinfo_scope_filter_test.go b/internal/http_handlers/userinfo_scope_filter_test.go new file mode 100644 index 00000000..f67f0986 --- /dev/null +++ b/internal/http_handlers/userinfo_scope_filter_test.go @@ -0,0 +1,53 @@ +package http_handlers + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +// TestFilterUserInfoByScopes_OmitsUnsetClaims proves optional claims with no +// value are OMITTED from the userinfo response, not emitted as JSON null or +// an empty string. Regression test for the oidcc-scope-profile conformance +// failure ("gender is not a string with content", etc.) — the validator +// rejects both null and "" as invalid, so an absent key is the only +// spec-compliant way to represent "no value". +func TestFilterUserInfoByScopes_OmitsUnsetClaims(t *testing.T) { + full := map[string]interface{}{ + "sub": "user-1", + "given_name": "Ada", + "family_name": "Lovelace", + "preferred_username": "ada", + "gender": "", // present but empty — must still be omitted + "updated_at": float64(42), // non-string, always kept when present + } + scopes := map[string]struct{}{"profile": {}} + + filtered := filterUserInfoByScopes(full, scopes) + + assert.Equal(t, "Ada", filtered["given_name"]) + assert.Equal(t, "Lovelace", filtered["family_name"]) + assert.Equal(t, float64(42), filtered["updated_at"]) + + // Never present in `full` at all (no DB column) — must be omitted. + for _, k := range []string{"name", "profile", "website", "zoneinfo", "locale"} { + _, ok := filtered[k] + assert.False(t, ok, "claim %q must be omitted, not present as null", k) + } + // Present in `full` but empty/nil — must also be omitted. + _, ok := filtered["gender"] + assert.False(t, ok, "empty-string claim must be omitted") +} + +func TestFilterUserInfoByScopes_NilValueOmitted(t *testing.T) { + full := map[string]interface{}{ + "sub": "user-1", + "gender": nil, + } + scopes := map[string]struct{}{"profile": {}} + + filtered := filterUserInfoByScopes(full, scopes) + + _, ok := filtered["gender"] + assert.False(t, ok, "nil claim value must be omitted") +} diff --git a/internal/http_handlers/verify_email.go b/internal/http_handlers/verify_email.go index e333b50c..2cd0125c 100644 --- a/internal/http_handlers/verify_email.go +++ b/internal/http_handlers/verify_email.go @@ -16,6 +16,7 @@ import ( "github.com/authorizerdev/authorizer/internal/metrics" "github.com/authorizerdev/authorizer/internal/parsers" "github.com/authorizerdev/authorizer/internal/refs" + "github.com/authorizerdev/authorizer/internal/service" "github.com/authorizerdev/authorizer/internal/storage/schemas" "github.com/authorizerdev/authorizer/internal/token" "github.com/authorizerdev/authorizer/internal/utils" @@ -82,6 +83,52 @@ func (h *httpProvider) VerifyEmailHandler() gin.HandlerFunc { return } + if user.RevokedTimestamp != nil { + log.Debug().Msg("User access has been revoked") + errorRes["error"] = "user access has been revoked" + utils.HandleRedirectORJsonResponse(c, http.StatusBadRequest, errorRes, generateRedirectURL(redirectURL, errorRes)) + return + } + + // Resolved once, early: needed both for the MFA-gate-withheld redirect + // below and the success redirect further down. + if redirectURL == "" { + redirectURL = claim["redirect_uri"].(string) + } + if !validators.IsValidRedirectURI(redirectURL, h.Config.AllowedOrigins, hostname) { + log.Debug().Msg("Invalid redirect URI in token claim") + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid redirect uri"}) + return + } + + // MFA gate: this REST endpoint is what the emailed verification/magic + // link literally points to, so it must enforce the same gate every + // other login entry point does (login.go/signup.go/oauth_callback.go). + // Previously this handler issued tokens unconditionally, bypassing MFA + // entirely for magic-link login and signup email verification - the + // GraphQL verify_email mutation's own gate fix (service.VerifyEmail) + // never covered this REST path since it's a separate implementation. + meta := service.MetaFromGin(c) + side := &service.ResponseSideEffects{} + withheld, redirectSuffix, gateErr := h.ServiceProvider.EvaluateMFAGateForOAuth(c, meta, side, user) + if gateErr != nil { + log.Debug().Err(gateErr).Msg("MFA gate rejected email verification") + errorRes["error"] = gateErr.Error() + utils.HandleRedirectORJsonResponse(c, http.StatusBadRequest, errorRes, generateRedirectURL(redirectURL, errorRes)) + return + } + if withheld { + service.ApplyToGin(c, side) + target := redirectURL + if strings.Contains(target, "?") { + target = target + "&" + redirectSuffix + } else { + target = target + "?" + strings.TrimPrefix(redirectSuffix, "&") + } + c.Redirect(http.StatusTemporaryRedirect, target) + return + } + isSignUp := false // update email_verified_at in users table if user.EmailVerifiedAt == nil { @@ -199,15 +246,6 @@ func (h *httpProvider) VerifyEmailHandler() gin.HandlerFunc { _ = h.MemoryStoreProvider.SetUserSession(sessionKey, constants.TokenTypeRefreshToken+"_"+authToken.FingerPrint, authToken.RefreshToken.Token, authToken.RefreshToken.ExpiresAt) } - if redirectURL == "" { - redirectURL = claim["redirect_uri"].(string) - } - if !validators.IsValidRedirectURI(redirectURL, h.Config.AllowedOrigins, hostname) { - log.Debug().Msg("Invalid redirect URI in token claim") - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid redirect uri"}) - return - } - if strings.Contains(redirectURL, "?") { redirectURL = redirectURL + "&" + params } else { diff --git a/internal/integration_tests/admin_reset_mfa_test.go b/internal/integration_tests/admin_reset_mfa_test.go new file mode 100644 index 00000000..5ae42d95 --- /dev/null +++ b/internal/integration_tests/admin_reset_mfa_test.go @@ -0,0 +1,104 @@ +package integration_tests + +import ( + "fmt" + "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/crypto" + "github.com/authorizerdev/authorizer/internal/graph/model" + "github.com/authorizerdev/authorizer/internal/refs" + "github.com/authorizerdev/authorizer/internal/storage/schemas" +) + +// TestAdminResetMFA covers the _update_user{reset_mfa: true} admin recovery +// path: it must clear mfa_locked_at, is_multi_factor_auth_enabled, and +// has_skipped_mfa_setup_at, and delete every enrolled authenticator and +// webauthn credential row for the user. +func TestAdminResetMFA(t *testing.T) { + cfg := getTestConfig() + cfg.EnableMFA = true + cfg.EnableTOTPLogin = true + ts := initTestSetup(t, cfg) + req, ctx := createContext(ts) + + email := "admin_reset_mfa_" + uuid.NewString() + "@authorizer.dev" + password := "Password@123" + _, err := ts.GraphQLProvider.SignUp(ctx, &model.SignUpRequest{ + Email: &email, Password: password, ConfirmPassword: password, + }) + require.NoError(t, err) + // cfg.EnableMFA/EnableTOTPLogin are both on here, so SignUp itself now + // runs the same MFA gate as Login (Task 7): its response withholds the + // token and the User field (matching login.go's own mfaGateOfferAll/ + // BlockEnroll responses, which never set User either). Look the user up + // by email instead of relying on a User field that isn't there for this + // path. + signedUpUser, err := ts.StorageProvider.GetUserByEmail(ctx, email) + require.NoError(t, err) + userID := signedUpUser.ID + + // Put the user into the exact state reset_mfa is meant to unwind: locked, + // MFA enabled, skip recorded, plus a real TOTP authenticator row and a + // webauthn credential row. + now := time.Now().Unix() + user, err := ts.StorageProvider.GetUserByID(ctx, userID) + require.NoError(t, err) + user.MFALockedAt = &now + user.IsMultiFactorAuthEnabled = refs.NewBoolRef(true) + user.HasSkippedMFASetupAt = &now + _, err = ts.StorageProvider.UpdateUser(ctx, user) + require.NoError(t, err) + + _, err = ts.StorageProvider.AddAuthenticator(ctx, &schemas.Authenticator{ + UserID: userID, + Method: constants.EnvKeyTOTPAuthenticator, + Secret: "test-secret", + VerifiedAt: &now, + }) + require.NoError(t, err) + + _, err = ts.StorageProvider.AddWebauthnCredential(ctx, &schemas.WebauthnCredential{ + UserID: userID, + CredentialID: uuid.NewString(), + PublicKey: "test-public-key", + }) + require.NoError(t, err) + + // sanity-check the fixture actually landed before asserting the reset. + authenticator, err := ts.StorageProvider.GetAuthenticatorDetailsByUserId(ctx, userID, constants.EnvKeyTOTPAuthenticator) + require.NoError(t, err) + require.NotNil(t, authenticator) + creds, err := ts.StorageProvider.ListWebauthnCredentialsByUserID(ctx, userID) + require.NoError(t, err) + require.Len(t, creds, 1) + + h, err := crypto.EncryptPassword(cfg.AdminSecret) + require.NoError(t, err) + req.Header.Set("Cookie", fmt.Sprintf("%s=%s", constants.AdminCookieName, h)) + + updateRes, err := ts.GraphQLProvider.UpdateUser(ctx, &model.UpdateUserRequest{ + ID: userID, + ResetMfa: refs.NewBoolRef(true), + }) + require.NoError(t, err) + require.NotNil(t, updateRes) + + reset, err := ts.StorageProvider.GetUserByID(ctx, userID) + require.NoError(t, err) + assert.Nil(t, reset.MFALockedAt, "reset_mfa must clear mfa_locked_at") + assert.Nil(t, reset.IsMultiFactorAuthEnabled, "reset_mfa must clear is_multi_factor_auth_enabled") + assert.Nil(t, reset.HasSkippedMFASetupAt, "reset_mfa must clear has_skipped_mfa_setup_at") + + _, err = ts.StorageProvider.GetAuthenticatorDetailsByUserId(ctx, userID, constants.EnvKeyTOTPAuthenticator) + assert.Error(t, err, "reset_mfa must delete the user's authenticator rows") + + remainingCreds, err := ts.StorageProvider.ListWebauthnCredentialsByUserID(ctx, userID) + require.NoError(t, err) + assert.Empty(t, remainingCreds, "reset_mfa must delete the user's webauthn credentials") +} diff --git a/internal/integration_tests/admin_update_user_enforce_mfa_test.go b/internal/integration_tests/admin_update_user_enforce_mfa_test.go new file mode 100644 index 00000000..56b0509d --- /dev/null +++ b/internal/integration_tests/admin_update_user_enforce_mfa_test.go @@ -0,0 +1,53 @@ +package integration_tests + +import ( + "fmt" + "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/crypto" + "github.com/authorizerdev/authorizer/internal/graph/model" + "github.com/authorizerdev/authorizer/internal/refs" + "github.com/authorizerdev/authorizer/internal/storage/schemas" +) + +// TestAdminUpdateUserEnforceMFA verifies EnforceMFA is absolute on the admin +// path: an admin cannot persist IsMultiFactorAuthEnabled=false while the org +// enforces MFA, matching the self-service update_profile.go guard. +func TestAdminUpdateUserEnforceMFA(t *testing.T) { + cfg := getTestConfig() + cfg.EnableMFA = true + cfg.EnableTOTPLogin = true + cfg.EnforceMFA = true + ts := initTestSetup(t, cfg) + req, ctx := createContext(ts) + + email := "admin_enforce_mfa_" + 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) + + h, err := crypto.EncryptPassword(cfg.AdminSecret) + require.NoError(t, err) + req.Header.Set("Cookie", fmt.Sprintf("%s=%s", constants.AdminCookieName, h)) + + _, err = ts.GraphQLProvider.UpdateUser(ctx, &model.UpdateUserRequest{ + ID: user.ID, + IsMultiFactorAuthEnabled: refs.NewBoolRef(false), + }) + require.Error(t, err, "admin must not be able to disable MFA while EnforceMFA is on") + + persisted, err := ts.StorageProvider.GetUserByID(ctx, user.ID) + require.NoError(t, err) + assert.True(t, refs.BoolValue(persisted.IsMultiFactorAuthEnabled), "MFA must remain enabled after a rejected disable") +} diff --git a/internal/integration_tests/admin_users_grpc_test.go b/internal/integration_tests/admin_users_grpc_test.go index 51c95218..4a16d729 100644 --- a/internal/integration_tests/admin_users_grpc_test.go +++ b/internal/integration_tests/admin_users_grpc_test.go @@ -6,6 +6,7 @@ import ( "testing" "github.com/google/uuid" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "google.golang.org/grpc" "google.golang.org/grpc/codes" @@ -165,6 +166,29 @@ func TestAdminUpdateUserGRPC(t *testing.T) { _, err := client.UpdateUser(adminCtx(cfg.AdminSecret), &authorizerv1.UpdateUserRequest{Id: id}) require.Error(t, err) }) + + t.Run("reset_mfa clears locked/enabled/skipped MFA state", func(t *testing.T) { + now := int64(1) + user, err := ts.StorageProvider.AddUser(context.Background(), &schemas.User{ + Email: refs.NewStringRef("admin-users-grpc-reset-mfa-" + uuid.New().String() + "@authorizer.test"), + SignupMethods: constants.AuthRecipeMethodBasicAuth, + EmailVerifiedAt: &now, + IsMultiFactorAuthEnabled: refs.NewBoolRef(true), + MFALockedAt: &now, + HasSkippedMFASetupAt: &now, + }) + require.NoError(t, err) + + resp, err := client.UpdateUser(adminCtx(cfg.AdminSecret), &authorizerv1.UpdateUserRequest{ + Id: user.ID, + ResetMfa: refs.NewBoolRef(true), + }) + require.NoError(t, err) + require.NotNil(t, resp.User) + assert.False(t, resp.User.IsMultiFactorAuthEnabled) + assert.Zero(t, resp.User.MfaLockedAt) + assert.Zero(t, resp.User.HasSkippedMfaSetupAt) + }) } // TestAdminDeleteUserGRPC exercises AuthorizerAdminService.DeleteUser over gRPC: diff --git a/internal/integration_tests/csrf_authorize_test.go b/internal/integration_tests/csrf_authorize_test.go new file mode 100644 index 00000000..e8e30c9d --- /dev/null +++ b/internal/integration_tests/csrf_authorize_test.go @@ -0,0 +1,54 @@ +package integration_tests + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestCSRFExemptsAuthorizePost proves POST /authorize (required by RFC 6749 +// §3.1 / OIDC Core §3.1.2.1) survives the CSRF middleware: it's reached via +// top-level navigation/form-submit from arbitrary third-party RPs, not a +// cookie-authenticated mutation, so Origin/Content-Type enforcement does not +// apply — same rationale as /userinfo. +func TestCSRFExemptsAuthorizePost(t *testing.T) { + cfg := getTestConfig() + ts := initTestSetup(t, cfg) + + gin.SetMode(gin.TestMode) + router := gin.New() + router.Use(ts.HttpProvider.CSRFMiddleware()) + reached := false + router.POST("/authorize", func(c *gin.Context) { + reached = true + c.Status(http.StatusOK) + }) + router.POST("/other", func(c *gin.Context) { + c.Status(http.StatusOK) + }) + + t.Run("plain form POST reaches the authorize handler", func(t *testing.T) { + req, err := http.NewRequest(http.MethodPost, "/authorize", nil) + require.NoError(t, err) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + assert.True(t, reached, "authorize handler must be reached, not blocked by CSRF") + assert.NotEqual(t, http.StatusForbidden, w.Code) + }) + + t.Run("other POST paths are NOT exempt", func(t *testing.T) { + req, err := http.NewRequest(http.MethodPost, "/other", nil) + require.NoError(t, err) + req.Header.Set("Origin", "https://attacker.example.com") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusForbidden, w.Code, + "the /authorize exemption must not leak to other POST routes") + }) +} diff --git a/internal/integration_tests/csrf_userinfo_test.go b/internal/integration_tests/csrf_userinfo_test.go new file mode 100644 index 00000000..cb29f6d2 --- /dev/null +++ b/internal/integration_tests/csrf_userinfo_test.go @@ -0,0 +1,54 @@ +package integration_tests + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestCSRFExemptsUserInfoPost proves POST /userinfo (required by OIDC Core +// §5.3.1) survives the CSRF middleware: it's authenticated via a bearer +// access token, not cookies, so Origin allow-listing does not apply — same +// rationale as /oauth/token. +func TestCSRFExemptsUserInfoPost(t *testing.T) { + cfg := getTestConfig() + ts := initTestSetup(t, cfg) + + gin.SetMode(gin.TestMode) + router := gin.New() + router.Use(ts.HttpProvider.CSRFMiddleware()) + reached := false + router.POST("/userinfo", func(c *gin.Context) { + reached = true + c.Status(http.StatusOK) + }) + router.POST("/other", func(c *gin.Context) { + c.Status(http.StatusOK) + }) + + t.Run("bearer-authenticated POST reaches the userinfo handler", func(t *testing.T) { + req, err := http.NewRequest(http.MethodPost, "/userinfo", nil) + require.NoError(t, err) + req.Header.Set("Authorization", "Bearer sometoken") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + assert.True(t, reached, "userinfo handler must be reached, not blocked by CSRF") + assert.NotEqual(t, http.StatusForbidden, w.Code) + }) + + t.Run("other POST paths are NOT exempt", func(t *testing.T) { + req, err := http.NewRequest(http.MethodPost, "/other", nil) + require.NoError(t, err) + req.Header.Set("Origin", "https://attacker.example.com") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusForbidden, w.Code, + "the /userinfo exemption must not leak to other POST routes") + }) +} diff --git a/internal/integration_tests/enrolled_mfa_methods_test.go b/internal/integration_tests/enrolled_mfa_methods_test.go new file mode 100644 index 00000000..c71630cc --- /dev/null +++ b/internal/integration_tests/enrolled_mfa_methods_test.go @@ -0,0 +1,112 @@ +package integration_tests + +import ( + "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/storage/schemas" +) + +// TestEnrolledMFAMethods covers the lazily-resolved User.enrolled_mfa_methods +// GraphQL field: it must report exactly the MFA factors a user has actually +// verified/enrolled, mirroring the verified checks the login MFA gate uses, +// and never report a factor that exists but is unverified. +// +// The tests exercise the resolver's delegate target +// (GraphQLProvider.EnrolledMFAMethods) directly, matching the integration-test +// style of this package. The field is proven lazy separately by the generated +// exec wiring (_User_enrolled_mfa_methods calls ec.resolvers.User(). +// EnrolledMfaMethods only when the field is selected — it never reads a struct +// field), not at runtime here. +func TestEnrolledMFAMethods(t *testing.T) { + cfg := getTestConfig() + cfg.EnableMFA = true + cfg.EnableTOTPLogin = true + ts := initTestSetup(t, cfg) + _, ctx := createContext(ts) + + newUser := func(t *testing.T) string { + t.Helper() + email := "enrolled_mfa_" + uuid.NewString() + "@authorizer.dev" + password := "Password@123" + _, err := ts.GraphQLProvider.SignUp(ctx, &model.SignUpRequest{ + Email: &email, Password: password, ConfirmPassword: password, + }) + require.NoError(t, err) + u, err := ts.StorageProvider.GetUserByEmail(ctx, email) + require.NoError(t, err) + // With EnableMFA/EnableTOTPLogin on, the signup MFA offer pre-creates an + // unverified TOTP authenticator row. Clear it so each test starts from a + // known-empty enrollment state and controls exactly which rows exist. + require.NoError(t, ts.StorageProvider.DeleteAuthenticatorsByUserID(ctx, u.ID)) + return u.ID + } + + t.Run("totp and passkey enrolled, otp not", func(t *testing.T) { + userID := newUser(t) + now := time.Now().Unix() + + _, err := ts.StorageProvider.AddAuthenticator(ctx, &schemas.Authenticator{ + UserID: userID, + Method: constants.EnvKeyTOTPAuthenticator, + Secret: "test-secret", + VerifiedAt: &now, + }) + require.NoError(t, err) + _, err = ts.StorageProvider.AddWebauthnCredential(ctx, &schemas.WebauthnCredential{ + UserID: userID, + CredentialID: uuid.NewString(), + PublicKey: "test-public-key", + }) + require.NoError(t, err) + + methods, err := ts.GraphQLProvider.EnrolledMFAMethods(ctx, &model.User{ID: userID}) + require.NoError(t, err) + assert.ElementsMatch(t, []string{ + constants.EnvKeyTOTPAuthenticator, // "totp" + constants.AuthRecipeMethodWebauthn, // "webauthn" + }, methods, "must report exactly the verified/enrolled factors") + }) + + t.Run("nothing enrolled returns empty non-nil slice", func(t *testing.T) { + userID := newUser(t) + + methods, err := ts.GraphQLProvider.EnrolledMFAMethods(ctx, &model.User{ID: userID}) + require.NoError(t, err) + require.NotNil(t, methods, "field is [String!]! — must never be nil") + assert.Empty(t, methods) + }) + + t.Run("unverified authenticator is excluded, verified email otp included", func(t *testing.T) { + userID := newUser(t) + now := time.Now().Unix() + + // A TOTP row that was never verified (VerifiedAt == nil) — half-enrolled. + // It must NOT count as an enrolled method. + _, err := ts.StorageProvider.AddAuthenticator(ctx, &schemas.Authenticator{ + UserID: userID, + Method: constants.EnvKeyTOTPAuthenticator, + Secret: "unverified-secret", + }) + require.NoError(t, err) + // A verified email-OTP authenticator — must count. + _, err = ts.StorageProvider.AddAuthenticator(ctx, &schemas.Authenticator{ + UserID: userID, + Method: constants.EnvKeyEmailOTPAuthenticator, + Secret: "email-otp-secret", + VerifiedAt: &now, + }) + require.NoError(t, err) + + methods, err := ts.GraphQLProvider.EnrolledMFAMethods(ctx, &model.User{ID: userID}) + require.NoError(t, err) + assert.ElementsMatch(t, []string{constants.EnvKeyEmailOTPAuthenticator}, methods, + "unverified TOTP must be excluded; verified email OTP must be included") + }) +} diff --git a/internal/integration_tests/fga_test.go b/internal/integration_tests/fga_test.go index cc124b59..3aacf723 100644 --- a/internal/integration_tests/fga_test.go +++ b/internal/integration_tests/fga_test.go @@ -58,17 +58,17 @@ func initFGATestSetup(t *testing.T, cfg *config.Config) (*testSetup, engine.Auth storageProvider, err := storage.New(cfg, &storage.Dependencies{Log: &logger}) require.NoError(t, err) - authProvider, err := authenticators.New(cfg, &authenticators.Dependencies{Log: &logger, StorageProvider: storageProvider}) + memoryStoreProvider, err := memory_store.New(cfg, &memory_store.Dependencies{Log: &logger}) + require.NoError(t, err) + authProvider, err := authenticators.New(cfg, &authenticators.Dependencies{Log: &logger, StorageProvider: storageProvider, MemoryStoreProvider: memoryStoreProvider}) require.NoError(t, err) emailProvider, err := email.New(cfg, &email.Dependencies{Log: &logger, StorageProvider: storageProvider}) require.NoError(t, err) eventsProvider, err := events.New(cfg, &events.Dependencies{Log: &logger, StorageProvider: storageProvider}) require.NoError(t, err) - memoryStoreProvider, err := memory_store.New(cfg, &memory_store.Dependencies{Log: &logger}) - require.NoError(t, err) smsProvider, err := sms.New(cfg, &sms.Dependencies{Log: &logger}) require.NoError(t, err) - tokenProvider, err := token.New(cfg, &token.Dependencies{Log: &logger, MemoryStoreProvider: memoryStoreProvider}) + tokenProvider, err := token.New(cfg, &token.Dependencies{Log: &logger, MemoryStoreProvider: memoryStoreProvider, StorageProvider: storageProvider}) require.NoError(t, err) rateLimitProvider, err := rate_limit.New(cfg, &rate_limit.Dependencies{Log: &logger}) require.NoError(t, err) diff --git a/internal/integration_tests/grpc_mfa_gate_test.go b/internal/integration_tests/grpc_mfa_gate_test.go new file mode 100644 index 00000000..863527b2 --- /dev/null +++ b/internal/integration_tests/grpc_mfa_gate_test.go @@ -0,0 +1,364 @@ +package integration_tests + +import ( + "context" + "fmt" + "net" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/status" + "google.golang.org/grpc/test/bufconn" + + "github.com/authorizerdev/authorizer/internal/config" + "github.com/authorizerdev/authorizer/internal/constants" + "github.com/authorizerdev/authorizer/internal/grpcsrv" + "github.com/authorizerdev/authorizer/internal/refs" + "github.com/authorizerdev/authorizer/internal/storage/schemas" + + authorizerv1 "github.com/authorizerdev/authorizer/gen/go/authorizer/v1" +) + +// bootPublicClientForConfig is newPublicClient's sibling for tests that need +// to seed cfg (e.g. EnableMFA) before initTestSetup boots the service +// provider; newPublicClient always calls getTestConfig() internally so it +// can't be reused here. +func bootPublicClientForConfig(t *testing.T, cfg *config.Config) (authorizerv1.AuthorizerServiceClient, *testSetup) { + t.Helper() + ts := initTestSetup(t, cfg) + + srv, err := grpcsrv.New(":0", &grpcsrv.Dependencies{ + Log: ts.Logger, + Config: cfg, + ServiceProvider: ts.ServiceProvider, + TokenProvider: ts.TokenProvider, + }) + require.NoError(t, err) + + lis := bufconn.Listen(1 << 20) + t.Cleanup(func() { _ = lis.Close() }) + go func() { _ = srv.GRPCServer().Serve(lis) }() + t.Cleanup(srv.GRPCServer().GracefulStop) + + conn, err := grpc.NewClient( + "passthrough:///bufconn", + grpc.WithContextDialer(func(_ context.Context, _ string) (net.Conn, error) { return lis.Dial() }), + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + require.NoError(t, err) + t.Cleanup(func() { _ = conn.Close() }) + + return authorizerv1.NewAuthorizerServiceClient(conn), ts +} + +// mfaSessionCookieCtx builds an outgoing gRPC context carrying the mfa +// session cookie directly (no "cookie:" prefix parsing needed since the +// value already is "name=value"). +func mfaSessionCookieCtx(session string) context.Context { + return metadata.NewOutgoingContext(context.Background(), + metadata.Pairs("cookie", constants.MfaCookieName+"_session="+session)) +} + +// findSetCookie returns the first Set-Cookie header value (as captured via +// grpc.Header) whose cookie name matches, or "" if none match. Mirrors how +// TestSessionGRPCRequiresCookieOnly extracts the session cookie from gRPC +// response metadata. +func findSetCookie(cookies []string, name string) string { + prefix := name + "=" + for _, c := range cookies { + if len(c) >= len(prefix) && c[:len(prefix)] == prefix { + return c + } + } + return "" +} + +// TestSkipMfaSetupGRPC exercises the new SkipMfaSetup RPC end-to-end over +// gRPC: sign up + enable MFA, log in (token withheld behind an MFA session +// cookie), then prove skip_mfa_setup issues the withheld token and persists +// HasSkippedMfaSetupAt. Closes the REST/gRPC parity gap for the GraphQL-only +// skip_mfa_setup mutation added in PR #686. +func TestSkipMfaSetupGRPC(t *testing.T) { + cfg := getTestConfig() + cfg.EnableMFA = true + cfg.EnableTOTPLogin = true + c, ts := bootPublicClientForConfig(t, cfg) + ctx := context.Background() + + email := "grpc_skip_mfa_" + uuid.New().String() + "@authorizer.dev" + password := "Password@123" + + _, err := c.Signup(ctx, &authorizerv1.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) + + var header metadata.MD + loginResp, err := c.Login(ctx, &authorizerv1.LoginRequest{Email: email, Password: password}, grpc.Header(&header)) + require.NoError(t, err) + require.Empty(t, loginResp.AccessToken, "first login with optional MFA and no prior enrollment must withhold the token") + require.True(t, loginResp.ShouldShowTotpScreen) + + mfaCookie := findSetCookie(header.Get("Set-Cookie"), constants.MfaCookieName+"_session") + require.NotEmpty(t, mfaCookie, "login must set an mfa session cookie via gRPC response metadata") + mfaCtx := metadata.NewOutgoingContext(context.Background(), metadata.Pairs("cookie", mfaCookie)) + + t.Run("skip issues the withheld token and persists HasSkippedMfaSetupAt", func(t *testing.T) { + resp, err := c.SkipMfaSetup(mfaCtx, &authorizerv1.SkipMfaSetupRequest{Email: email}) + require.NoError(t, err) + require.NotEmpty(t, resp.AccessToken, "skip must issue the token withheld at login") + + updated, err := ts.StorageProvider.GetUserByEmail(ctx, email) + require.NoError(t, err) + assert.NotNil(t, updated.HasSkippedMFASetupAt) + }) + + t.Run("without a valid mfa session cookie it is rejected, not Unimplemented", func(t *testing.T) { + _, err := c.SkipMfaSetup(context.Background(), &authorizerv1.SkipMfaSetupRequest{Email: "nobody@authorizer.dev"}) + require.Error(t, err) + st, ok := status.FromError(err) + require.True(t, ok) + assert.Equal(t, codes.Unauthenticated, st.Code()) + }) +} + +// TestLockMfaGRPC exercises the new LockMfa RPC end-to-end over gRPC: a +// caller with a valid mfa session cookie can lock their account (no verified +// OTP fallback enrolled), and a caller without one is rejected. +func TestLockMfaGRPC(t *testing.T) { + cfg := getTestConfig() + cfg.EnableMFA = true + c, ts := bootPublicClientForConfig(t, cfg) + ctx := context.Background() + + email := "grpc_lock_mfa_" + uuid.New().String() + "@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) + + mfaSession := uuid.NewString() + require.NoError(t, ts.MemoryStoreProvider.SetMfaSession(user.ID, mfaSession, constants.MFASessionPurposeVerified, time.Now().Add(5*time.Minute).Unix())) + + t.Run("locks the account with a valid mfa session", func(t *testing.T) { + resp, err := c.LockMfa(mfaSessionCookieCtx(mfaSession), &authorizerv1.LockMfaRequest{Email: email}) + require.NoError(t, err) + assert.NotEmpty(t, resp.Message) + + updated, err := ts.StorageProvider.GetUserByEmail(ctx, email) + require.NoError(t, err) + assert.NotNil(t, updated.MFALockedAt) + }) + + t.Run("without a valid mfa session it is rejected, not Unimplemented", func(t *testing.T) { + _, err := c.LockMfa(context.Background(), &authorizerv1.LockMfaRequest{Email: email}) + require.Error(t, err) + st, ok := status.FromError(err) + require.True(t, ok) + assert.Equal(t, codes.Unauthenticated, st.Code()) + }) +} + +// TestEmailOtpMfaSetupGRPC exercises the new EmailOtpMfaSetup RPC end-to-end +// over gRPC, proving the transport correctly reaches both auth modes +// resolveOTPSetupCaller supports (already proven at the GraphQL layer by +// TestEmailOTPMFASetupViaMfaSessionCookie / TestOTPMFASetupRejectsUnauthenticatedCaller +// in otp_mfa_setup_test.go): the MFA-session-cookie + email fallback for a +// caller in the withheld first-time-offer state, and the ordinary bearer +// token used by an already-authenticated caller adding a second factor. +func TestEmailOtpMfaSetupGRPC(t *testing.T) { + cfg := getTestConfig() + cfg.EnableMFA = true + cfg.EnableTOTPLogin = true + cfg.EnableEmailOTP = true + cfg.SMTPHost = "localhost" + cfg.SMTPPort = 1025 + cfg.SMTPSenderEmail = "test@authorizer.dev" + cfg.SMTPSenderName = "Test" + cfg.SMTPLocalName = "Test" + cfg.SMTPSkipTLSVerification = true + cfg.IsEmailServiceEnabled = true + c, ts := bootPublicClientForConfig(t, cfg) + ctx := context.Background() + password := "Password@123" + + t.Run("cookie mode: mfa session cookie + email, no bearer token", func(t *testing.T) { + email := "grpc_email_otp_cookie_" + uuid.New().String() + "@authorizer.dev" + + _, err := c.Signup(ctx, &authorizerv1.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) + + var header metadata.MD + loginResp, err := c.Login(ctx, &authorizerv1.LoginRequest{Email: email, Password: password}, grpc.Header(&header)) + require.NoError(t, err) + require.Empty(t, loginResp.AccessToken, "first login with optional MFA and no prior enrollment must withhold the token") + + mfaCookie := findSetCookie(header.Get("Set-Cookie"), constants.MfaCookieName+"_session") + require.NotEmpty(t, mfaCookie, "login must set an mfa session cookie via gRPC response metadata") + mfaCtx := metadata.NewOutgoingContext(context.Background(), metadata.Pairs("cookie", mfaCookie)) + + resp, err := c.EmailOtpMfaSetup(mfaCtx, &authorizerv1.EmailOtpMfaSetupRequest{Email: email}) + require.NoError(t, err, "email_otp_mfa_setup must be reachable via cookie+email with no bearer token") + require.NotEmpty(t, resp.Message) + + authenticator, err := ts.StorageProvider.GetAuthenticatorDetailsByUserId(ctx, user.ID, constants.EnvKeyEmailOTPAuthenticator) + require.NoError(t, err) + require.NotNil(t, authenticator) + assert.Nil(t, authenticator.VerifiedAt, "setup alone must not mark the enrollment verified") + }) + + t.Run("bearer-token mode: already authenticated caller, no email param", func(t *testing.T) { + email := "grpc_email_otp_bearer_" + uuid.New().String() + "@authorizer.dev" + + // Signup omits is_multi_factor_auth_enabled, which the gRPC handler + // still forwards explicitly as false (see AuthorizerHandler.Signup), + // so the MFA gate never engages here and the token is issued + // directly -- this caller reaches EmailOtpMfaSetup the ordinary + // already-logged-in "add a second factor from settings" way. + signupResp, err := c.Signup(ctx, &authorizerv1.SignupRequest{ + Email: email, Password: password, ConfirmPassword: password, + }) + require.NoError(t, err) + require.NotEmpty(t, signupResp.AccessToken, "no MFA enrolled -> token issued directly") + + user, err := ts.StorageProvider.GetUserByEmail(ctx, email) + require.NoError(t, err) + + resp, err := c.EmailOtpMfaSetup(bearerCtx(signupResp.AccessToken), &authorizerv1.EmailOtpMfaSetupRequest{}) + require.NoError(t, err, "email_otp_mfa_setup must be reachable via bearer token with no email param") + require.NotEmpty(t, resp.Message) + + authenticator, err := ts.StorageProvider.GetAuthenticatorDetailsByUserId(ctx, user.ID, constants.EnvKeyEmailOTPAuthenticator) + require.NoError(t, err) + require.NotNil(t, authenticator) + assert.Nil(t, authenticator.VerifiedAt, "setup alone must not mark the enrollment verified") + }) + + t.Run("without a valid token or cookie+email it is rejected, not Unimplemented", func(t *testing.T) { + _, err := c.EmailOtpMfaSetup(context.Background(), &authorizerv1.EmailOtpMfaSetupRequest{}) + require.Error(t, err) + st, ok := status.FromError(err) + require.True(t, ok) + assert.Equal(t, codes.Unauthenticated, st.Code()) + }) +} + +// TestSmsOtpMfaSetupGRPC is TestEmailOtpMfaSetupGRPC's SMS twin -- same two +// auth modes and rejection case, keyed by phone_number instead of email. +func TestSmsOtpMfaSetupGRPC(t *testing.T) { + cfg := getTestConfig() + cfg.EnableMFA = true + cfg.EnableSMSOTP = true + cfg.IsSMSServiceEnabled = true + cfg.EnableMobileBasicAuthentication = true + cfg.TwilioAPISecret = "test-twilio-api-secret" + cfg.TwilioAPIKey = "test-twilio-api-key" + cfg.TwilioAccountSID = "test-twilio-account-sid" + cfg.TwilioSender = "test-twilio-sender" + c, ts := bootPublicClientForConfig(t, cfg) + ctx := context.Background() + password := "Password@123" + + t.Run("cookie mode: mfa session cookie + phone_number, no bearer token", func(t *testing.T) { + mobile := fmt.Sprintf("+1%010d", time.Now().UnixNano()%10000000000) + + _, err := c.Signup(ctx, &authorizerv1.SignupRequest{ + PhoneNumber: mobile, Password: password, ConfirmPassword: password, + }) + require.NoError(t, err) + + user, err := ts.StorageProvider.GetUserByPhoneNumber(ctx, mobile) + require.NoError(t, err) + // Signup's own phone-verification OTP is irrelevant here; mark the + // phone verified directly so login reaches the MFA gate instead of + // the phone-verification challenge, same as the GraphQL-layer twin. + now := time.Now().Unix() + user.PhoneNumberVerifiedAt = &now + user.IsMultiFactorAuthEnabled = refs.NewBoolRef(true) + _, err = ts.StorageProvider.UpdateUser(ctx, user) + require.NoError(t, err) + + var header metadata.MD + loginResp, err := c.Login(ctx, &authorizerv1.LoginRequest{PhoneNumber: mobile, Password: password}, grpc.Header(&header)) + require.NoError(t, err) + require.Empty(t, loginResp.AccessToken, "first login with optional MFA and no prior enrollment must withhold the token") + + mfaCookie := findSetCookie(header.Get("Set-Cookie"), constants.MfaCookieName+"_session") + require.NotEmpty(t, mfaCookie, "login must set an mfa session cookie via gRPC response metadata") + mfaCtx := metadata.NewOutgoingContext(context.Background(), metadata.Pairs("cookie", mfaCookie)) + + resp, err := c.SmsOtpMfaSetup(mfaCtx, &authorizerv1.SmsOtpMfaSetupRequest{PhoneNumber: mobile}) + require.NoError(t, err, "sms_otp_mfa_setup must be reachable via cookie+phone_number with no bearer token") + require.NotEmpty(t, resp.Message) + + authenticator, err := ts.StorageProvider.GetAuthenticatorDetailsByUserId(ctx, user.ID, constants.EnvKeySMSOTPAuthenticator) + require.NoError(t, err) + require.NotNil(t, authenticator) + assert.Nil(t, authenticator.VerifiedAt, "setup alone must not mark the enrollment verified") + }) + + t.Run("bearer-token mode: already authenticated caller, no phone_number param", func(t *testing.T) { + mobile := fmt.Sprintf("+1%010d", time.Now().UnixNano()%10000000000) + + _, err := c.Signup(ctx, &authorizerv1.SignupRequest{ + PhoneNumber: mobile, Password: password, ConfirmPassword: password, + }) + require.NoError(t, err) + + user, err := ts.StorageProvider.GetUserByPhoneNumber(ctx, mobile) + require.NoError(t, err) + now := time.Now().Unix() + user.PhoneNumberVerifiedAt = &now + _, err = ts.StorageProvider.UpdateUser(ctx, user) + require.NoError(t, err) + + // Same reasoning as the email twin: is_multi_factor_auth_enabled is + // always forwarded explicitly (false here), so login issues the + // token directly instead of hitting the MFA gate. + loginResp, err := c.Login(ctx, &authorizerv1.LoginRequest{PhoneNumber: mobile, Password: password}) + require.NoError(t, err) + require.NotEmpty(t, loginResp.AccessToken, "no MFA enrolled -> token issued directly") + + resp, err := c.SmsOtpMfaSetup(bearerCtx(loginResp.AccessToken), &authorizerv1.SmsOtpMfaSetupRequest{}) + require.NoError(t, err, "sms_otp_mfa_setup must be reachable via bearer token with no phone_number param") + require.NotEmpty(t, resp.Message) + + authenticator, err := ts.StorageProvider.GetAuthenticatorDetailsByUserId(ctx, user.ID, constants.EnvKeySMSOTPAuthenticator) + require.NoError(t, err) + require.NotNil(t, authenticator) + assert.Nil(t, authenticator.VerifiedAt, "setup alone must not mark the enrollment verified") + }) + + t.Run("without a valid token or cookie+phone_number it is rejected, not Unimplemented", func(t *testing.T) { + _, err := c.SmsOtpMfaSetup(context.Background(), &authorizerv1.SmsOtpMfaSetupRequest{}) + require.Error(t, err) + st, ok := status.FromError(err) + require.True(t, ok) + assert.Equal(t, codes.Unauthenticated, st.Code()) + }) +} diff --git a/internal/integration_tests/lock_mfa_test.go b/internal/integration_tests/lock_mfa_test.go new file mode 100644 index 00000000..68edd744 --- /dev/null +++ b/internal/integration_tests/lock_mfa_test.go @@ -0,0 +1,178 @@ +package integration_tests + +import ( + "errors" + "fmt" + "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/service" + "github.com/authorizerdev/authorizer/internal/storage/schemas" +) + +// TestLockMFA covers: locking with a valid mfa session and no OTP fallback +// succeeds and blocks subsequent login; a caller with no valid mfa session +// is rejected with Unauthenticated. +func TestLockMFA(t *testing.T) { + const password = "Password@123" + + t.Run("locks the account and blocks subsequent login", func(t *testing.T) { + cfg := getTestConfig() + cfg.EnableMFA = true + cfg.EnableTOTPLogin = true + ts := initTestSetup(t, cfg) + req, ctx := createContext(ts) + + email := "lock_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) + + // LockMFA is reached mid-MFA-flow, identified by the mfa session + // cookie plus email — same identification pattern as SkipMFASetup. + // Set the session directly rather than driving a full TOTP/OTP + // challenge; LockMFA itself never issues a token, so there is + // nothing about a real challenge this test needs to exercise. + mfaSession := uuid.NewString() + require.NoError(t, ts.MemoryStoreProvider.SetMfaSession(user.ID, mfaSession, constants.MFASessionPurposeVerified, time.Now().Add(5*time.Minute).Unix())) + req.Header.Set("Cookie", fmt.Sprintf("%s=%s", constants.MfaCookieName+"_session", mfaSession)) + + lockRes, err := ts.GraphQLProvider.LockMFA(ctx, &model.LockMfaRequest{Email: &email}) + require.NoError(t, err) + require.NotNil(t, lockRes) + + locked, err := ts.StorageProvider.GetUserByEmail(ctx, email) + require.NoError(t, err) + assert.NotNil(t, locked.MFALockedAt, "lock_mfa must persist MFALockedAt") + + // The signup password is real (unlike a bare AddUser fixture), so a + // rejection here is unambiguously the lockout check, not an + // incidental bad-password mismatch. + loginRes, err := ts.GraphQLProvider.Login(ctx, &model.LoginRequest{Email: &email, Password: password}) + require.Error(t, err) + assert.Nil(t, loginRes) + + 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, "a locked account must be rejected by the lockout check specifically, not any other error kind") + }) + + t.Run("refuses to lock when a verified SMS-OTP fallback exists", func(t *testing.T) { + cfg := getTestConfig() + cfg.EnableMFA = true + ts := initTestSetup(t, cfg) + req, ctx := createContext(ts) + + email := "lock_mfa_otp_fallback_" + 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) + + // A verified SMS-OTP authenticator is a working recovery path, so + // locking must be refused: the user should use it instead. + _, err = ts.StorageProvider.AddAuthenticator(ctx, &schemas.Authenticator{ + UserID: user.ID, + Method: constants.EnvKeySMSOTPAuthenticator, + VerifiedAt: &now, + }) + require.NoError(t, err) + + mfaSession := uuid.NewString() + require.NoError(t, ts.MemoryStoreProvider.SetMfaSession(user.ID, mfaSession, constants.MFASessionPurposeVerified, time.Now().Add(5*time.Minute).Unix())) + req.Header.Set("Cookie", fmt.Sprintf("%s=%s", constants.MfaCookieName+"_session", mfaSession)) + + lockRes, err := ts.GraphQLProvider.LockMFA(ctx, &model.LockMfaRequest{Email: &email}) + require.Error(t, err) + assert.Nil(t, lockRes) + + 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, "a verified OTP fallback must block locking with FailedPrecondition") + + unlocked, err := ts.StorageProvider.GetUserByEmail(ctx, email) + require.NoError(t, err) + assert.Nil(t, unlocked.MFALockedAt, "a refused lock must not have persisted MFALockedAt") + }) + + for _, enforceMFA := range []bool{false, true} { + t.Run(fmt.Sprintf("rejects with Unauthenticated when caller has no valid mfa session (EnforceMFA=%v)", enforceMFA), func(t *testing.T) { + cfg := getTestConfig() + cfg.EnableMFA = true + cfg.EnforceMFA = enforceMFA + ts := initTestSetup(t, cfg) + _, ctx := createContext(ts) + + email := "lock_mfa_nosession_" + uuid.NewString() + "@authorizer.dev" + now := time.Now().Unix() + _, err := ts.StorageProvider.AddUser(ctx, &schemas.User{ + Email: refs.NewStringRef(email), + EmailVerifiedAt: &now, + SignupMethods: constants.AuthRecipeMethodBasicAuth, + IsMultiFactorAuthEnabled: refs.NewBoolRef(true), + }) + require.NoError(t, err) + + lockRes, err := ts.GraphQLProvider.LockMFA(ctx, &model.LockMfaRequest{Email: &email}) + require.Error(t, err) + assert.Nil(t, lockRes) + + var svcErr *service.Error + require.True(t, errors.As(err, &svcErr)) + assert.Equal(t, service.KindUnauthenticated, svcErr.Kind) + }) + } + + t.Run("rejects a Challenge session (ResendOTP/ForgotPassword) with Unauthenticated", func(t *testing.T) { + cfg := getTestConfig() + cfg.EnableMFA = true + ts := initTestSetup(t, cfg) + req, ctx := createContext(ts) + + email := "lock_mfa_challenge_" + 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) + + // The pre-auth account-lockout DoS: an attacker who only knows the + // victim's email obtains a Challenge session via ResendOTP, then tries + // to permanently lock the account. It must be rejected. + mfaSession := uuid.NewString() + require.NoError(t, ts.MemoryStoreProvider.SetMfaSession(user.ID, mfaSession, constants.MFASessionPurposeChallenge, time.Now().Add(5*time.Minute).Unix())) + req.Header.Set("Cookie", fmt.Sprintf("%s=%s", constants.MfaCookieName+"_session", mfaSession)) + + lockRes, err := ts.GraphQLProvider.LockMFA(ctx, &model.LockMfaRequest{Email: &email}) + require.Error(t, err) + assert.Nil(t, lockRes) + + 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 Challenge session must not be able to lock an account") + + unlocked, err := ts.StorageProvider.GetUserByEmail(ctx, email) + require.NoError(t, err) + assert.Nil(t, unlocked.MFALockedAt, "a rejected Challenge session must not have locked the account") + }) +} diff --git a/internal/integration_tests/login_mfa_cross_identifier_test.go b/internal/integration_tests/login_mfa_cross_identifier_test.go new file mode 100644 index 00000000..142ab296 --- /dev/null +++ b/internal/integration_tests/login_mfa_cross_identifier_test.go @@ -0,0 +1,107 @@ +package integration_tests + +import ( + "fmt" + "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/crypto" + "github.com/authorizerdev/authorizer/internal/graph/model" + "github.com/authorizerdev/authorizer/internal/refs" + "github.com/authorizerdev/authorizer/internal/storage/schemas" +) + +// TestLoginMFACrossIdentifierChallenge is the regression guard for the bug +// where login.go's inline email/SMS-OTP MFA challenge only fired when the +// enrolled method matched the identifier the caller logged in with. A user +// who signed up with email, later verified a phone number, and enrolled +// SMS-OTP as their second factor was silently NOT challenged on an +// email+password login — the SMS branch required isMobileLogin — and fell +// through to resolveMFAGate, which (correctly) does not count email/SMS OTP, +// so they were offered a fresh setup instead of being blocked to verify the +// factor they already opted into. +// +// The challenge must now fire on enrollment alone and send the code to the +// account's own stored contact (user.PhoneNumber), independent of the login +// identifier. +func TestLoginMFACrossIdentifierChallenge(t *testing.T) { + const password = "Password@123" + + cfg := getTestConfig() + cfg.EnableMFA = true + cfg.EnableSMSOTP = true + cfg.IsSMSServiceEnabled = true + ts := initTestSetup(t, cfg) + req, ctx := createContext(ts) + + // Email signup (auto-verified: email verification is off in getTestConfig) + // so the stored password hash is one login.go's bcrypt check accepts. + email := "login_cross_id_" + 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) + + // Later this account verifies a phone number and opts into MFA. + now := time.Now().Unix() + phone := fmt.Sprintf("+1%010d", time.Now().UnixNano()%10000000000) + user.PhoneNumber = refs.NewStringRef(phone) + user.PhoneNumberVerifiedAt = &now + user.IsMultiFactorAuthEnabled = refs.NewBoolRef(true) + user, err = ts.StorageProvider.UpdateUser(ctx, user) + require.NoError(t, err) + + // SMS-OTP is the user's ONLY enrolled/verified second factor. + _, err = ts.StorageProvider.AddAuthenticator(ctx, &schemas.Authenticator{ + UserID: user.ID, + Method: constants.EnvKeySMSOTPAuthenticator, + VerifiedAt: &now, + }) + require.NoError(t, err) + + // Login with EMAIL + password (not phone). The SMS-OTP factor must still + // be challenged. + res, err := ts.GraphQLProvider.Login(ctx, &model.LoginRequest{Email: &email, Password: password}) + require.NoError(t, err) + require.NotNil(t, res) + assert.Nil(t, res.AccessToken, "must not issue a token before the enrolled SMS-OTP factor is verified") + assert.True(t, refs.BoolValue(res.ShouldShowMobileOtpScreen), "an email login must still challenge the account's enrolled SMS-OTP factor") + assert.False(t, refs.BoolValue(res.ShouldOfferSmsOtpMfaSetup), "the user already enrolled SMS-OTP; this must be a verify challenge, not a setup offer") + + // The plaintext OTP is only sent over SMS (which the suite can't + // intercept) and stored as an HMAC digest, keyed by both email and phone + // (generateAndStoreOTP writes both). Overwrite it with a known + // plaintext/digest pair, then complete the challenge via the phone. + storedOTP, err := ts.StorageProvider.GetOTPByPhoneNumber(ctx, phone) + require.NoError(t, err) + require.NotNil(t, storedOTP) + const knownPlainOTP = "123456" + storedOTP.Otp = crypto.HashOTP(knownPlainOTP, cfg.JWTSecret) + storedOTP.ExpiresAt = time.Now().Add(5 * time.Minute).Unix() + _, err = ts.StorageProvider.UpsertOTP(ctx, storedOTP) + require.NoError(t, err) + + // The MFA session cookie is only set on the login response; copy it onto + // the next request by hand (http.Request cookies aren't auto-updated from + // responses in this in-process setup). + mfaCookie := latestMfaSessionCookie(ts) + require.NotEmpty(t, mfaCookie, "the SMS-OTP challenge must arm an mfa session cookie") + req.Header.Set("Cookie", fmt.Sprintf("%s=%s", constants.MfaCookieName+"_session", mfaCookie)) + + verifyRes, err := ts.GraphQLProvider.VerifyOTP(ctx, &model.VerifyOTPRequest{ + PhoneNumber: &phone, + Otp: knownPlainOTP, + }) + require.NoError(t, err) + require.NotNil(t, verifyRes) + assert.NotNil(t, verifyRes.AccessToken, "verifying the SMS OTP must complete login") + assert.NotEmpty(t, *verifyRes.AccessToken) +} diff --git a/internal/integration_tests/magic_link_login_test.go b/internal/integration_tests/magic_link_login_test.go index 970fe2b6..087e4ff1 100644 --- a/internal/integration_tests/magic_link_login_test.go +++ b/internal/integration_tests/magic_link_login_test.go @@ -2,12 +2,15 @@ package integration_tests import ( "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/storage/schemas" ) // TestMagicLinkLogin tests the magic link login functionality of the Authorizer application. @@ -70,3 +73,77 @@ func TestMagicLinkLogin(t *testing.T) { ts.GinContext.Request.Header.Set("Authorization", "") }) } + +// TestMagicLinkLoginMFAGate is a regression guard for VerifyEmail's MFA +// check: it used to be an ad-hoc TOTP-only condition +// (refs.BoolValue(user.IsMultiFactorAuthEnabled) && isMFAEnabled && +// isTOTPLoginEnabled) that silently skipped WebAuthn, email/SMS-OTP-as-MFA, +// EnforceMFA, and — most severely — MFALockedAt entirely, letting a locked +// or non-TOTP-MFA account complete a magic-link login with zero challenge. +// VerifyEmail now calls the same resolveMFAGate every other entry point +// (login.go, signup.go, oauth_mfa_gate.go, webauthn.go) uses. +func TestMagicLinkLoginMFAGate(t *testing.T) { + cfg := getTestConfig() + cfg.EnableMagicLinkLogin = true + cfg.SMTPHost = "localhost" + cfg.SMTPPort = 1025 + cfg.SMTPSenderEmail = "test@authorizer.dev" + cfg.SMTPSenderName = "Test" + cfg.SMTPLocalName = "Test" + cfg.SMTPSkipTLSVerification = true + cfg.IsEmailServiceEnabled = true + cfg.EnableEmailVerification = true + cfg.EnableMFA = true + ts := initTestSetup(t, cfg) + _, ctx := createContext(ts) + + startMagicLinkLogin := func(t *testing.T, email string) *schemas.VerificationRequest { + t.Helper() + res, err := ts.GraphQLProvider.MagicLinkLogin(ctx, &model.MagicLinkLoginRequest{ + Email: email, + }) + require.NoError(t, err) + require.NotNil(t, res) + verificationRequest, err := ts.StorageProvider.GetVerificationRequestByEmail(ctx, email, constants.VerificationTypeMagicLinkLogin) + require.NoError(t, err) + require.NotNil(t, verificationRequest) + return verificationRequest + } + + t.Run("locked account is blocked, not logged in", func(t *testing.T) { + email := "magic_link_locked_" + uuid.New().String() + "@authorizer.dev" + verificationRequest := startMagicLinkLogin(t, email) + + // MagicLinkLogin creates the user record on the fly before the + // verification email is sent - lock it before the click-through. + user, err := ts.StorageProvider.GetUserByEmail(ctx, email) + require.NoError(t, err) + now := time.Now().Unix() + user.MFALockedAt = &now + _, err = ts.StorageProvider.UpdateUser(ctx, user) + require.NoError(t, err) + + verifyRes, err := ts.GraphQLProvider.VerifyEmail(ctx, &model.VerifyEmailRequest{ + Token: verificationRequest.Token, + }) + assert.Error(t, err) + assert.Nil(t, verifyRes) + assert.Contains(t, err.Error(), "locked") + }) + + t.Run("MFA available and not yet configured withholds the token", func(t *testing.T) { + email := "magic_link_offer_" + uuid.New().String() + "@authorizer.dev" + verificationRequest := startMagicLinkLogin(t, email) + + verifyRes, err := ts.GraphQLProvider.VerifyEmail(ctx, &model.VerifyEmailRequest{ + Token: verificationRequest.Token, + }) + require.NoError(t, err) + require.NotNil(t, verifyRes) + // The old ad-hoc check unconditionally issued a full session here + // whenever EnableTOTPLogin was off (or the user had a + // non-TOTP-only MFA config) - the gate must withhold instead. + assert.Nil(t, verifyRes.AccessToken) + assert.Equal(t, "Proceed to mfa setup", verifyRes.Message) + }) +} diff --git a/internal/integration_tests/meta_test.go b/internal/integration_tests/meta_test.go index 8fd49809..ea3234f0 100644 --- a/internal/integration_tests/meta_test.go +++ b/internal/integration_tests/meta_test.go @@ -68,18 +68,18 @@ func TestMeta(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. + // Default config has MFA disabled, so all OTP/TOTP/WebAuthn 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) + assert.False(t, meta.IsWebauthnEnabled) }) - t.Run("should enable TOTP MFA when MFA and TOTP login are on", func(t *testing.T) { + t.Run("should enable TOTP and WebAuthn MFA when MFA, TOTP login, and WebAuthn MFA are on", func(t *testing.T) { cfg2 := getTestConfig() cfg2.EnableMFA = true cfg2.EnableTOTPLogin = true + cfg2.EnableWebauthnMFA = true ts2 := initTestSetup(t, cfg2) _, ctx2 := createContext(ts2) @@ -93,6 +93,22 @@ func TestMeta(t *testing.T) { assert.True(t, meta.IsWebauthnEnabled) }) + t.Run("should disable WebAuthn MFA when --disable-webauthn-mfa is set, even with MFA on", func(t *testing.T) { + cfg2 := getTestConfig() + cfg2.EnableMFA = true + cfg2.EnableTOTPLogin = true + cfg2.EnableWebauthnMFA = false + ts2 := initTestSetup(t, cfg2) + _, ctx2 := createContext(ts2) + + meta, err := ts2.GraphQLProvider.Meta(ctx2) + require.NoError(t, err) + require.NotNil(t, meta) + assert.False(t, meta.IsWebauthnEnabled) + // Other MFA methods are unaffected by disabling WebAuthn specifically. + assert.True(t, meta.IsTotpMfaEnabled) + }) + t.Run("should gate email/SMS OTP MFA on service availability", func(t *testing.T) { cfg2 := getTestConfig() cfg2.EnableMFA = true diff --git a/internal/integration_tests/mfa_gate_login_test.go b/internal/integration_tests/mfa_gate_login_test.go index 146c6d44..e25e0246 100644 --- a/internal/integration_tests/mfa_gate_login_test.go +++ b/internal/integration_tests/mfa_gate_login_test.go @@ -17,20 +17,20 @@ import ( // 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. +// mfaGateBlockVerify, mfaGateBlockEnroll, and mfaGateOfferAll must NEVER +// reach the code path that sets AccessToken on the login response, while +// mfaGateNone and mfaGateSkippedSetup must 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. +// that removes a `return` from the mfaGateBlockVerify/mfaGateBlockEnroll/ +// mfaGateOfferAll 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" @@ -50,10 +50,25 @@ func TestLoginMFAGateTokenWithholding(t *testing.T) { } // addVerifiedAuthenticator gives the user a completed TOTP authenticator, - // the condition login.go reads as authenticatorVerified=true. + // the condition login.go reads as authenticatorVerified=true. Upserts + // rather than blindly inserting: SignUp itself now runs the same MFA + // gate as Login (Task 7), so signUpUser (below, with cfg.EnableMFA=true) + // already leaves an unverified TOTP row behind via its own + // generateTOTPEnrollment call. StorageProvider.AddAuthenticator no-ops + // when a row already exists for (userID, method), so calling it here + // unconditionally would silently fail to mark that pre-existing row + // verified. addVerifiedAuthenticator := func(t *testing.T, ts *testSetup, ctx context.Context, userID string) { t.Helper() now := time.Now().Unix() + existing, _ := ts.StorageProvider.GetAuthenticatorDetailsByUserId(ctx, userID, constants.EnvKeyTOTPAuthenticator) + if existing != nil { + existing.Secret = "dummy-secret-for-gate-test" + existing.VerifiedAt = &now + _, err := ts.StorageProvider.UpdateAuthenticator(ctx, existing) + require.NoError(t, err) + return + } _, err := ts.StorageProvider.AddAuthenticator(ctx, &schemas.Authenticator{ UserID: userID, Method: constants.EnvKeyTOTPAuthenticator, @@ -163,7 +178,7 @@ func TestLoginMFAGateTokenWithholding(t *testing.T) { 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) { + t.Run("mfaGateOfferAll withholds the token and offers every available method", func(t *testing.T) { cfg := getTestConfig() cfg.EnableMFA = true cfg.EnableTOTPLogin = true @@ -179,10 +194,9 @@ func TestLoginMFAGateTokenWithholding(t *testing.T) { 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) + assert.Nil(t, res.AccessToken, "a first-time optional-MFA offer must withhold the token until setup or skip") + assert.True(t, refs.BoolValue(res.ShouldShowTotpScreen)) + assert.NotNil(t, res.AuthenticatorSecret, "offer-all must hand back a fresh TOTP enrollment payload") }) t.Run("mfaGateSkippedSetup issues a token quietly", func(t *testing.T) { @@ -215,7 +229,13 @@ func TestLoginMFAGateTokenWithholding(t *testing.T) { _, ctx := createContext(ts) user := signUpUser(t, ts, ctx) - // IsMultiFactorAuthEnabled left false/unset: the gate is a no-op. + // Signup now defaults IsMultiFactorAuthEnabled to true whenever MFA + // is available server-wide (see signup.go), so this test's actual + // target state - a user for whom MFA is individually off - must be + // set explicitly rather than relying on signup to leave it unset. + user.IsMultiFactorAuthEnabled = refs.NewBoolRef(false) + 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) @@ -225,4 +245,59 @@ func TestLoginMFAGateTokenWithholding(t *testing.T) { assert.False(t, refs.BoolValue(res.ShouldShowTotpScreen)) assert.False(t, refs.BoolValue(res.ShouldOfferMfaSetup)) }) + + // Regression guard for finding I1 (final whole-branch review): this + // block used to be guarded by `isMFAEnabled && isTOTPLoginEnabled`. A + // server configured for WebAuthn-only enforced MFA (EnableTOTPLogin + // off, EnableWebauthnMFA on) skipped resolveMFAGate entirely and + // issued a token to an unenrolled password-login user unconditionally + // -- no offer, no enforcement -- even though WebauthnLoginVerify was + // already correctly gated on such a server. The gate must now run + // whenever MFA applies at all, and only the TOTP-specific parts of + // the response should be conditioned on EnableTOTPLogin. + t.Run("mfaGateBlockEnroll offers WebAuthn setup on a WebAuthn-only enforced-MFA server", func(t *testing.T) { + cfg := getTestConfig() + cfg.EnableMFA = true + cfg.EnableTOTPLogin = false + cfg.EnableWebauthnMFA = 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 TOTP/WebAuthn 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, "must not issue a token to an unenrolled user on a WebAuthn-only enforced-MFA server") + assert.True(t, refs.BoolValue(res.ShouldOfferWebauthnMfaSetup)) + assert.False(t, refs.BoolValue(res.ShouldShowTotpScreen), "TOTP login is disabled server-wide; must not offer a screen the user can't complete") + assert.Nil(t, res.AuthenticatorSecret, "must not generate a TOTP enrollment when TOTP login is disabled") + }) + + t.Run("mfaGateOfferAll offers WebAuthn setup on a WebAuthn-only server when MFA isn't enforced", func(t *testing.T) { + cfg := getTestConfig() + cfg.EnableMFA = true + cfg.EnableTOTPLogin = false + cfg.EnableWebauthnMFA = 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) + + 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 first-time optional-MFA offer must withhold the token even when TOTP login is unavailable") + assert.True(t, refs.BoolValue(res.ShouldOfferWebauthnMfaSetup)) + assert.False(t, refs.BoolValue(res.ShouldShowTotpScreen)) + assert.Nil(t, res.AuthenticatorSecret) + }) } diff --git a/internal/integration_tests/mfa_gate_missing_otp_factors_test.go b/internal/integration_tests/mfa_gate_missing_otp_factors_test.go new file mode 100644 index 00000000..4e782bd1 --- /dev/null +++ b/internal/integration_tests/mfa_gate_missing_otp_factors_test.go @@ -0,0 +1,112 @@ +package integration_tests + +import ( + "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" +) + +// TestVerifyEmailChallengesEmailOTPFactor is the regression guard for a bug +// found in a final whole-branch review, not by the earlier task-level audit +// of this file: service.VerifyEmail's authenticatorVerified was +// `totpVerified || hasWebauthnCredential`, completely omitting Email-OTP and +// SMS-OTP. A user whose only enrolled factor was Email-OTP or SMS-OTP, with +// HasSkippedMFASetupAt set (reachable: skip while unenrolled, then later +// enroll Email/SMS-OTP via settings without ever re-verifying TOTP/WebAuthn), +// resolved to mfaGateSkippedSetup and got a full token via magic-link login +// or signup email verification with zero MFA challenge - despite the exact +// same account being correctly challenged on a password login. +func TestVerifyEmailChallengesEmailOTPFactor(t *testing.T) { + cfg := getTestConfig() + cfg.EnableMagicLinkLogin = true + cfg.SMTPHost = "localhost" + cfg.SMTPPort = 1025 + cfg.SMTPSenderEmail = "test@authorizer.dev" + cfg.SMTPSenderName = "Test" + cfg.SMTPLocalName = "Test" + cfg.SMTPSkipTLSVerification = true + cfg.IsEmailServiceEnabled = true + cfg.EnableEmailVerification = true + cfg.EnableMFA = true + cfg.EnableEmailOTP = true + ts := initTestSetup(t, cfg) + _, ctx := createContext(ts) + + email := "verify_email_otp_factor_" + uuid.New().String() + "@authorizer.dev" + res, err := ts.GraphQLProvider.MagicLinkLogin(ctx, &model.MagicLinkLoginRequest{Email: email}) + require.NoError(t, err) + require.NotNil(t, res) + + user, err := ts.StorageProvider.GetUserByEmail(ctx, email) + require.NoError(t, err) + + // The user previously skipped MFA setup, then later enrolled Email-OTP + // via settings — both fields set, exactly the reachable combination that + // triggered mfaGateSkippedSetup under the pre-fix authenticatorVerified. + now := time.Now().Unix() + user.HasSkippedMFASetupAt = &now + user, err = ts.StorageProvider.UpdateUser(ctx, user) + require.NoError(t, err) + _, err = ts.StorageProvider.AddAuthenticator(ctx, &schemas.Authenticator{ + UserID: user.ID, + Method: constants.EnvKeyEmailOTPAuthenticator, + VerifiedAt: &now, + }) + require.NoError(t, err) + + verificationRequest, err := ts.StorageProvider.GetVerificationRequestByEmail(ctx, email, constants.VerificationTypeMagicLinkLogin) + require.NoError(t, err) + require.NotNil(t, verificationRequest) + + verifyRes, err := ts.GraphQLProvider.VerifyEmail(ctx, &model.VerifyEmailRequest{Token: verificationRequest.Token}) + require.NoError(t, err) + require.NotNil(t, verifyRes) + assert.Nil(t, verifyRes.AccessToken, "must not issue a token before the enrolled Email-OTP factor is verified") + assert.True(t, refs.BoolValue(verifyRes.ShouldShowEmailOtpScreen), "must challenge the account's enrolled Email-OTP factor") +} + +// TestWebauthnLoginVerifySatisfiesMFAOverEmailOTPFactor locks in the decided +// policy for WebauthnLoginVerify (the inverse of the VerifyEmail case above, +// which is a different, unchanged code path): a successful passkey assertion +// satisfies MFA outright, so even the exact skip-setup + Email-OTP-enrolled +// combination that this endpoint used to challenge now issues a token with no +// OTP prompt. VerifyEmail (magic-link / signup) still challenges it — only the +// passkey path treats the assertion itself as the satisfied factor. +func TestWebauthnLoginVerifySatisfiesMFAOverEmailOTPFactor(t *testing.T) { + cfg := getTestConfig() + cfg.EnableWebauthnMFA = true + cfg.EnableEmailOTP = true + cfg.IsEmailServiceEnabled = true + ts := initTestSetup(t, cfg) + + // Per-user opt-in, not the global cfg.EnableMFA flag - signup itself must + // stay unaffected so registerPasskeyForNewUser's SignUp call still issues + // a token to register the passkey with. + user, rp, authenticator, credential := registerPasskeyForNewUser(t, ts) + + now := time.Now().Unix() + user.IsMultiFactorAuthEnabled = refs.NewBoolRef(true) + user.HasSkippedMFASetupAt = &now + user, err := ts.StorageProvider.UpdateUser(t.Context(), user) + require.NoError(t, err) + _, err = ts.StorageProvider.AddAuthenticator(t.Context(), &schemas.Authenticator{ + UserID: user.ID, + Method: constants.EnvKeyEmailOTPAuthenticator, + VerifiedAt: &now, + }) + require.NoError(t, err) + + res, err := assertPasskeyLogin(t, ts, rp, authenticator, credential) + require.NoError(t, err) + require.NotNil(t, res) + require.NotNil(t, res.AccessToken, "a passkey assertion satisfies MFA on its own — no Email-OTP challenge") + assert.False(t, refs.BoolValue(res.ShouldShowEmailOtpScreen), "must not challenge Email-OTP after a successful passkey verify") +} diff --git a/internal/integration_tests/mfa_service_availability_test.go b/internal/integration_tests/mfa_service_availability_test.go index 436ec343..17c38ce2 100644 --- a/internal/integration_tests/mfa_service_availability_test.go +++ b/internal/integration_tests/mfa_service_availability_test.go @@ -67,14 +67,21 @@ func TestMFAServiceAvailability(t *testing.T) { require.True(t, meta.IsMultiFactorAuthServiceEnabled) email := "mfa_on_" + uuid.New().String() + "@authorizer.dev" - su, err := ts.GraphQLProvider.SignUp(ctx, &model.SignUpRequest{ + _, err = ts.GraphQLProvider.SignUp(ctx, &model.SignUpRequest{ Email: &email, Password: "Password@123", ConfirmPassword: "Password@123", }) require.NoError(t, err) + // cfg.EnableMFA is true here, so SignUp itself now runs the same MFA + // gate as Login (Task 7): its response withholds the token and the + // User field (matching login.go's own mfaGateOfferAll/BlockEnroll + // responses, which never set User either). Look the user up by email + // instead of relying on a User field that isn't there for this path. + signedUpUser, err := ts.StorageProvider.GetUserByEmail(ctx, email) + require.NoError(t, err) setAdminCookie(t, ts) res, err := ts.GraphQLProvider.UpdateUser(ctx, &model.UpdateUserRequest{ - ID: su.User.ID, IsMultiFactorAuthEnabled: refs.NewBoolRef(true), + ID: signedUpUser.ID, IsMultiFactorAuthEnabled: refs.NewBoolRef(true), }) require.NoError(t, err) require.True(t, refs.BoolValue(res.IsMultiFactorAuthEnabled)) diff --git a/internal/integration_tests/mfa_session_only_test.go b/internal/integration_tests/mfa_session_only_test.go new file mode 100644 index 00000000..719d968a --- /dev/null +++ b/internal/integration_tests/mfa_session_only_test.go @@ -0,0 +1,249 @@ +package integration_tests + +import ( + "errors" + "fmt" + "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/crypto" + "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" +) + +// TestMFASessionOnlyContinuation covers the OAuth-return MFA continuation path: +// the caller has a valid MFA session cookie but supplies NO email/phone_number +// (the identifier never travels in the OAuth redirect). Each continuation +// endpoint must then resolve the account from the session alone via +// MemoryStoreProvider.GetMfaSessionOwner and behave exactly as the +// identifier-supplied path would — while still rejecting a bare Challenge +// session, preserving the existing account-lockout-DoS guarantee. +func TestMFASessionOnlyContinuation(t *testing.T) { + t.Run("SkipMFASetup resolves the account from the session cookie alone", func(t *testing.T) { + cfg := getTestConfig() + cfg.EnableMFA = true + cfg.EnableTOTPLogin = true + ts := initTestSetup(t, cfg) + req, ctx := createContext(ts) + + email := "session_only_skip_" + 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) + + // Mirror what EvaluateMFAGateForOAuth leaves behind: a Verified session + // keyed by the user, and the cookie on the request — but NO identifier + // in the request params. + mfaSession := uuid.NewString() + require.NoError(t, ts.MemoryStoreProvider.SetMfaSession(user.ID, mfaSession, constants.MFASessionPurposeVerified, time.Now().Add(5*time.Minute).Unix())) + req.Header.Set("Cookie", fmt.Sprintf("%s=%s", constants.MfaCookieName+"_session", mfaSession)) + + skipRes, err := ts.GraphQLProvider.SkipMFASetup(ctx, &model.SkipMfaSetupRequest{}) + require.NoError(t, err) + require.NotNil(t, skipRes) + require.NotNil(t, skipRes.AccessToken, "session-only skip must issue the withheld token, same as the identifier path") + assert.NotEmpty(t, *skipRes.AccessToken) + + updated, err := ts.StorageProvider.GetUserByID(ctx, user.ID) + require.NoError(t, err) + assert.NotNil(t, updated.HasSkippedMFASetupAt, "skip must persist HasSkippedMFASetupAt on the SAME user resolved from the session") + }) + + t.Run("VerifyOTP resolves the account from the session cookie alone", func(t *testing.T) { + cfg := getTestConfig() + cfg.EnableMFA = true + ts := initTestSetup(t, cfg) + req, ctx := createContext(ts) + + email := "session_only_verify_" + 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) + + // Seed an email OTP keyed by the user's email — the session-only path + // derives the email from the resolved account, so this is what the + // verify branch will look up. + const plainOTP = "246810" + _, err = ts.StorageProvider.UpsertOTP(ctx, &schemas.OTP{ + Email: email, + Otp: crypto.HashOTP(plainOTP, cfg.JWTSecret), + ExpiresAt: time.Now().Add(5 * time.Minute).Unix(), + }) + require.NoError(t, err) + + mfaSession := uuid.NewString() + require.NoError(t, ts.MemoryStoreProvider.SetMfaSession(user.ID, mfaSession, constants.MFASessionPurposeVerified, time.Now().Add(5*time.Minute).Unix())) + req.Header.Set("Cookie", fmt.Sprintf("%s=%s", constants.MfaCookieName+"_session", mfaSession)) + + verifyRes, err := ts.GraphQLProvider.VerifyOTP(ctx, &model.VerifyOTPRequest{Otp: plainOTP}) + require.NoError(t, err) + require.NotNil(t, verifyRes) + require.NotNil(t, verifyRes.AccessToken, "session-only verify must issue a token") + assert.NotEmpty(t, *verifyRes.AccessToken) + if verifyRes.User != nil { + assert.Equal(t, email, refs.StringValue(verifyRes.User.Email), "must resolve and authenticate the SAME user the session belongs to") + } + }) + + t.Run("SkipMFASetup rejects a Challenge session with no identifier (Unauthenticated)", func(t *testing.T) { + cfg := getTestConfig() + cfg.EnableMFA = true + cfg.EnableTOTPLogin = true + ts := initTestSetup(t, cfg) + req, ctx := createContext(ts) + + email := "session_only_skip_chal_" + 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) + + // A Challenge session must not gain skip powers through the session-only + // fallback either — same guarantee as the identifier path. + mfaSession := uuid.NewString() + require.NoError(t, ts.MemoryStoreProvider.SetMfaSession(user.ID, mfaSession, constants.MFASessionPurposeChallenge, time.Now().Add(5*time.Minute).Unix())) + req.Header.Set("Cookie", fmt.Sprintf("%s=%s", constants.MfaCookieName+"_session", mfaSession)) + + skipRes, err := ts.GraphQLProvider.SkipMFASetup(ctx, &model.SkipMfaSetupRequest{}) + 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) + + unchanged, err := ts.StorageProvider.GetUserByID(ctx, user.ID) + require.NoError(t, err) + assert.Nil(t, unchanged.HasSkippedMFASetupAt, "a rejected Challenge session must not have recorded a skip") + }) + + t.Run("LockMFA rejects a Challenge session with no identifier (Unauthenticated)", func(t *testing.T) { + cfg := getTestConfig() + cfg.EnableMFA = true + cfg.EnableTOTPLogin = true + ts := initTestSetup(t, cfg) + req, ctx := createContext(ts) + + email := "session_only_lock_chal_" + 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) + + mfaSession := uuid.NewString() + require.NoError(t, ts.MemoryStoreProvider.SetMfaSession(user.ID, mfaSession, constants.MFASessionPurposeChallenge, time.Now().Add(5*time.Minute).Unix())) + req.Header.Set("Cookie", fmt.Sprintf("%s=%s", constants.MfaCookieName+"_session", mfaSession)) + + lockRes, err := ts.GraphQLProvider.LockMFA(ctx, &model.LockMfaRequest{}) + require.Error(t, err) + assert.Nil(t, lockRes) + 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) + + unchanged, err := ts.StorageProvider.GetUserByID(ctx, user.ID) + require.NoError(t, err) + assert.Nil(t, unchanged.MFALockedAt, "a rejected Challenge session must not have locked the account") + }) + + t.Run("ResendOTP with a Verified session and no identifier resends for the right user", func(t *testing.T) { + cfg := getTestConfig() + cfg.EnableMFA = true + cfg.EnableEmailOTP = true + cfg.IsEmailServiceEnabled = true + cfg.EnableEmailVerification = true + cfg.SMTPHost = "localhost" + cfg.SMTPPort = 1025 + cfg.SMTPSenderEmail = "test@authorizer.dev" + cfg.SMTPSenderName = "Test" + cfg.SMTPLocalName = "Test" + cfg.SMTPSkipTLSVerification = true + ts := initTestSetup(t, cfg) + req, ctx := createContext(ts) + + email := "session_only_resend_" + 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) + + // ResendOTP requires an existing OTP row to resend (unchanged existing + // behavior); seed one keyed by the resolved account's email. + _, err = ts.StorageProvider.UpsertOTP(ctx, &schemas.OTP{ + Email: email, + Otp: crypto.HashOTP("111111", cfg.JWTSecret), + ExpiresAt: time.Now().Add(5 * time.Minute).Unix(), + }) + require.NoError(t, err) + + mfaSession := uuid.NewString() + require.NoError(t, ts.MemoryStoreProvider.SetMfaSession(user.ID, mfaSession, constants.MFASessionPurposeVerified, time.Now().Add(5*time.Minute).Unix())) + req.Header.Set("Cookie", fmt.Sprintf("%s=%s", constants.MfaCookieName+"_session", mfaSession)) + + resendRes, err := ts.GraphQLProvider.ResendOTP(ctx, &model.ResendOTPRequest{}) + require.NoError(t, err) + require.NotNil(t, resendRes) + assert.Equal(t, "OTP has been sent. Please check your inbox", resendRes.Message) + }) + + t.Run("ResendOTP with a Challenge session and no identifier is rejected as InvalidArgument", func(t *testing.T) { + cfg := getTestConfig() + cfg.EnableMFA = true + cfg.EnableEmailOTP = true + cfg.IsEmailServiceEnabled = true + ts := initTestSetup(t, cfg) + req, ctx := createContext(ts) + + email := "session_only_resend_chal_" + 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) + + // Only a Verified session unlocks the session-only resend. A Challenge + // session with no identifier is treated exactly as if no identifier was + // supplied — InvalidArgument, so it cannot spawn further resends. + mfaSession := uuid.NewString() + require.NoError(t, ts.MemoryStoreProvider.SetMfaSession(user.ID, mfaSession, constants.MFASessionPurposeChallenge, time.Now().Add(5*time.Minute).Unix())) + req.Header.Set("Cookie", fmt.Sprintf("%s=%s", constants.MfaCookieName+"_session", mfaSession)) + + resendRes, err := ts.GraphQLProvider.ResendOTP(ctx, &model.ResendOTPRequest{}) + require.Error(t, err) + assert.Nil(t, resendRes) + var svcErr *service.Error + require.True(t, errors.As(err, &svcErr), "expected a *service.Error, got %T: %v", err, err) + assert.Equal(t, service.KindInvalidArgument, svcErr.Kind) + }) +} diff --git a/internal/integration_tests/oauth_mfa_gate_test.go b/internal/integration_tests/oauth_mfa_gate_test.go new file mode 100644 index 00000000..4ca85674 --- /dev/null +++ b/internal/integration_tests/oauth_mfa_gate_test.go @@ -0,0 +1,229 @@ +package integration_tests + +import ( + "context" + "errors" + "net/url" + "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/refs" + "github.com/authorizerdev/authorizer/internal/service" + "github.com/authorizerdev/authorizer/internal/storage/schemas" +) + +// TestEvaluateMFAGateForOAuth covers oauth_callback.go's entry point into the +// same gate Login/SignUp/WebauthnLoginVerify use, exercised directly at the +// service layer (no OAuth provider round-trip needed — the function only +// takes an already-resolved *schemas.User). See mfa_gate_test.go for +// resolveMFAGate's own decision table in isolation. +// +// NOTE: this does not exercise OAuthCallbackHandler itself (the HTTP +// handler) — there is no existing test double for a real OAuth +// provider round-trip (Google/GitHub/etc.) in this codebase, and building +// one is out of scope for this change. Full end-to-end coverage of the +// redirect built in oauth_callback.go requires manual/browser testing. +func TestEvaluateMFAGateForOAuth(t *testing.T) { + newTestUser := func(t *testing.T, ts *testSetup, ctx context.Context, mutate func(*schemas.User)) *schemas.User { + t.Helper() + now := time.Now().Unix() + user := &schemas.User{ + Email: refs.NewStringRef("oauth_mfa_gate_" + uuid.NewString() + "@authorizer.dev"), + EmailVerifiedAt: &now, + SignupMethods: constants.AuthRecipeMethodGoogle, + } + if mutate != nil { + mutate(user) + } + created, err := ts.StorageProvider.AddUser(ctx, user) + require.NoError(t, err) + return created + } + + t.Run("mfaGateNone does not withhold", func(t *testing.T) { + cfg := getTestConfig() + cfg.EnableMFA = true + ts := initTestSetup(t, cfg) + _, ctx := createContext(ts) + + user := newTestUser(t, ts, ctx, func(u *schemas.User) { + u.IsMultiFactorAuthEnabled = refs.NewBoolRef(false) + }) + + side := &service.ResponseSideEffects{} + meta := service.RequestMetadata{HostURL: testAuthorizerHost(ts)} + withheld, redirectSuffix, err := ts.ServiceProvider.EvaluateMFAGateForOAuth(ctx, meta, side, user) + require.NoError(t, err) + assert.False(t, withheld) + assert.Empty(t, redirectSuffix) + assert.Empty(t, side.Cookies, "no mfa session cookie should be set when the gate does not withhold") + }) + + t.Run("mfaGateSkippedSetup does not withhold", func(t *testing.T) { + cfg := getTestConfig() + cfg.EnableMFA = true + ts := initTestSetup(t, cfg) + _, ctx := createContext(ts) + + skippedAt := time.Now().Unix() + user := newTestUser(t, ts, ctx, func(u *schemas.User) { + u.IsMultiFactorAuthEnabled = refs.NewBoolRef(true) + u.HasSkippedMFASetupAt = &skippedAt + }) + + side := &service.ResponseSideEffects{} + meta := service.RequestMetadata{HostURL: testAuthorizerHost(ts)} + withheld, redirectSuffix, err := ts.ServiceProvider.EvaluateMFAGateForOAuth(ctx, meta, side, user) + require.NoError(t, err) + assert.False(t, withheld) + assert.Empty(t, redirectSuffix) + }) + + t.Run("mfaGateOfferAll withholds with mfa_required=1", func(t *testing.T) { + cfg := getTestConfig() + cfg.EnableMFA = true + ts := initTestSetup(t, cfg) + _, ctx := createContext(ts) + + user := newTestUser(t, ts, ctx, func(u *schemas.User) { + u.IsMultiFactorAuthEnabled = refs.NewBoolRef(true) + }) + + side := &service.ResponseSideEffects{} + meta := service.RequestMetadata{HostURL: testAuthorizerHost(ts)} + withheld, redirectSuffix, err := ts.ServiceProvider.EvaluateMFAGateForOAuth(ctx, meta, side, user) + require.NoError(t, err) + assert.True(t, withheld) + assert.Contains(t, redirectSuffix, "mfa_required=1") + assert.Contains(t, redirectSuffix, "mfa_gate=offer", "a first-time offer must route the frontend to the setup screen, not the verify screen") + assert.NotEmpty(t, side.Cookies, "an mfa session cookie must be set on a withheld outcome") + }) + + t.Run("mfaGateOfferAll omits totp when TOTP login is disabled", func(t *testing.T) { + // Regression guard for the oauth gate appending totp unconditionally: + // every other method is gated on its own config flag, but totp used to + // be listed even on a server where TOTP login is off (DisableTOTPLogin). + // Enable WebAuthn so the offer branch still produces a non-empty + // mfa_methods, then assert it lists only webauthn — never totp. + cfg := getTestConfig() + cfg.EnableMFA = true + cfg.EnableTOTPLogin = false + cfg.EnableWebauthnMFA = true + ts := initTestSetup(t, cfg) + _, ctx := createContext(ts) + + user := newTestUser(t, ts, ctx, func(u *schemas.User) { + u.IsMultiFactorAuthEnabled = refs.NewBoolRef(true) + }) + + side := &service.ResponseSideEffects{} + meta := service.RequestMetadata{HostURL: testAuthorizerHost(ts)} + withheld, redirectSuffix, err := ts.ServiceProvider.EvaluateMFAGateForOAuth(ctx, meta, side, user) + require.NoError(t, err) + assert.True(t, withheld) + + values, parseErr := url.ParseQuery(redirectSuffix) + require.NoError(t, parseErr) + methods := values.Get("mfa_methods") + assert.NotContains(t, methods, constants.EnvKeyTOTPAuthenticator, "totp must not be offered when TOTP login is disabled") + assert.Contains(t, methods, constants.AuthRecipeMethodWebauthn, "the offer branch must still list configured methods") + }) + + t.Run("mfaGateBlockEnroll withholds with mfa_required=1", func(t *testing.T) { + cfg := getTestConfig() + cfg.EnableMFA = true + cfg.EnforceMFA = true + ts := initTestSetup(t, cfg) + _, ctx := createContext(ts) + + user := newTestUser(t, ts, ctx, func(u *schemas.User) { + u.IsMultiFactorAuthEnabled = refs.NewBoolRef(true) + }) + + side := &service.ResponseSideEffects{} + meta := service.RequestMetadata{HostURL: testAuthorizerHost(ts)} + withheld, redirectSuffix, err := ts.ServiceProvider.EvaluateMFAGateForOAuth(ctx, meta, side, user) + require.NoError(t, err) + assert.True(t, withheld) + assert.Contains(t, redirectSuffix, "mfa_required=1") + assert.Contains(t, redirectSuffix, "mfa_gate=offer", "enforced-but-unenrolled is still an enrollment offer, not a challenge") + }) + + t.Run("mfaGateBlockVerify withholds with mfa_required=1", func(t *testing.T) { + cfg := getTestConfig() + cfg.EnableMFA = true + // Enable every other MFA method the offer/enroll branches would list + // (webauthn, email OTP, SMS OTP — IsSMSServiceEnabled is already true + // in getTestConfig) so that if the gate mis-routed this verified-TOTP + // user to mfaGateOfferAll/BlockEnroll instead of mfaGateBlockVerify, + // mfa_methods would pick up those extra, un-enrolled methods too — + // making "totp" alone an insufficient signal. asserting the exact + // mfa_methods value below is what actually distinguishes the verify + // branch (lists only the user's already-verified factors) from the + // offer/enroll branches (lists every configured method). + cfg.EnableWebauthnMFA = true + cfg.EnableEmailOTP = true + cfg.IsEmailServiceEnabled = true + cfg.EnableSMSOTP = true + ts := initTestSetup(t, cfg) + _, ctx := createContext(ts) + + user := newTestUser(t, ts, ctx, func(u *schemas.User) { + u.IsMultiFactorAuthEnabled = refs.NewBoolRef(true) + }) + now := time.Now().Unix() + _, err := ts.StorageProvider.AddAuthenticator(ctx, &schemas.Authenticator{ + UserID: user.ID, + Method: constants.EnvKeyTOTPAuthenticator, + Secret: "dummy-secret-for-oauth-gate-test", + VerifiedAt: &now, + }) + require.NoError(t, err) + + side := &service.ResponseSideEffects{} + meta := service.RequestMetadata{HostURL: testAuthorizerHost(ts)} + withheld, redirectSuffix, err := ts.ServiceProvider.EvaluateMFAGateForOAuth(ctx, meta, side, user) + require.NoError(t, err) + assert.True(t, withheld) + assert.Contains(t, redirectSuffix, "mfa_required=1") + assert.Contains(t, redirectSuffix, "mfa_gate=verify", "an already-verified factor must route the frontend to the challenge screen, not the setup screen") + + values, parseErr := url.ParseQuery(redirectSuffix) + require.NoError(t, parseErr) + // Exactly "totp" -- not "totp,webauthn,email_otp,sms_otp", which is + // what an offer/enroll branch would produce given the config above. + // This is what distinguishes mfaGateBlockVerify (only the factors + // this user actually has verified) from mfaGateOfferAll/BlockEnroll + // (every method the server has configured). + assert.Equal(t, constants.EnvKeyTOTPAuthenticator, values.Get("mfa_methods")) + }) + + t.Run("locked user is rejected", func(t *testing.T) { + cfg := getTestConfig() + cfg.EnableMFA = true + ts := initTestSetup(t, cfg) + _, ctx := createContext(ts) + + lockedAt := time.Now().Unix() + user := newTestUser(t, ts, ctx, func(u *schemas.User) { + u.IsMultiFactorAuthEnabled = refs.NewBoolRef(true) + u.MFALockedAt = &lockedAt + }) + + side := &service.ResponseSideEffects{} + meta := service.RequestMetadata{HostURL: testAuthorizerHost(ts)} + withheld, redirectSuffix, err := ts.ServiceProvider.EvaluateMFAGateForOAuth(ctx, meta, side, user) + require.Error(t, err) + assert.False(t, withheld) + assert.Empty(t, redirectSuffix) + + var svcErr *service.Error + require.True(t, errors.As(err, &svcErr)) + assert.Equal(t, service.KindFailedPrecondition, svcErr.Kind) + }) +} diff --git a/internal/integration_tests/oidc_userinfo_scope_filtering_test.go b/internal/integration_tests/oidc_userinfo_scope_filtering_test.go index f8f9d811..e078d28c 100644 --- a/internal/integration_tests/oidc_userinfo_scope_filtering_test.go +++ b/internal/integration_tests/oidc_userinfo_scope_filtering_test.go @@ -126,16 +126,15 @@ func TestUserInfoScopeFiltering(t *testing.T) { require.Equal(t, http.StatusOK, code) assert.NotEmpty(t, body["sub"]) - // Profile claims may legitimately be empty/nil values on a freshly - // signed-up user, but the KEYS must be present in the response. - _, hasGiven := body["given_name"] - _, hasFamily := body["family_name"] - _, hasNickname := body["nickname"] - _, hasPreferred := body["preferred_username"] - assert.True(t, hasGiven, "profile scope → given_name key present") - assert.True(t, hasFamily, "profile scope → family_name key present") - assert.True(t, hasNickname, "profile scope → nickname key present") - assert.True(t, hasPreferred, "profile scope → preferred_username key present") + // OIDC Core §5.3.2/§5.1: a claim with no value must be OMITTED, not + // returned as null — this user signed up with no name, so these + // stay unset and must be omitted. + assert.Nil(t, body["given_name"], "unset profile claim must be omitted, not null") + assert.Nil(t, body["family_name"], "unset profile claim must be omitted, not null") + assert.Nil(t, body["nickname"], "unset profile claim must be omitted, not null") + // preferred_username defaults to the signup email, so it IS set — + // the key must be present (with the profile scope granted). + assert.Equal(t, email, body["preferred_username"]) assert.Nil(t, body["email"], "email scope not requested → email omitted") }) @@ -146,8 +145,6 @@ func TestUserInfoScopeFiltering(t *testing.T) { assert.NotEmpty(t, body["sub"]) assert.Equal(t, email, body["email"]) - _, hasGiven := body["given_name"] - assert.True(t, hasGiven) }) t.Run("sub_is_always_present_for_unknown_scope_combo", func(t *testing.T) { diff --git a/internal/integration_tests/org_scoped_admin_test.go b/internal/integration_tests/org_scoped_admin_test.go index c88bd41c..75c1ddeb 100644 --- a/internal/integration_tests/org_scoped_admin_test.go +++ b/internal/integration_tests/org_scoped_admin_test.go @@ -248,6 +248,22 @@ func TestOrgScopedAdmin(t *testing.T) { require.NotEmpty(t, conn) }) + t.Run("org-admin can fetch its own org, not another org (dashboard OrganizationDetail)", func(t *testing.T) { + orgID := createOrg("viewer") + otherOrgID := createOrg("other") + adminID, adminTok := signupUser() + addMember(orgID, adminID, []string{constants.OrgRoleAdmin}) + + asUser(adminTok) + + got, err := ts.GraphQLProvider.Organization(ctx, &model.OrganizationRequest{ID: orgID}) + require.NoError(t, err, "an org-admin must be able to fetch their own org's detail page") + require.Equal(t, orgID, got.ID) + + _, err = ts.GraphQLProvider.Organization(ctx, &model.OrganizationRequest{ID: otherOrgID}) + require.Error(t, err, "an org-admin must not be able to fetch a different org") + }) + t.Run("org-admin cannot create or delete the organization itself", func(t *testing.T) { orgID := createOrg("tenant") adminID, adminTok := signupUser() diff --git a/internal/integration_tests/otp_mfa_setup_test.go b/internal/integration_tests/otp_mfa_setup_test.go new file mode 100644 index 00000000..c3918c9f --- /dev/null +++ b/internal/integration_tests/otp_mfa_setup_test.go @@ -0,0 +1,457 @@ +package integration_tests + +import ( + "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/crypto" + "github.com/authorizerdev/authorizer/internal/graph/model" + "github.com/authorizerdev/authorizer/internal/refs" + "github.com/authorizerdev/authorizer/internal/storage/schemas" +) + +// TestTOTPMFAEnrollment covers the full enroll-then-use cycle for +// settings-mode TOTP MFA setup (totp_mfa_setup): previously TOTP enrollment +// could only ever be generated inline as a side effect of the login/signup +// gate response - a bearer-token-authenticated user had no way to add TOTP +// from account settings the way EmailOTPMFASetup/SMSOTPMFASetup already +// allow. Mirrors TestEmailOTPMFAEnrollment's flow. +func TestTOTPMFAEnrollment(t *testing.T) { + const password = "Password@123" + + cfg := getTestConfig() + cfg.EnableMFA = true + cfg.EnableTOTPLogin = true + ts := initTestSetup(t, cfg) + req, ctx := createContext(ts) + + email := "totp_mfa_" + uuid.NewString() + "@authorizer.dev" + _, err := ts.GraphQLProvider.SignUp(ctx, &model.SignUpRequest{ + Email: &email, Password: password, ConfirmPassword: password, + IsMultiFactorAuthEnabled: refs.NewBoolRef(true), + }) + require.NoError(t, err) + user, err := ts.StorageProvider.GetUserByEmail(ctx, email) + require.NoError(t, err) + + // First login: nothing enrolled yet -> withheld, offered. + loginRes, err := ts.GraphQLProvider.Login(ctx, &model.LoginRequest{Email: &email, Password: password}) + require.NoError(t, err) + require.Nil(t, loginRes.AccessToken, "no method enrolled yet -> withheld, offer-all") + + // Get a bearer token the realistic way: skip the offer now (settings- + // screen enrollment is a separate, later, already-logged-in action). + mfaSession := latestMfaSessionCookie(ts) + require.NotEmpty(t, mfaSession, "login must have set an mfa session cookie on the response") + req.Header.Set("Cookie", fmt.Sprintf("%s=%s", constants.MfaCookieName+"_session", mfaSession)) + skipRes, err := ts.GraphQLProvider.SkipMFASetup(ctx, &model.SkipMfaSetupRequest{Email: &email}) + require.NoError(t, err) + require.NotNil(t, skipRes.AccessToken) + req.Header.Set("Authorization", "Bearer "+*skipRes.AccessToken) + + // Now, as an already-logged-in user with NO leftover MFA session (skip + // already consumed/deleted the one from login), enroll TOTP. This is + // the realistic settings-screen scenario, and the regression case: + // resolveOTPSetupCaller must re-arm a fresh MFA session itself here, or + // the verify_otp call below fails with "invalid session" even though + // setup itself reported success. + req.Header.Set("Cookie", "") + setupRes, err := ts.GraphQLProvider.TOTPMFASetup(ctx, nil) + require.NoError(t, err) + require.NotNil(t, setupRes) + require.NotEmpty(t, refs.StringValue(setupRes.AuthenticatorSecret)) + require.NotEmpty(t, refs.StringValue(setupRes.AuthenticatorScannerImage)) + require.NotEmpty(t, setupRes.AuthenticatorRecoveryCodes) + + authenticator, err := ts.StorageProvider.GetAuthenticatorDetailsByUserId(ctx, user.ID, constants.EnvKeyTOTPAuthenticator) + require.NoError(t, err) + require.NotNil(t, authenticator) + assert.Nil(t, authenticator.VerifiedAt, "setup alone must not mark the enrollment verified") + + code, err := totp.GenerateCode(refs.StringValue(setupRes.AuthenticatorSecret), time.Now()) + require.NoError(t, err) + + // verify_otp is identified by the mfa session cookie TOTPMFASetup itself + // must have just re-armed - not a synthetic one, proving the fix rather + // than bypassing the exact gap it closes. + verifySession := latestMfaSessionCookie(ts) + require.NotEmpty(t, verifySession, "totp_mfa_setup must re-arm an MFA session for the caller") + req.Header.Set("Cookie", fmt.Sprintf("%s=%s", constants.MfaCookieName+"_session", verifySession)) + verifyRes, err := ts.GraphQLProvider.VerifyOTP(ctx, &model.VerifyOTPRequest{Email: &email, Otp: code, IsTotp: refs.NewBoolRef(true)}) + require.NoError(t, err) + require.NotNil(t, verifyRes) + + authenticator, err = ts.StorageProvider.GetAuthenticatorDetailsByUserId(ctx, user.ID, constants.EnvKeyTOTPAuthenticator) + require.NoError(t, err) + require.NotNil(t, authenticator) + require.NotNil(t, authenticator.VerifiedAt, "verify_otp must mark the pending enrollment verified") + + // A subsequent login now routes through the TOTP challenge branch (not + // mfaGateOfferAll) since the enrollment is verified - authenticatorVerified + // wins over the earlier skip per resolveMFAGate's decision table. + req.Header.Set("Authorization", "") + secondLogin, err := ts.GraphQLProvider.Login(ctx, &model.LoginRequest{Email: &email, Password: password}) + require.NoError(t, err) + require.Nil(t, secondLogin.AccessToken, "an enrolled-and-verified TOTP factor must challenge, not skip, login") + assert.True(t, refs.BoolValue(secondLogin.ShouldShowTotpScreen), "must route through the TOTP challenge branch, not the offer-all screen") +} + +// TestEmailOTPMFAEnrollment covers the full enroll-then-use cycle for +// email-OTP-as-MFA: +// - a first-time, unenrolled login is withheld and offers email OTP setup +// (mfaGateOfferAll's ShouldOfferEmailOtpMfaSetup, wired in login.go). +// - EmailOTPMFASetup (bearer-token authenticated) creates an unverified +// Authenticator row and does not by itself gate login. +// - verify_otp marks that row verified. +// - only after verification does a subsequent login route through the +// retrofitted email-OTP-as-MFA branch instead of the offer-all screen. +// +// Sequencing note: EmailOTPMFASetup requires a bearer token, but the first +// login's token is withheld (mfaGateOfferAll). This test obtains a token via +// skip_mfa_setup first -- the realistic "skip now, add a second factor later +// from account settings" flow -- rather than calling EmailOTPMFASetup with no +// token, which cannot succeed. +func TestEmailOTPMFAEnrollment(t *testing.T) { + const password = "Password@123" + + cfg := getTestConfig() + cfg.EnableMFA = true + cfg.EnableTOTPLogin = true + cfg.EnableEmailOTP = true + cfg.SMTPHost = "localhost" + cfg.SMTPPort = 1025 + cfg.SMTPSenderEmail = "test@authorizer.dev" + cfg.SMTPSenderName = "Test" + cfg.SMTPLocalName = "Test" + cfg.SMTPSkipTLSVerification = true + cfg.IsEmailServiceEnabled = true + ts := initTestSetup(t, cfg) + require.True(t, ts.Config.IsEmailServiceEnabled, "test SMTP fixture must derive IsEmailServiceEnabled=true") + req, ctx := createContext(ts) + + email := "email_otp_mfa_" + uuid.NewString() + "@authorizer.dev" + _, err := ts.GraphQLProvider.SignUp(ctx, &model.SignUpRequest{ + Email: &email, Password: password, ConfirmPassword: password, + IsMultiFactorAuthEnabled: refs.NewBoolRef(true), + }) + require.NoError(t, err) + user, err := ts.StorageProvider.GetUserByEmail(ctx, email) + require.NoError(t, err) + + // First login: nothing enrolled yet -> withheld, offered. + loginRes, err := ts.GraphQLProvider.Login(ctx, &model.LoginRequest{Email: &email, Password: password}) + require.NoError(t, err) + require.Nil(t, loginRes.AccessToken, "no method enrolled yet -> withheld, offer-all") + assert.True(t, refs.BoolValue(loginRes.ShouldOfferEmailOtpMfaSetup)) + + // Get a bearer token the realistic way: skip the offer now (settings- + // screen enrollment is a separate, later, already-logged-in action). + mfaSession := latestMfaSessionCookie(ts) + require.NotEmpty(t, mfaSession, "login must have set an mfa session cookie on the response") + req.Header.Set("Cookie", fmt.Sprintf("%s=%s", constants.MfaCookieName+"_session", mfaSession)) + skipRes, err := ts.GraphQLProvider.SkipMFASetup(ctx, &model.SkipMfaSetupRequest{Email: &email}) + require.NoError(t, err) + require.NotNil(t, skipRes.AccessToken) + req.Header.Set("Authorization", "Bearer "+*skipRes.AccessToken) + + // Now, as an already-logged-in user with NO leftover MFA session (skip + // already consumed/deleted the one from login), enroll a second factor. + // This is the realistic settings-screen scenario, and the regression + // case: resolveOTPSetupCaller must re-arm a fresh MFA session itself + // here, or the verify_otp call below fails with "invalid session" even + // though setup itself reported success. + req.Header.Set("Cookie", "") + setupRes, err := ts.GraphQLProvider.EmailOTPMFASetup(ctx, nil) + require.NoError(t, err) + require.NotNil(t, setupRes) + + authenticator, err := ts.StorageProvider.GetAuthenticatorDetailsByUserId(ctx, user.ID, constants.EnvKeyEmailOTPAuthenticator) + require.NoError(t, err) + require.NotNil(t, authenticator) + assert.Nil(t, authenticator.VerifiedAt, "setup alone must not mark the enrollment verified") + + // Verify the code EmailOTPMFASetup sent. The test can't intercept the + // outgoing email, so overwrite the stored digest with one for a known + // plaintext, mirroring TestVerifyOTP's pattern. + const knownPlainOTP = "654321" + storedOTP, err := ts.StorageProvider.GetOTPByEmail(ctx, email) + require.NoError(t, err) + require.NotNil(t, storedOTP) + storedOTP.Otp = crypto.HashOTP(knownPlainOTP, cfg.JWTSecret) + storedOTP.ExpiresAt = time.Now().Add(5 * time.Minute).Unix() + _, err = ts.StorageProvider.UpsertOTP(ctx, storedOTP) + require.NoError(t, err) + + // verify_otp is identified by the mfa session cookie EmailOTPMFASetup + // itself must have just re-armed - not a synthetic one, proving the fix + // rather than bypassing the exact gap it closes. + verifySession := latestMfaSessionCookie(ts) + require.NotEmpty(t, verifySession, "email_otp_mfa_setup must re-arm an MFA session for the caller") + req.Header.Set("Cookie", fmt.Sprintf("%s=%s", constants.MfaCookieName+"_session", verifySession)) + verifyRes, err := ts.GraphQLProvider.VerifyOTP(ctx, &model.VerifyOTPRequest{Email: &email, Otp: knownPlainOTP}) + require.NoError(t, err) + require.NotNil(t, verifyRes) + + authenticator, err = ts.StorageProvider.GetAuthenticatorDetailsByUserId(ctx, user.ID, constants.EnvKeyEmailOTPAuthenticator) + require.NoError(t, err) + require.NotNil(t, authenticator) + require.NotNil(t, authenticator.VerifiedAt, "verify_otp must mark the pending enrollment verified") + + // A subsequent login now routes through the retrofitted email-OTP-as-MFA + // challenge branch (not mfaGateOfferAll) since the enrollment is verified. + req.Header.Set("Authorization", "") + secondLogin, err := ts.GraphQLProvider.Login(ctx, &model.LoginRequest{Email: &email, Password: password}) + require.NoError(t, err) + require.Nil(t, secondLogin.AccessToken, "an enrolled-and-verified email OTP factor must challenge, not skip, login") + assert.True(t, refs.BoolValue(secondLogin.ShouldShowEmailOtpScreen), "must route through the OTP challenge branch, not the offer-all screen") +} + +// TestEmailOTPMFASetupViaMfaSessionCookie proves the actual gap this fix +// closes: a user offered "set up Email OTP" from the token-withheld +// mfaGateOfferAll screen has NO bearer token, only the MFA session cookie — +// so email_otp_mfa_setup must be reachable in that cookie-only mode, not +// just from an already-logged-in settings screen. Full flow: withheld +// first login -> email_otp_mfa_setup via cookie+email (no Authorization +// header at all) -> verify_otp -> the previously-withheld token is issued. +func TestEmailOTPMFASetupViaMfaSessionCookie(t *testing.T) { + const password = "Password@123" + + cfg := getTestConfig() + cfg.EnableMFA = true + cfg.EnableTOTPLogin = true + cfg.EnableEmailOTP = true + cfg.SMTPHost = "localhost" + cfg.SMTPPort = 1025 + cfg.SMTPSenderEmail = "test@authorizer.dev" + cfg.SMTPSenderName = "Test" + cfg.SMTPLocalName = "Test" + cfg.SMTPSkipTLSVerification = true + cfg.IsEmailServiceEnabled = true + ts := initTestSetup(t, cfg) + req, ctx := createContext(ts) + + email := "cookie_email_otp_mfa_" + uuid.NewString() + "@authorizer.dev" + _, err := ts.GraphQLProvider.SignUp(ctx, &model.SignUpRequest{ + Email: &email, Password: password, ConfirmPassword: password, + IsMultiFactorAuthEnabled: refs.NewBoolRef(true), + }) + require.NoError(t, err) + user, err := ts.StorageProvider.GetUserByEmail(ctx, email) + require.NoError(t, err) + + // First login: nothing enrolled yet -> withheld, offered, no bearer + // token issued. + loginRes, err := ts.GraphQLProvider.Login(ctx, &model.LoginRequest{Email: &email, Password: password}) + require.NoError(t, err) + require.Nil(t, loginRes.AccessToken, "no method enrolled yet -> withheld, offer-all") + assert.True(t, refs.BoolValue(loginRes.ShouldOfferEmailOtpMfaSetup)) + + mfaSession := latestMfaSessionCookie(ts) + require.NotEmpty(t, mfaSession, "login must have set an mfa session cookie on the response") + req.Header.Set("Cookie", fmt.Sprintf("%s=%s", constants.MfaCookieName+"_session", mfaSession)) + // Deliberately no Authorization header — this is the whole point: the + // caller has no bearer token yet, only the cookie + email. + req.Header.Set("Authorization", "") + + setupRes, err := ts.GraphQLProvider.EmailOTPMFASetup(ctx, &model.OtpMfaSetupRequest{Email: &email}) + require.NoError(t, err, "email_otp_mfa_setup must be reachable via cookie+email with no bearer token") + require.NotNil(t, setupRes) + + authenticator, err := ts.StorageProvider.GetAuthenticatorDetailsByUserId(ctx, user.ID, constants.EnvKeyEmailOTPAuthenticator) + require.NoError(t, err) + require.NotNil(t, authenticator) + assert.Nil(t, authenticator.VerifiedAt, "setup alone must not mark the enrollment verified") + + const knownPlainOTP = "482913" + storedOTP, err := ts.StorageProvider.GetOTPByEmail(ctx, email) + require.NoError(t, err) + require.NotNil(t, storedOTP) + storedOTP.Otp = crypto.HashOTP(knownPlainOTP, cfg.JWTSecret) + storedOTP.ExpiresAt = time.Now().Add(5 * time.Minute).Unix() + _, err = ts.StorageProvider.UpsertOTP(ctx, storedOTP) + require.NoError(t, err) + + // Same MFA session cookie is still valid -- verify_otp completes the + // still-in-progress, still-withheld login. + verifyRes, err := ts.GraphQLProvider.VerifyOTP(ctx, &model.VerifyOTPRequest{Email: &email, Otp: knownPlainOTP}) + require.NoError(t, err) + require.NotNil(t, verifyRes) + require.NotNil(t, verifyRes.AccessToken, "verify_otp must issue the token that was withheld at login") + + authenticator, err = ts.StorageProvider.GetAuthenticatorDetailsByUserId(ctx, user.ID, constants.EnvKeyEmailOTPAuthenticator) + require.NoError(t, err) + require.NotNil(t, authenticator) + require.NotNil(t, authenticator.VerifiedAt, "verify_otp must mark the pending enrollment verified") +} + +// TestSMSOTPMFASetupViaMfaSessionCookie is TestEmailOTPMFASetupViaMfaSessionCookie's +// SMS twin -- confirms sms_otp_mfa_setup is reachable the same cookie-only +// way, keyed by phone_number instead of email, and that the full chain +// (withheld login -> cookie-authenticated setup -> verify_otp -> the +// withheld token being issued) closes the same as the email-OTP twin. +func TestSMSOTPMFASetupViaMfaSessionCookie(t *testing.T) { + const password = "Password@123" + + cfg := getTestConfig() + cfg.EnableMFA = true + cfg.EnableSMSOTP = true + cfg.IsSMSServiceEnabled = true + cfg.EnableMobileBasicAuthentication = true + cfg.TwilioAPISecret = "test-twilio-api-secret" + cfg.TwilioAPIKey = "test-twilio-api-key" + cfg.TwilioAccountSID = "test-twilio-account-sid" + cfg.TwilioSender = "test-twilio-sender" + ts := initTestSetup(t, cfg) + req, ctx := createContext(ts) + + mobile := fmt.Sprintf("+1%010d", time.Now().UnixNano()%10000000000) + _, err := ts.GraphQLProvider.SignUp(ctx, &model.SignUpRequest{ + PhoneNumber: &mobile, Password: password, ConfirmPassword: password, + IsMultiFactorAuthEnabled: refs.NewBoolRef(true), + }) + require.NoError(t, err) + user, err := ts.StorageProvider.GetUserByPhoneNumber(ctx, mobile) + require.NoError(t, err) + // Signup's own verification OTP is irrelevant to this test; mark the + // phone verified directly so login reaches the MFA gate instead of the + // phone-verification challenge. + now := time.Now().Unix() + user.PhoneNumberVerifiedAt = &now + _, err = ts.StorageProvider.UpdateUser(ctx, user) + require.NoError(t, err) + + loginRes, err := ts.GraphQLProvider.Login(ctx, &model.LoginRequest{PhoneNumber: &mobile, Password: password}) + require.NoError(t, err) + require.Nil(t, loginRes.AccessToken, "no method enrolled yet -> withheld, offer-all") + assert.True(t, refs.BoolValue(loginRes.ShouldOfferSmsOtpMfaSetup)) + + mfaSession := latestMfaSessionCookie(ts) + require.NotEmpty(t, mfaSession) + req.Header.Set("Cookie", fmt.Sprintf("%s=%s", constants.MfaCookieName+"_session", mfaSession)) + req.Header.Set("Authorization", "") + + setupRes, err := ts.GraphQLProvider.SMSOTPMFASetup(ctx, &model.OtpMfaSetupRequest{PhoneNumber: &mobile}) + require.NoError(t, err, "sms_otp_mfa_setup must be reachable via cookie+phone_number with no bearer token") + require.NotNil(t, setupRes) + + authenticator, err := ts.StorageProvider.GetAuthenticatorDetailsByUserId(ctx, user.ID, constants.EnvKeySMSOTPAuthenticator) + require.NoError(t, err) + require.NotNil(t, authenticator) + assert.Nil(t, authenticator.VerifiedAt, "setup alone must not mark the enrollment verified") + + // Complete the chain: the test can't intercept the outgoing SMS, so + // overwrite the stored digest with one for a known plaintext, same as + // the email-OTP twin. + const knownPlainOTP = "739104" + storedOTP, err := ts.StorageProvider.GetOTPByPhoneNumber(ctx, mobile) + require.NoError(t, err) + require.NotNil(t, storedOTP) + storedOTP.Otp = crypto.HashOTP(knownPlainOTP, cfg.JWTSecret) + storedOTP.ExpiresAt = time.Now().Add(5 * time.Minute).Unix() + _, err = ts.StorageProvider.UpsertOTP(ctx, storedOTP) + require.NoError(t, err) + + // Same MFA session cookie is still valid -- verify_otp completes the + // still-in-progress, still-withheld login. + verifyRes, err := ts.GraphQLProvider.VerifyOTP(ctx, &model.VerifyOTPRequest{PhoneNumber: &mobile, Otp: knownPlainOTP}) + require.NoError(t, err) + require.NotNil(t, verifyRes) + require.NotNil(t, verifyRes.AccessToken, "verify_otp must issue the token that was withheld at login") + + authenticator, err = ts.StorageProvider.GetAuthenticatorDetailsByUserId(ctx, user.ID, constants.EnvKeySMSOTPAuthenticator) + require.NoError(t, err) + require.NotNil(t, authenticator) + require.NotNil(t, authenticator.VerifiedAt, "verify_otp must mark the pending enrollment verified") +} + +// TestOTPMFASetupRejectsUnauthenticatedCaller confirms the new dual-mode +// resolution fails closed: a caller with neither a valid bearer token/session +// NOR a valid MFA session cookie + email/phone_number must be rejected, for +// both email_otp_mfa_setup and sms_otp_mfa_setup. +func TestOTPMFASetupRejectsUnauthenticatedCaller(t *testing.T) { + cfg := getTestConfig() + cfg.EnableEmailOTP = true + cfg.IsEmailServiceEnabled = true + cfg.EnableSMSOTP = true + cfg.IsSMSServiceEnabled = true + ts := initTestSetup(t, cfg) + _, ctx := createContext(ts) + + // No Authorization header, no MFA session cookie, no params at all. + _, err := ts.GraphQLProvider.EmailOTPMFASetup(ctx, nil) + assert.Error(t, err, "no token and no cookie/identity must be rejected") + + _, err = ts.GraphQLProvider.SMSOTPMFASetup(ctx, nil) + assert.Error(t, err, "no token and no cookie/identity must be rejected") + + _, err = ts.GraphQLProvider.TOTPMFASetup(ctx, nil) + assert.Error(t, err, "no token and no cookie/identity must be rejected") + + // email/phone_number supplied but no MFA session cookie set at all -- + // still must be rejected (params alone never authenticate). + someEmail := "no_cookie_" + uuid.NewString() + "@authorizer.dev" + _, err = ts.GraphQLProvider.EmailOTPMFASetup(ctx, &model.OtpMfaSetupRequest{Email: &someEmail}) + assert.Error(t, err, "email/phone_number without a valid mfa session cookie must be rejected") +} + +// TestVerifyOTPDoesNotAutoEnroll ensures verify_otp's new VerifiedAt-marking +// logic only fires when a pending (unverified) Authenticator row already +// exists -- i.e. only for a code sent by EmailOTPMFASetup/SMSOTPMFASetup. A +// routine login-time OTP (e.g. the pre-MFA email-verification challenge) +// that never went through setup must remain a no-op for enrollment purposes: +// it must not silently create/verify an Authenticator row. +func TestVerifyOTPDoesNotAutoEnroll(t *testing.T) { + cfg := getTestConfig() + cfg.EnableEmailOTP = true + cfg.SMTPHost = "localhost" + cfg.SMTPPort = 1025 + cfg.SMTPSenderEmail = "test@authorizer.dev" + cfg.SMTPSenderName = "Test" + cfg.SMTPLocalName = "Test" + cfg.SMTPSkipTLSVerification = true + cfg.IsEmailServiceEnabled = true + ts := initTestSetup(t, cfg) + req, ctx := createContext(ts) + + // A basic-auth user with an unverified email and no pending verification + // request: login.go's (non-MFA) "email not verified -> send OTP" branch + // fires for this, entirely independent of any MFA enrollment. + email := "plain_otp_" + uuid.NewString() + "@authorizer.dev" + user, err := ts.StorageProvider.AddUser(ctx, &schemas.User{ + Email: refs.NewStringRef(email), + SignupMethods: constants.AuthRecipeMethodBasicAuth, + }) + require.NoError(t, err) + + loginRes, err := ts.GraphQLProvider.Login(ctx, &model.LoginRequest{Email: &email, Password: "irrelevant"}) + require.NoError(t, err) + require.Nil(t, loginRes.AccessToken) + require.True(t, refs.BoolValue(loginRes.ShouldShowEmailOtpScreen)) + + const knownPlainOTP = "111222" + storedOTP, err := ts.StorageProvider.GetOTPByEmail(ctx, email) + require.NoError(t, err) + require.NotNil(t, storedOTP) + storedOTP.Otp = crypto.HashOTP(knownPlainOTP, cfg.JWTSecret) + storedOTP.ExpiresAt = time.Now().Add(5 * time.Minute).Unix() + _, err = ts.StorageProvider.UpsertOTP(ctx, storedOTP) + require.NoError(t, err) + + mfaSession := latestMfaSessionCookie(ts) + require.NotEmpty(t, mfaSession) + req.Header.Set("Cookie", fmt.Sprintf("%s=%s", constants.MfaCookieName+"_session", mfaSession)) + verifyRes, err := ts.GraphQLProvider.VerifyOTP(ctx, &model.VerifyOTPRequest{Email: &email, Otp: knownPlainOTP}) + require.NoError(t, err) + require.NotNil(t, verifyRes) + + authenticator, err := ts.StorageProvider.GetAuthenticatorDetailsByUserId(ctx, user.ID, constants.EnvKeyEmailOTPAuthenticator) + assert.Error(t, err, "no Authenticator row should exist for a plain login-time OTP that never went through setup") + assert.Nil(t, authenticator) +} diff --git a/internal/integration_tests/refresh_token_client_binding_test.go b/internal/integration_tests/refresh_token_client_binding_test.go new file mode 100644 index 00000000..fb9672fb --- /dev/null +++ b/internal/integration_tests/refresh_token_client_binding_test.go @@ -0,0 +1,159 @@ +package integration_tests + +import ( + "crypto/sha256" + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/crypto/bcrypt" + + "github.com/authorizerdev/authorizer/internal/constants" + "github.com/authorizerdev/authorizer/internal/graph/model" + "github.com/authorizerdev/authorizer/internal/storage/schemas" + "github.com/authorizerdev/authorizer/internal/token" +) + +// loginForOfflineAccess is runReservedLogin's counterpart with an +// offline_access scope, so the resulting code exchange mints a refresh +// token — needed to exercise refresh-token client binding, which +// runReservedLogin's fixed "openid profile email" scope never triggers. +func loginForOfflineAccess(t *testing.T, ts *testSetup, clientID string) (http.Handler, string, string) { + t.Helper() + _, ctx := createContext(ts) + + email := "refresh_bind_" + uuid.New().String() + "@authorizer.dev" + password := "Password@123" + _, 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) + + nonce := uuid.New().String() + scope := []string{"openid", "profile", "email", "offline_access"} + sessionData, sessionToken, sessionExpiresAt, err := ts.TokenProvider.CreateSessionToken(&token.AuthTokenConfig{ + User: user, + Nonce: nonce, + Roles: ts.Config.DefaultRoles, + Scope: scope, + LoginMethod: constants.AuthRecipeMethodBasicAuth, + }) + require.NoError(t, err) + + sessionKey := constants.AuthRecipeMethodBasicAuth + ":" + user.ID + require.NoError(t, ts.MemoryStoreProvider.SetUserSession( + sessionKey, constants.TokenTypeSessionToken+"_"+sessionData.Nonce, sessionToken, sessionExpiresAt)) + + codeVerifier := uuid.New().String() + uuid.New().String() + sum := sha256.Sum256([]byte(codeVerifier)) + codeChallenge := base64.RawURLEncoding.EncodeToString(sum[:]) + + router := gin.New() + router.GET("/authorize", ts.HttpProvider.AuthorizeHandler()) + router.POST("/oauth/token", ts.HttpProvider.TokenHandler()) + + qs := url.Values{} + qs.Set("client_id", clientID) + qs.Set("response_type", "code") + qs.Set("redirect_uri", "http://localhost:3000/callback") + qs.Set("code_challenge", codeChallenge) + qs.Set("code_challenge_method", "S256") + qs.Set("state", uuid.New().String()) + qs.Set("response_mode", "query") + qs.Set("scope", strings.Join(scope, " ")) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/authorize?"+qs.Encode(), nil) + req.AddCookie(&http.Cookie{Name: constants.AppCookieName + "_session", Value: sessionToken}) + router.ServeHTTP(w, req) + + require.Equal(t, http.StatusFound, w.Code, "authorize should redirect: %s", w.Body.String()) + loc, err := url.Parse(w.Header().Get("Location")) + require.NoError(t, err) + code := loc.Query().Get("code") + require.NotEmpty(t, code, "authorization code must be present") + + return router, code, codeVerifier +} + +func registerTestClient(t *testing.T, ts *testSetup, clientID, secret string) { + t.Helper() + hash, err := bcrypt.GenerateFromPassword([]byte(secret), 12) + require.NoError(t, err) + _, err = ts.StorageProvider.AddClient(t.Context(), &schemas.Client{ + ClientID: clientID, + Kind: constants.ClientKindInteractive, + Name: "Test Client " + clientID, + ClientSecret: string(hash), + TokenEndpointAuthMethod: constants.TokenEndpointAuthMethodClientSecretBasic, + IsActive: true, + }) + require.NoError(t, err) +} + +// RFC 6749 §6: "the authorization server MUST verify the binding between the +// refresh token and client identity whenever possible." A refresh token +// minted for client1 must not be redeemable by client2 presenting its own +// valid credentials — regression test for the oidcc-refresh-token +// conformance failure (LOG-0234: ValidateErrorFromTokenEndpointResponseError +// — the server returned no error when a refresh token issued to one client +// was redeemed by a different client). +func TestRefreshToken_CrossClientRedemption_Rejected(t *testing.T) { + cfg := getTestConfig() + ts := initTestSetup(t, cfg) + + registerTestClient(t, ts, "client-one", "client-one-secret") + registerTestClient(t, ts, "client-two", "client-two-secret") + + router, code, codeVerifier := loginForOfflineAccess(t, ts, "client-one") + + exchangeForm := url.Values{} + exchangeForm.Set("grant_type", "authorization_code") + exchangeForm.Set("code", code) + exchangeForm.Set("code_verifier", codeVerifier) + exchangeForm.Set("redirect_uri", "http://localhost:3000/callback") + + w := exchangeCode(router, exchangeForm, []string{"client-one", "client-one-secret"}) + require.Equal(t, http.StatusOK, w.Code, "code exchange body: %s", w.Body.String()) + var body map[string]interface{} + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) + refreshToken, _ := body["refresh_token"].(string) + require.NotEmpty(t, refreshToken, "offline_access scope must yield a refresh_token") + + t.Run("a different client redeeming the refresh token is rejected", func(t *testing.T) { + refreshForm := url.Values{} + refreshForm.Set("grant_type", "refresh_token") + refreshForm.Set("refresh_token", refreshToken) + + w := exchangeCode(router, refreshForm, []string{"client-two", "client-two-secret"}) + assert.Equal(t, http.StatusBadRequest, w.Code, "body: %s", w.Body.String()) + var errBody map[string]interface{} + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &errBody)) + assert.Equal(t, "invalid_grant", errBody["error"]) + }) + + t.Run("the issuing client can still redeem its own refresh token", func(t *testing.T) { + refreshForm := url.Values{} + refreshForm.Set("grant_type", "refresh_token") + refreshForm.Set("refresh_token", refreshToken) + + w := exchangeCode(router, refreshForm, []string{"client-one", "client-one-secret"}) + assert.Equal(t, http.StatusOK, w.Code, "body: %s", w.Body.String()) + var okBody map[string]interface{} + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &okBody)) + assert.NotEmpty(t, okBody["access_token"]) + }) +} diff --git a/internal/integration_tests/reset_password_test.go b/internal/integration_tests/reset_password_test.go index 527e2fd7..14045644 100644 --- a/internal/integration_tests/reset_password_test.go +++ b/internal/integration_tests/reset_password_test.go @@ -219,7 +219,8 @@ func TestResetPassword(t *testing.T) { req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("X-Authorizer-URL", issuer) router.ServeHTTP(w, req) - assert.Equal(t, http.StatusUnauthorized, w.Code, + // RFC 6749 §5.2: invalid_grant responses MUST use HTTP 400, not 401. + assert.Equal(t, http.StatusBadRequest, w.Code, "pre-existing refresh token must be rejected after password reset") }) diff --git a/internal/integration_tests/scim_deprovision_test.go b/internal/integration_tests/scim_deprovision_test.go index ffbbc79d..5694eab1 100644 --- a/internal/integration_tests/scim_deprovision_test.go +++ b/internal/integration_tests/scim_deprovision_test.go @@ -97,6 +97,59 @@ func TestDeprovisionedUserRevocation(t *testing.T) { require.NoError(t, ts.MemoryStoreProvider.DeleteAllUserSessions(user.ID)) // After deprovision: refresh is rejected and introspection reports inactive. - assert.Equal(t, http.StatusUnauthorized, refresh(), "refresh must be rejected for a revoked user") + // RFC 6749 §5.2: invalid_grant responses MUST use HTTP 400, not 401. + assert.Equal(t, http.StatusBadRequest, refresh(), "refresh must be rejected for a revoked user") assert.False(t, introspect(*loginRes.AccessToken), "a revoked user's access token must introspect as inactive") } + +// TestDeprovisionedUserRevocation_AccessTokenBlockedEvenIfSessionDeleteMissed +// covers the third checkpoint the SAME way SCIM active:false actually protects +// it in production: request-serving auth (ValidateAccessToken, used by every +// GraphQL/REST resolver and the gRPC interceptor) re-checks RevokedTimestamp +// as defense-in-depth, so a deprovisioned user's held access token is rejected +// even when the session-store delete that normally invalidates it is skipped +// (e.g. a missed/failed instance in a multi-node deployment). Deliberately +// does NOT call DeleteAllUserSessions, unlike TestDeprovisionedUserRevocation +// above — that isolates this specific defense-in-depth path from the +// session-deletion path already covered there. +func TestDeprovisionedUserRevocation_AccessTokenBlockedEvenIfSessionDeleteMissed(t *testing.T) { + cfg := getTestConfig() + ts := initTestSetup(t, cfg) + req, ctx := createContext(ts) + + email := "scim_deprov_reqauth_" + 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) + + callProfile := func() error { + authedReq, rErr := http.NewRequest(http.MethodPost, req.URL.String(), nil) + require.NoError(t, rErr) + authedReq.Header.Set("Authorization", "Bearer "+*signupRes.AccessToken) + ts.GinContext.Request = authedReq + _, pErr := ts.GraphQLProvider.Profile(ctx) + return pErr + } + + // Baseline: the freshly issued access token authenticates a real resolver call. + require.NoError(t, callProfile(), "access token should authenticate before deprovision") + + // Deprovision via the RevokedTimestamp field only — no session-store delete — + // to isolate the DB-level defense-in-depth from the session-deletion path. + user, err := ts.StorageProvider.GetUserByID(ctx, signupRes.User.ID) + require.NoError(t, err) + now := time.Now().Unix() + user.RevokedTimestamp = &now + _, err = ts.StorageProvider.UpdateUser(ctx, user) + require.NoError(t, err) + + // The still-live session-store entry alone must not be enough: the same + // access token must now be rejected purely on the RevokedTimestamp check. + assert.Error(t, callProfile(), "a revoked user's access token must stop authenticating requests immediately") +} diff --git a/internal/integration_tests/scim_org_isolation_test.go b/internal/integration_tests/scim_org_isolation_test.go new file mode 100644 index 00000000..a5d7aede --- /dev/null +++ b/internal/integration_tests/scim_org_isolation_test.go @@ -0,0 +1,175 @@ +package integration_tests + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "github.com/authorizerdev/authorizer/internal/graph/model" + scimhttp "github.com/authorizerdev/authorizer/internal/http_handlers/scim" + "github.com/authorizerdev/authorizer/internal/service/scim" +) + +// bootSCIMServer mounts the real inbound SCIM 2.0 handler (org resolved only +// from the bearer token, design §4.4 H6) over the same storage/memory-store +// providers as the rest of the test setup, mirroring cmd/root.go's production +// wiring. Returns the base URL. +func bootSCIMServer(t *testing.T, ts *testSetup) string { + t.Helper() + scimService := scim.New(&scim.Dependencies{ + Log: ts.Logger, + StorageProvider: ts.StorageProvider, + MemoryStoreProvider: ts.MemoryStoreProvider, + }) + scimHandler := scimhttp.New(&scimhttp.Dependencies{ + Log: ts.Logger, + Service: scimService, + }) + gin.SetMode(gin.TestMode) + r := gin.New() + scimHandler.Register(r.Group("/scim/v2")) + srv := httptest.NewServer(r) + t.Cleanup(srv.Close) + return srv.URL +} + +// scimDo issues a raw SCIM HTTP request with the given bearer token and +// decodes the JSON response body (if any). +func scimDo(t *testing.T, base, method, path, bearer string, body any) (*http.Response, map[string]any) { + t.Helper() + var reader io.Reader + if body != nil { + raw, err := json.Marshal(body) + require.NoError(t, err) + reader = bytes.NewReader(raw) + } + req, err := http.NewRequest(method, base+path, reader) + require.NoError(t, err) + if bearer != "" { + req.Header.Set("Authorization", "Bearer "+bearer) + } + req.Header.Set("Content-Type", "application/scim+json") + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + t.Cleanup(func() { _ = resp.Body.Close() }) + raw, err := io.ReadAll(resp.Body) + require.NoError(t, err) + var decoded map[string]any + if len(raw) > 0 { + require.NoError(t, json.Unmarshal(raw, &decoded), "body: %s", string(raw)) + } + return resp, decoded +} + +// TestSCIMOrgIsolation closes the coverage gap flagged in the SCIM design +// review (§4.4 H6): a SCIM bearer token is scoped to exactly one org +// server-side, and org_scoped_admin_test.go's confused-deputy matrix does not +// exercise the inbound SCIM REST surface. This proves org A's SCIM connection +// can never read, enumerate, modify, or deactivate a user who belongs only to +// org B via GET/PATCH/PUT/DELETE /scim/v2/Users/:id or the userName filter +// probe on /scim/v2/Users. +func TestSCIMOrgIsolation(t *testing.T) { + cfg := getTestConfig() + ts := initTestSetup(t, cfg) + _, ctx := createContext(ts) + base := bootSCIMServer(t, ts) + + setAdminCookie(t, ts) + + createOrg := func(prefix string) string { + org, err := ts.GraphQLProvider.CreateOrganization(ctx, &model.CreateOrganizationRequest{ + Name: prefix + "-" + uuid.NewString(), + }) + require.NoError(t, err) + return org.ID + } + + mintSCIMToken := func(orgID string) string { + resp, err := ts.GraphQLProvider.CreateScimEndpoint(ctx, &model.CreateScimEndpointRequest{OrgID: orgID}) + require.NoError(t, err) + require.NotEmpty(t, resp.Token) + return resp.Token + } + + orgA := createOrg("scim-org-a") + orgB := createOrg("scim-org-b") + tokenA := mintSCIMToken(orgA) + tokenB := mintSCIMToken(orgB) + + // Provision one user into each org via that org's own SCIM connection — + // the realistic path (Okta/Entra provisioning), not a backdoor GraphQL + // signup. + provision := func(bearer, userName string) string { + resp, decoded := scimDo(t, base, http.MethodPost, "/scim/v2/Users", bearer, map[string]any{ + "userName": userName, + "active": true, + }) + require.Equal(t, http.StatusCreated, resp.StatusCode) + id, _ := decoded["id"].(string) + require.NotEmpty(t, id) + return id + } + + userAName := "usera-" + uuid.NewString() + "@authorizer.test" + userBName := "userb-" + uuid.NewString() + "@authorizer.test" + _ = provision(tokenA, userAName) + userBID := provision(tokenB, userBName) + + // Sanity: org B's own token can see its own user. + resp, decoded := scimDo(t, base, http.MethodGet, "/scim/v2/Users/"+userBID, tokenB, nil) + require.Equal(t, http.StatusOK, resp.StatusCode) + require.Equal(t, userBName, decoded["userName"]) + + t.Run("GET org-B user via org-A token -> 404, no data leak", func(t *testing.T) { + resp, decoded := scimDo(t, base, http.MethodGet, "/scim/v2/Users/"+userBID, tokenA, nil) + require.Equal(t, http.StatusNotFound, resp.StatusCode) + require.NotEqual(t, userBName, decoded["userName"]) + }) + + t.Run("PUT org-B user via org-A token -> 404, no mutation", func(t *testing.T) { + resp, _ := scimDo(t, base, http.MethodPut, "/scim/v2/Users/"+userBID, tokenA, map[string]any{ + "userName": userBName, + "active": false, + }) + require.Equal(t, http.StatusNotFound, resp.StatusCode) + }) + + t.Run("PATCH (deactivate) org-B user via org-A token -> 404, no mutation", func(t *testing.T) { + resp, _ := scimDo(t, base, http.MethodPatch, "/scim/v2/Users/"+userBID, tokenA, map[string]any{ + "Operations": []map[string]any{ + {"op": "replace", "path": "active", "value": false}, + }, + }) + require.Equal(t, http.StatusNotFound, resp.StatusCode) + }) + + t.Run("DELETE (deprovision) org-B user via org-A token -> 404, no deactivation", func(t *testing.T) { + resp, _ := scimDo(t, base, http.MethodDelete, "/scim/v2/Users/"+userBID, tokenA, nil) + require.Equal(t, http.StatusNotFound, resp.StatusCode) + }) + + t.Run("userName filter probe via org-A token does not enumerate org-B user", func(t *testing.T) { + filter := url.QueryEscape(`userName eq "` + userBName + `"`) + resp, decoded := scimDo(t, base, http.MethodGet, + "/scim/v2/Users?filter="+filter, tokenA, nil) + require.Equal(t, http.StatusOK, resp.StatusCode) + resources, _ := decoded["Resources"].([]any) + require.Empty(t, resources, "org A must not be able to enumerate org B's user by userName") + require.Equal(t, float64(0), decoded["totalResults"]) + }) + + // Confirm org B's user survived every cross-org attempt untouched: still + // active, still fetchable with org B's own token. + resp, decoded = scimDo(t, base, http.MethodGet, "/scim/v2/Users/"+userBID, tokenB, nil) + require.Equal(t, http.StatusOK, resp.StatusCode) + require.Equal(t, userBName, decoded["userName"]) + require.Equal(t, true, decoded["active"], "org B's user must not have been deactivated by org A's cross-org attempts") +} diff --git a/internal/integration_tests/signup_test.go b/internal/integration_tests/signup_test.go index 525a2b7e..55b07dbd 100644 --- a/internal/integration_tests/signup_test.go +++ b/internal/integration_tests/signup_test.go @@ -7,6 +7,7 @@ import ( "github.com/authorizerdev/authorizer/internal/constants" "github.com/authorizerdev/authorizer/internal/graph/model" + "github.com/authorizerdev/authorizer/internal/refs" "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -187,3 +188,73 @@ func TestSignup(t *testing.T) { }) }) } + +// TestSignUpGatesToken verifies that a brand-new signup, like login, has its +// token withheld and is offered the first-time MFA setup screen when MFA is +// available server-wide, and issued a token immediately when it is not. +func TestSignUpGatesToken(t *testing.T) { + const password = "Password@123" + + t.Run("MFA available, no explicit opt-out -> token withheld, offer all", func(t *testing.T) { + cfg := getTestConfig() + cfg.EnableMFA = true + cfg.EnableTOTPLogin = true + ts := initTestSetup(t, cfg) + _, ctx := createContext(ts) + + email := "signup_gate_offer_" + uuid.New().String() + "@authorizer.dev" + res, err := ts.GraphQLProvider.SignUp(ctx, &model.SignUpRequest{ + Email: &email, Password: password, ConfirmPassword: password, + IsMultiFactorAuthEnabled: refs.NewBoolRef(true), + }) + require.NoError(t, err) + require.NotNil(t, res) + assert.Nil(t, res.AccessToken, "a brand-new signup with MFA available must withhold the token, same as login") + assert.True(t, refs.BoolValue(res.ShouldShowTotpScreen)) + assert.NotNil(t, res.AuthenticatorSecret) + }) + + t.Run("MFA not available -> token issued immediately", func(t *testing.T) { + cfg := getTestConfig() + cfg.EnableMFA = false + ts := initTestSetup(t, cfg) + _, ctx := createContext(ts) + + email := "signup_gate_none_" + uuid.New().String() + "@authorizer.dev" + res, err := ts.GraphQLProvider.SignUp(ctx, &model.SignUpRequest{ + Email: &email, Password: password, ConfirmPassword: password, + }) + require.NoError(t, err) + require.NotNil(t, res) + require.NotNil(t, res.AccessToken) + }) + + // Regression guard for finding I1 (final whole-branch review): this + // block used to be guarded by `p.Config.EnableMFA && p.Config.EnableTOTPLogin`, + // mirroring login.go's old guard. A server configured for WebAuthn-only + // enforced MFA (EnableTOTPLogin off, EnableWebauthnMFA on) skipped the + // gate entirely and issued a token to a brand-new signup unconditionally + // -- no offer, no enforcement. The gate must now run whenever MFA + // applies at all, and only the TOTP-specific parts of the response + // should be conditioned on EnableTOTPLogin. + t.Run("WebAuthn-only enforced MFA, unenrolled -> token withheld via mfaGateBlockEnroll, WebAuthn setup offered", func(t *testing.T) { + cfg := getTestConfig() + cfg.EnableMFA = true + cfg.EnableTOTPLogin = false + cfg.EnableWebauthnMFA = true + cfg.EnforceMFA = true + ts := initTestSetup(t, cfg) + _, ctx := createContext(ts) + + email := "signup_gate_webauthn_only_" + uuid.New().String() + "@authorizer.dev" + res, err := ts.GraphQLProvider.SignUp(ctx, &model.SignUpRequest{ + Email: &email, Password: password, ConfirmPassword: password, + }) + require.NoError(t, err) + require.NotNil(t, res) + assert.Nil(t, res.AccessToken, "must not issue a token to a brand-new signup on a WebAuthn-only enforced-MFA server") + assert.True(t, refs.BoolValue(res.ShouldOfferWebauthnMfaSetup)) + assert.False(t, refs.BoolValue(res.ShouldShowTotpScreen), "TOTP login is disabled server-wide; must not offer a screen the user can't complete") + assert.Nil(t, res.AuthenticatorSecret, "must not generate a TOTP enrollment when TOTP login is disabled") + }) +} diff --git a/internal/integration_tests/skip_mfa_setup_test.go b/internal/integration_tests/skip_mfa_setup_test.go index fbfb18dd..08d69c38 100644 --- a/internal/integration_tests/skip_mfa_setup_test.go +++ b/internal/integration_tests/skip_mfa_setup_test.go @@ -7,7 +7,6 @@ import ( "time" "github.com/google/uuid" - "github.com/pquerna/otp/totp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -19,27 +18,22 @@ import ( ) // 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. +// skip_mfa_setup mutation under the withheld-token model: +// - a valid MFA session + matching email, with MFA optional, records +// HasSkippedMFASetupAt and issues the previously-withheld access token. +// - with EnforceMFA=true it is rejected with FailedPrecondition even with +// a valid MFA session — enforcement is never skippable. +// - with no valid MFA session cookie at all, it is rejected with +// Unauthenticated in both EnforceMFA states. 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) { + t.Run("skips setup, issues the withheld token, and quiets a later login", func(t *testing.T) { cfg := getTestConfig() cfg.EnableMFA = true cfg.EnableTOTPLogin = true ts := initTestSetup(t, cfg) - _, ctx := createContext(ts) + req, ctx := createContext(ts) email := "skip_mfa_" + uuid.NewString() + "@authorizer.dev" _, err := ts.GraphQLProvider.SignUp(ctx, &model.SignUpRequest{ @@ -54,42 +48,44 @@ func TestSkipMFASetup(t *testing.T) { 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") + require.Nil(t, loginRes.AccessToken, "first login with optional MFA and no prior enrollment/skip must withhold the token") + require.True(t, refs.BoolValue(loginRes.ShouldShowTotpScreen)) + + // Login withholds the token behind an MFA session cookie set on the + // response (Set-Cookie), not on the request — http.Request cookies + // are not auto-updated from responses in this in-process test setup + // (see latestAppSessionCookie's doc comment). Every other MFA-session + // test in this package (verify_otp_totp_test.go, + // verify_otp_totp_lockout_test.go, webauthn_test.go) copies the + // cookie onto the request by hand for the same reason; mirror that + // here rather than relying on it propagating automatically. + mfaSession := latestMfaSessionCookie(ts) + require.NotEmpty(t, mfaSession, "login must have set an mfa session cookie on the response") + req.Header.Set("Cookie", fmt.Sprintf("%s=%s", constants.MfaCookieName+"_session", mfaSession)) - ts.GinContext.Request.Header.Set("Authorization", "Bearer "+*loginRes.AccessToken) - skipRes, err := ts.GraphQLProvider.SkipMFASetup(ctx) + skipRes, err := ts.GraphQLProvider.SkipMFASetup(ctx, &model.SkipMfaSetupRequest{Email: &email}) require.NoError(t, err) require.NotNil(t, skipRes) - assert.Equal(t, "MFA setup skipped", skipRes.Message) + require.NotNil(t, skipRes.AccessToken, "skip must issue the token that was withheld at login") + assert.NotEmpty(t, *skipRes.AccessToken) 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") + require.NotNil(t, secondLogin.AccessToken, "a user who already skipped setup must log in normally, token issued immediately") }) - t.Run("rejects with FailedPrecondition when MFA is enforced, even with a valid token", func(t *testing.T) { + t.Run("rejects with FailedPrecondition when MFA is enforced, even with a valid mfa session", 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{ @@ -100,27 +96,11 @@ func TestSkipMFASetup(t *testing.T) { }) 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())) + require.NoError(t, ts.MemoryStoreProvider.SetMfaSession(user.ID, mfaSession, constants.MFASessionPurposeVerified, 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) + skipRes, err := ts.GraphQLProvider.SkipMFASetup(ctx, &model.SkipMfaSetupRequest{Email: &email}) require.Error(t, err) assert.Nil(t, skipRes) @@ -129,30 +109,134 @@ func TestSkipMFASetup(t *testing.T) { 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) { + t.Run(fmt.Sprintf("rejects with Unauthenticated when caller has no valid mfa session (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) + email := "skip_mfa_nosession_" + uuid.NewString() + "@authorizer.dev" + now := time.Now().Unix() + _, err := ts.StorageProvider.AddUser(ctx, &schemas.User{ + Email: refs.NewStringRef(email), + EmailVerifiedAt: &now, + SignupMethods: constants.AuthRecipeMethodBasicAuth, + IsMultiFactorAuthEnabled: refs.NewBoolRef(true), + }) + require.NoError(t, err) + + skipRes, err := ts.GraphQLProvider.SkipMFASetup(ctx, &model.SkipMfaSetupRequest{Email: &email}) 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") + assert.Equal(t, service.KindUnauthenticated, svcErr.Kind, "a caller with no valid mfa session must get Unauthenticated regardless of EnforceMFA") }) } + + t.Run("rejects with Unauthenticated when no identifier is given and the session cookie does not resolve", func(t *testing.T) { + cfg := getTestConfig() + cfg.EnableMFA = true + cfg.EnableTOTPLogin = true + ts := initTestSetup(t, cfg) + req, ctx := createContext(ts) + + // With no email/phone_number, SkipMFASetup now falls back to resolving + // the account from the session cookie alone (the OAuth-return + // continuation path — GetMfaSessionOwner). A cookie that resolves to no + // stored session therefore fails with Unauthenticated, not + // InvalidArgument: there is no longer an "identifier required" path here. + req.Header.Set("Cookie", fmt.Sprintf("%s=%s", constants.MfaCookieName+"_session", uuid.NewString())) + + skipRes, err := ts.GraphQLProvider.SkipMFASetup(ctx, &model.SkipMfaSetupRequest{}) + require.Error(t, err) + assert.Nil(t, skipRes) + + var svcErr *service.Error + require.True(t, errors.As(err, &svcErr)) + assert.Equal(t, service.KindUnauthenticated, svcErr.Kind) + }) + + t.Run("rejects a Challenge session (ResendOTP/ForgotPassword) with Unauthenticated", func(t *testing.T) { + cfg := getTestConfig() + cfg.EnableMFA = true + cfg.EnableTOTPLogin = true + ts := initTestSetup(t, cfg) + req, ctx := createContext(ts) + + email := "skip_mfa_challenge_" + 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) + + // A Challenge session is what ResendOTP / ForgotPassword mint for a + // caller who only supplied an email/phone number — no first factor. It + // must never be tradeable for a token, and must fail with the SAME shape + // as a missing session so the two cases are indistinguishable. + mfaSession := uuid.NewString() + require.NoError(t, ts.MemoryStoreProvider.SetMfaSession(user.ID, mfaSession, constants.MFASessionPurposeChallenge, time.Now().Add(5*time.Minute).Unix())) + req.Header.Set("Cookie", fmt.Sprintf("%s=%s", constants.MfaCookieName+"_session", mfaSession)) + + skipRes, err := ts.GraphQLProvider.SkipMFASetup(ctx, &model.SkipMfaSetupRequest{Email: &email}) + 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 Challenge session must be rejected like a missing session") + + unchanged, err := ts.StorageProvider.GetUserByEmail(ctx, email) + require.NoError(t, err) + assert.Nil(t, unchanged.HasSkippedMFASetupAt, "a rejected Challenge session must not have recorded a skip") + }) + + t.Run("rejects with FailedPrecondition when the user already has a verified authenticator", func(t *testing.T) { + cfg := getTestConfig() + cfg.EnableMFA = true + cfg.EnableTOTPLogin = true + ts := initTestSetup(t, cfg) + req, ctx := createContext(ts) + + email := "skip_mfa_verified_" + 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) + + // A verified TOTP authenticator puts the user in mfaGateBlockVerify — + // their own opted-in second factor. Even with a genuine Verified + // session, skip_mfa_setup must not let them bypass it. + _, err = ts.StorageProvider.AddAuthenticator(ctx, &schemas.Authenticator{ + UserID: user.ID, + Method: constants.EnvKeyTOTPAuthenticator, + Secret: "test-secret", + VerifiedAt: &now, + }) + require.NoError(t, err) + + mfaSession := uuid.NewString() + require.NoError(t, ts.MemoryStoreProvider.SetMfaSession(user.ID, mfaSession, constants.MFASessionPurposeVerified, time.Now().Add(5*time.Minute).Unix())) + req.Header.Set("Cookie", fmt.Sprintf("%s=%s", constants.MfaCookieName+"_session", mfaSession)) + + skipRes, err := ts.GraphQLProvider.SkipMFASetup(ctx, &model.SkipMfaSetupRequest{Email: &email}) + 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, "a user with a verified second factor must not be able to skip it") + }) } diff --git a/internal/integration_tests/test_helper.go b/internal/integration_tests/test_helper.go index c1bcab83..92c640e6 100644 --- a/internal/integration_tests/test_helper.go +++ b/internal/integration_tests/test_helper.go @@ -244,9 +244,15 @@ func initTestSetup(t *testing.T, cfg *config.Config) *testSetup { require.NoError(t, err) // Initialize other providers + memoryStoreProvider, err := memory_store.New(cfg, &memory_store.Dependencies{ + Log: &logger, + }) + require.NoError(t, err) + authProvider, err := authenticators.New(cfg, &authenticators.Dependencies{ - Log: &logger, - StorageProvider: storageProvider, + Log: &logger, + StorageProvider: storageProvider, + MemoryStoreProvider: memoryStoreProvider, }) require.NoError(t, err) @@ -262,11 +268,6 @@ func initTestSetup(t *testing.T, cfg *config.Config) *testSetup { }) require.NoError(t, err) - memoryStoreProvider, err := memory_store.New(cfg, &memory_store.Dependencies{ - Log: &logger, - }) - require.NoError(t, err) - webAuthnProvider, err := webauthn.NewProvider(&webauthn.Dependencies{ Log: &logger, StorageProvider: storageProvider, @@ -282,6 +283,7 @@ func initTestSetup(t *testing.T, cfg *config.Config) *testSetup { tokenProvider, err := token.New(cfg, &token.Dependencies{ Log: &logger, MemoryStoreProvider: memoryStoreProvider, + StorageProvider: storageProvider, }) require.NoError(t, err) @@ -365,6 +367,7 @@ func initTestSetup(t *testing.T, cfg *config.Config) *testSetup { r.Use(httpProvider.LoggerMiddleware()) r.POST("/graphql", httpProvider.GraphqlHandler()) + r.GET("/verify_email", httpProvider.VerifyEmailHandler()) server := httptest.NewServer(r) @@ -424,3 +427,26 @@ func latestAppSessionCookie(s *testSetup) string { } return latest } + +// latestMfaSessionCookie returns the most recent value of the +// MfaCookieName+"_session" cookie written to the gin response writer in this +// test — same rationale as latestAppSessionCookie: a token-withheld MFA +// offer (e.g. Login) sets this cookie only on the response, and +// http.Request cookies are not auto-updated from responses in this +// in-process test setup, so a caller that needs to act on that session (e.g. +// skip_mfa_setup) must copy it onto the next request by hand. +func latestMfaSessionCookie(s *testSetup) string { + prefix := constants.MfaCookieName + "_session=" + latest := "" + for _, h := range s.GinContext.Writer.Header().Values("Set-Cookie") { + first := h + if i := strings.IndexByte(h, ';'); i >= 0 { + first = h[:i] + } + first = strings.TrimSpace(first) + if strings.HasPrefix(first, prefix) { + latest = strings.TrimPrefix(first, prefix) + } + } + return latest +} diff --git a/internal/integration_tests/token_audience_test.go b/internal/integration_tests/token_audience_test.go new file mode 100644 index 00000000..2fa99c3b --- /dev/null +++ b/internal/integration_tests/token_audience_test.go @@ -0,0 +1,74 @@ +package integration_tests + +import ( + "encoding/base64" + "encoding/json" + "net/http" + "net/url" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// decodeJWTPayloadUnverified extracts the claims of a JWT without checking its +// signature — sufficient for tests that only need to read a claim from a +// token this same test just received from a trusted local server. +func decodeJWTPayloadUnverified(t *testing.T, tok string) map[string]interface{} { + t.Helper() + parts := strings.Split(tok, ".") + require.Len(t, parts, 3, "not a JWT: %s", tok) + payload, err := base64.RawURLEncoding.DecodeString(parts[1]) + require.NoError(t, err) + var claims map[string]interface{} + require.NoError(t, json.Unmarshal(payload, &claims)) + return claims +} + +// OIDC Core §2 / RFC 7519 §4.1.3: the "aud" claim MUST identify the intended +// recipient — the OAuth client the token was issued to. Before this fix, +// id_token/access_token/refresh_token minted directly by /authorize (hybrid, +// implicit) and by /oauth/token's authorization_code/refresh_token grants +// were audienced to the single reserved bootstrap client_id regardless of +// which client actually requested them, so a spec-compliant RP other than +// the reserved client would reject its own id_token as not meant for it. +// Regression test for the oidcc-refresh-token conformance failure +// (ValidateIdToken: "'aud' is not our client id") surfaced once a second, +// non-reserved client was exercised end-to-end. +func TestTokenAudience_MatchesRequestingClient_NotReservedClient(t *testing.T) { + cfg := getTestConfig() + ts := initTestSetup(t, cfg) + + registerTestClient(t, ts, "aud-test-client", "aud-test-client-secret") + require.NotEqual(t, "aud-test-client", cfg.ClientID, "test client must be distinct from the reserved bootstrap client") + + router, code, codeVerifier := loginForOfflineAccess(t, ts, "aud-test-client") + + form := url.Values{} + form.Set("grant_type", "authorization_code") + form.Set("code", code) + form.Set("code_verifier", codeVerifier) + form.Set("redirect_uri", "http://localhost:3000/callback") + + w := exchangeCode(router, form, []string{"aud-test-client", "aud-test-client-secret"}) + require.Equal(t, http.StatusOK, w.Code, "code exchange body: %s", w.Body.String()) + + var body map[string]interface{} + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) + + idToken, _ := body["id_token"].(string) + require.NotEmpty(t, idToken) + assert.Equal(t, "aud-test-client", decodeJWTPayloadUnverified(t, idToken)["aud"], + "id_token aud must be the requesting client, not the reserved bootstrap client") + + accessToken, _ := body["access_token"].(string) + require.NotEmpty(t, accessToken) + assert.Equal(t, "aud-test-client", decodeJWTPayloadUnverified(t, accessToken)["aud"], + "access_token aud must be the requesting client, not the reserved bootstrap client") + + refreshToken, _ := body["refresh_token"].(string) + require.NotEmpty(t, refreshToken) + assert.Equal(t, "aud-test-client", decodeJWTPayloadUnverified(t, refreshToken)["aud"], + "refresh_token aud must be the requesting client, not the reserved bootstrap client") +} diff --git a/internal/integration_tests/token_client_secret_basic_encoding_test.go b/internal/integration_tests/token_client_secret_basic_encoding_test.go new file mode 100644 index 00000000..af67c714 --- /dev/null +++ b/internal/integration_tests/token_client_secret_basic_encoding_test.go @@ -0,0 +1,52 @@ +package integration_tests + +import ( + "encoding/base64" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// RFC 6749 §2.3.1: client_id and client_secret carried in the HTTP Basic +// Authorization header MUST each be application/x-www-form-urlencoded before +// being placed there. Some client libraries (e.g. Java's URLEncoder, used by +// the OpenID Foundation conformance suite) percent-encode characters like "!" +// under that algorithm. A server that only base64-decodes the header — never +// undoing that encoding — rejects a perfectly valid secret containing such a +// character. Regression test for a real conformance failure (oidcc-refresh- +// token, "second client" token exchange) caused by a client secret containing +// "!": the raw HTTP Basic password arrived as "...%21" instead of "...!". +func TestTokenClientAuth_BasicAuth_FormURLEncodedSecret_Accepted(t *testing.T) { + cfg := getTestConfig() + ts := initTestSetup(t, cfg) + + registerTestClient(t, ts, "client-bang", "Secret!With*Special'Chars") + + router := gin.New() + router.POST("/oauth/token", ts.HttpProvider.TokenHandler()) + + // application/x-www-form-urlencoded (RFC 3986 percent-encoding, the + // legacy convention Java's URLEncoder follows) of "Secret!With*Special'Chars" + // percent-encodes "!", "*", and "'". + encodedSecret := "Secret%21With%2ASpecial%27Chars" + basicAuth := base64.StdEncoding.EncodeToString([]byte("client-bang:" + encodedSecret)) + + req, err := http.NewRequest(http.MethodPost, "/oauth/token", nil) + require.NoError(t, err) + req.Header.Set("Authorization", "Basic "+basicAuth) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + // grant_type is intentionally invalid — this test only proves client + // authentication succeeds (unsupported_grant_type, not invalid_client). + req.Body = http.NoBody + + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + assert.NotEqual(t, http.StatusUnauthorized, w.Code, "body: %s", w.Body.String()) + assert.NotContains(t, w.Body.String(), "invalid_client", + "a form-urlencoded Basic-auth secret must decode back to the stored secret before comparison") +} diff --git a/internal/integration_tests/token_exchange_test.go b/internal/integration_tests/token_exchange_test.go index f79943a6..a50cc053 100644 --- a/internal/integration_tests/token_exchange_test.go +++ b/internal/integration_tests/token_exchange_test.go @@ -38,6 +38,26 @@ func decodeJWTPayload(t *testing.T, tok string) map[string]interface{} { return claims } +// claimScope extracts a delegated/machine token's scope claim as []string. +// The claim is encoded as a JSON array (token.DelegationTokenConfig.Scope / +// AuthTokenConfig.Scope are both []string), so it decodes through +// encoding/json as []interface{}, NOT a space-separated string - a +// `claims["scope"].(string)` assertion fails silently (empty string, no +// panic), which previously made a widening regression on this exact claim +// undetectable by the test that specifically exists to catch one. +func claimScope(t *testing.T, claims map[string]interface{}) []string { + t.Helper() + raw, ok := claims["scope"].([]interface{}) + require.True(t, ok, "scope claim must be a JSON array") + out := make([]string, 0, len(raw)) + for _, v := range raw { + s, ok := v.(string) + require.True(t, ok, "scope claim entries must be strings") + out = append(out, s) + } + return out +} + // newDelegationAgent creates an active service_account client (the agent) with the // given scope ceiling, returning its client_id and plaintext secret. func newDelegationAgent(t *testing.T, ts *testSetup, ceiling string) (string, string) { @@ -88,6 +108,109 @@ func postTokenExchange(ts *testSetup, router http.Handler, form url.Values, clie return w } +// TestTokenExchangeMultiHopDelegation exercises the act-chain nesting and +// scope attenuation the map/design docs describe (app > agent > sub-agent) +// through REAL multiple JWT round-trips - not just reading actChainDepth's +// logic, since a JWT's claims (including the nested `act` object) go +// through a JSON marshal/parse round-trip at every hop, exactly the kind of +// path where a nested-map type assertion could silently truncate the chain +// without a runtime error. Only single-hop delegation had test coverage +// before this. +func TestTokenExchangeMultiHopDelegation(t *testing.T) { + cfg := getTestConfig() + ts := initTestSetup(t, cfg) + router := gin.New() + router.POST("/oauth/token", ts.HttpProvider.TokenHandler()) + + // Each hop's agent has a progressively narrower scope ceiling, so a + // widening bug would show up as a scope surviving past the hop that + // should have dropped it, not just as an empty-vs-nonempty check. Scoped + // to openid/email/profile - the standard OIDC scopes testAccessToken's + // plain signup actually carries (attenuation only ever narrows what the + // subject already has, so a ceiling of custom scopes the user's own + // token never had would just intersect to empty at hop 1). + agent1ID, agent1Secret := newDelegationAgent(t, ts, "openid,email,profile") + agent2ID, agent2Secret := newDelegationAgent(t, ts, "openid,email") + agent3ID, agent3Secret := newDelegationAgent(t, ts, "openid") + agent4ID, agent4Secret := newDelegationAgent(t, ts, "openid") + agent5ID, agent5Secret := newDelegationAgent(t, ts, "openid") + + exchange := func(t *testing.T, subjectToken, agentID, agentSecret string) *httptest.ResponseRecorder { + t.Helper() + actor := agentAccessToken(t, ts, router, agentID, agentSecret) + form := url.Values{} + form.Set("grant_type", tokenExchangeGrant) + form.Set("subject_token", subjectToken) + form.Set("subject_token_type", accessTokenType) + form.Set("actor_token", actor) + form.Set("actor_token_type", accessTokenType) + form.Set("resource", "https://api.example.com/v1") + return postTokenExchange(ts, router, form, agentID, agentSecret) + } + + userToken := testAccessToken(t, ts) + + w1 := exchange(t, userToken, agent1ID, agent1Secret) + require.Equal(t, http.StatusOK, w1.Code, "hop 1: %s", w1.Body.String()) + var resp1 map[string]interface{} + require.NoError(t, json.Unmarshal(w1.Body.Bytes(), &resp1)) + tok1, _ := resp1["access_token"].(string) + claims1 := decodeJWTPayload(t, tok1) + assert.ElementsMatch(t, []string{"openid", "email", "profile"}, claimScope(t, claims1), "hop 1 scope = agent1 ceiling ∩ user's own scope") + act1, ok := claims1["act"].(map[string]interface{}) + require.True(t, ok) + assert.Equal(t, agent1ID, act1["sub"]) + assert.Nil(t, act1["act"], "hop 1 has no prior actor") + + w2 := exchange(t, tok1, agent2ID, agent2Secret) + require.Equal(t, http.StatusOK, w2.Code, "hop 2: %s", w2.Body.String()) + var resp2 map[string]interface{} + require.NoError(t, json.Unmarshal(w2.Body.Bytes(), &resp2)) + tok2, _ := resp2["access_token"].(string) + claims2 := decodeJWTPayload(t, tok2) + assert.ElementsMatch(t, []string{"openid", "email"}, claimScope(t, claims2), "hop 2 narrows to agent2's ceiling (profile dropped)") + act2, ok := claims2["act"].(map[string]interface{}) + require.True(t, ok) + assert.Equal(t, agent2ID, act2["sub"], "immediate actor is the hop-2 agent") + act2prior, ok := act2["act"].(map[string]interface{}) + require.True(t, ok, "hop 1's actor must survive nested under hop 2 through the real JWT round-trip") + assert.Equal(t, agent1ID, act2prior["sub"], "hop 1's agent is preserved as the nested prior actor") + + w3 := exchange(t, tok2, agent3ID, agent3Secret) + require.Equal(t, http.StatusOK, w3.Code, "hop 3: %s", w3.Body.String()) + var resp3 map[string]interface{} + require.NoError(t, json.Unmarshal(w3.Body.Bytes(), &resp3)) + tok3, _ := resp3["access_token"].(string) + claims3 := decodeJWTPayload(t, tok3) + assert.ElementsMatch(t, []string{"openid"}, claimScope(t, claims3), "hop 3 narrows further to agent3's ceiling (email dropped)") + + w4 := exchange(t, tok3, agent4ID, agent4Secret) + require.Equal(t, http.StatusOK, w4.Code, "hop 4 (at the depth cap) must succeed: %s", w4.Body.String()) + var resp4 map[string]interface{} + require.NoError(t, json.Unmarshal(w4.Body.Bytes(), &resp4)) + tok4, _ := resp4["access_token"].(string) + claims4 := decodeJWTPayload(t, tok4) + // Walk the full 4-level nested act chain end to end - proves depth + // counting and nesting both survived four real JWT round-trips intact. + act4, ok := claims4["act"].(map[string]interface{}) + require.True(t, ok) + assert.Equal(t, agent4ID, act4["sub"]) + act4p1, ok := act4["act"].(map[string]interface{}) + require.True(t, ok, "depth 2 missing") + assert.Equal(t, agent3ID, act4p1["sub"]) + act4p2, ok := act4p1["act"].(map[string]interface{}) + require.True(t, ok, "depth 3 missing") + assert.Equal(t, agent2ID, act4p2["sub"]) + act4p3, ok := act4p2["act"].(map[string]interface{}) + require.True(t, ok, "depth 4 missing") + assert.Equal(t, agent1ID, act4p3["sub"]) + assert.Nil(t, act4p3["act"], "chain must be exactly 4 deep, no further nesting") + + w5 := exchange(t, tok4, agent5ID, agent5Secret) + assert.Equal(t, http.StatusBadRequest, w5.Code, "hop 5 exceeds maxActChainDepth and must be rejected: %s", w5.Body.String()) + assert.Contains(t, w5.Body.String(), "maximum allowed depth") +} + func TestTokenExchangeDelegation(t *testing.T) { cfg := getTestConfig() ts := initTestSetup(t, cfg) @@ -176,11 +299,41 @@ func TestTokenExchangeDelegation(t *testing.T) { tok, _ := resp["access_token"].(string) require.NotEmpty(t, tok) claims := decodeJWTPayload(t, tok) - scope, _ := claims["scope"].(string) - assert.NotContains(t, strings.Fields(scope), "admin", + assert.NotContains(t, claimScope(t, claims), "admin", "a scope outside the agent's ceiling must never be granted (non-widening)") }) + t.Run("deactivated_service_account_subject_cannot_seed_delegation", func(t *testing.T) { + // The subject here is a DIFFERENT service account than the agent - + // simulates a multi-hop chain where an upstream agent's own token is + // being re-exchanged as the subject_token. Deactivate it AFTER + // minting its token but before the exchange: the token is still + // cryptographically valid (unexpired, correctly signed) - proving + // this rejection actually comes from a live active-status check, not + // from token validation catching something else. + subjectClientID, subjectSecret := newDelegationAgent(t, ts, "openid,profile,email") + subjectToken := agentAccessToken(t, ts, router, subjectClientID, subjectSecret) + + subjectClient, err := ts.StorageProvider.GetClientByClientID(context.Background(), subjectClientID) + require.NoError(t, err) + subjectClient.IsActive = false + _, err = ts.StorageProvider.UpdateClient(context.Background(), subjectClient) + require.NoError(t, err) + + clientID, secret := newDelegationAgent(t, ts, "openid,profile,email") + actor := agentAccessToken(t, ts, router, clientID, secret) + form := url.Values{} + form.Set("grant_type", tokenExchangeGrant) + form.Set("subject_token", subjectToken) + form.Set("subject_token_type", accessTokenType) + form.Set("actor_token", actor) + form.Set("actor_token_type", accessTokenType) + form.Set("resource", "https://api.example.com/v1") + w := postTokenExchange(ts, router, form, clientID, secret) + assert.Equal(t, http.StatusBadRequest, w.Code, "a deactivated service-account subject must not seed a delegation: %s", w.Body.String()) + assert.Contains(t, w.Body.String(), "no longer active") + }) + t.Run("actor_token_must_belong_to_authenticated_agent", func(t *testing.T) { // A valid token that is NOT the agent's own (here a user token, whose // client_id != the agent) must be rejected as the actor (RFC 8693 §1.1). diff --git a/internal/integration_tests/totp_resetup_safety_test.go b/internal/integration_tests/totp_resetup_safety_test.go new file mode 100644 index 00000000..75f2f86c --- /dev/null +++ b/internal/integration_tests/totp_resetup_safety_test.go @@ -0,0 +1,91 @@ +package integration_tests + +import ( + "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/refs" + "github.com/authorizerdev/authorizer/internal/storage/schemas" +) + +// TestTOTPResetupDoesNotDesyncUntilConfirmed guards the re-setup safety fix: a +// user re-running TOTP setup on an already-verified authenticator must keep +// their existing authenticator app working until they actually confirm the new +// code. The new secret is staged as pending and only promoted to the live row +// on confirmation, so an abandoned re-setup can never lock the account out. +func TestTOTPResetupDoesNotDesyncUntilConfirmed(t *testing.T) { + cfg := getTestConfig() + cfg.EnableMFA = true + cfg.EnableTOTPLogin = true + ts := initTestSetup(t, cfg) + require.NotNil(t, ts.AuthenticatorProvider, "TOTP must be enabled for this test") + _, ctx := createContext(ts) + + email := "totp_resetup_" + 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, + }) + require.NoError(t, err) + + getRow := func() *schemas.Authenticator { + row, gErr := ts.StorageProvider.GetAuthenticatorDetailsByUserId(ctx, user.ID, constants.EnvKeyTOTPAuthenticator) + require.NoError(t, gErr) + require.NotNil(t, row) + return row + } + + // Initial enrollment + confirmation → row is verified with secret #1. + enroll1, err := ts.AuthenticatorProvider.Generate(ctx, user.ID) + require.NoError(t, err) + code1, err := totp.GenerateCode(enroll1.Secret, time.Now()) + require.NoError(t, err) + ok, err := ts.AuthenticatorProvider.Validate(ctx, code1, user.ID) + require.NoError(t, err) + require.True(t, ok, "initial enrollment code must confirm") + liveSecret := getRow().Secret + require.NotNil(t, getRow().VerifiedAt) + + // Re-setup: generate secret #2. The live row must be UNTOUCHED — this is the + // bug being fixed (previously the live secret was overwritten immediately). + enroll2, err := ts.AuthenticatorProvider.Generate(ctx, user.ID) + require.NoError(t, err) + require.NotEqual(t, enroll1.Secret, enroll2.Secret, "re-setup must generate a fresh secret") + assert.Equal(t, liveSecret, getRow().Secret, "re-setup must NOT overwrite the live secret before confirmation") + + // The previously-working authenticator must keep validating. + oldCode, err := totp.GenerateCode(enroll1.Secret, time.Now()) + require.NoError(t, err) + ok, err = ts.AuthenticatorProvider.Validate(ctx, oldCode, user.ID) + require.NoError(t, err) + assert.True(t, ok, "the old authenticator must keep working until the new code is confirmed") + assert.Equal(t, liveSecret, getRow().Secret, "validating the old code must not promote the pending secret") + + // Confirm the new code → the pending secret is promoted to the live row. + newCode, err := totp.GenerateCode(enroll2.Secret, time.Now()) + require.NoError(t, err) + ok, err = ts.AuthenticatorProvider.Validate(ctx, newCode, user.ID) + require.NoError(t, err) + require.True(t, ok, "confirming the new code must succeed") + assert.NotEqual(t, liveSecret, getRow().Secret, "after confirmation the new secret must be live") + + // The old secret must no longer validate; the new one must. + oldCodeAfter, err := totp.GenerateCode(enroll1.Secret, time.Now()) + require.NoError(t, err) + ok, _ = ts.AuthenticatorProvider.Validate(ctx, oldCodeAfter, user.ID) + assert.False(t, ok, "after promotion the old secret must no longer validate") + + newCodeAfter, err := totp.GenerateCode(enroll2.Secret, time.Now()) + require.NoError(t, err) + ok, err = ts.AuthenticatorProvider.Validate(ctx, newCodeAfter, user.ID) + require.NoError(t, err) + assert.True(t, ok, "the newly-confirmed secret must validate") +} diff --git a/internal/integration_tests/verify_email_test.go b/internal/integration_tests/verify_email_test.go index 178dbe08..bc1fbe8c 100644 --- a/internal/integration_tests/verify_email_test.go +++ b/internal/integration_tests/verify_email_test.go @@ -1,6 +1,8 @@ package integration_tests import ( + "net/http" + "net/url" "testing" "time" @@ -121,3 +123,109 @@ func TestVerifyEmail(t *testing.T) { assert.NotEmpty(t, verificationRes.AccessToken) }) } + +// TestVerifyEmailRESTEndpointMFAGate guards GET /verify_email - the actual +// URL the "verify your email" / magic-link-login email sends users to. It is +// a separate implementation from the GraphQL verify_email mutation (which +// TestVerifyEmail and TestMagicLinkLoginMFAGate exercise), and previously +// issued a full session unconditionally with no MFA gate check and no +// RevokedTimestamp check at all - a user could complete signup email +// verification or magic-link login and be handed working tokens regardless +// of MFA enrollment status or account revocation, entirely bypassing +// resolveMFAGate. Exercises the real HTTP handler, not the service layer +// directly, since that's exactly the boundary the bug lived at. +func TestVerifyEmailRESTEndpointMFAGate(t *testing.T) { + cfg := getTestConfig() + cfg.IsEmailServiceEnabled = true + cfg.EnableEmailVerification = true + cfg.EnableMFA = true + ts := initTestSetup(t, cfg) + _, ctx := createContext(ts) + password := "Password@123" + + httpClient := &http.Client{ + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + }, + } + + signupAndGetToken := func(t *testing.T, email string) string { + t.Helper() + _, err := ts.GraphQLProvider.SignUp(ctx, &model.SignUpRequest{ + Email: &email, + Password: password, + ConfirmPassword: password, + }) + require.NoError(t, err) + vreq, err := ts.StorageProvider.GetVerificationRequestByEmail(ctx, email, constants.VerificationTypeBasicAuthSignup) + require.NoError(t, err) + require.NotNil(t, vreq) + return vreq.Token + } + + hitVerifyEmail := func(t *testing.T, token string) *http.Response { + t.Helper() + reqURL := ts.HttpServer.URL + "/verify_email?token=" + url.QueryEscape(token) + + "&redirect_uri=" + url.QueryEscape("http://localhost:3000/callback") + resp, err := httpClient.Get(reqURL) + require.NoError(t, err) + t.Cleanup(func() { _ = resp.Body.Close() }) + return resp + } + + t.Run("MFA gate withholds tokens, matching the GraphQL mutation", func(t *testing.T) { + email := "verify_email_rest_offer_" + uuid.New().String() + "@authorizer.dev" + resp := hitVerifyEmail(t, signupAndGetToken(t, email)) + + require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode) + location := resp.Header.Get("Location") + assert.NotContains(t, location, "access_token=") + assert.Contains(t, location, "mfa_required=1") + assert.Contains(t, location, "mfa_methods=") + }) + + t.Run("revoked user is rejected, not issued a session", func(t *testing.T) { + email := "verify_email_rest_revoked_" + uuid.New().String() + "@authorizer.dev" + token := signupAndGetToken(t, email) + + user, err := ts.StorageProvider.GetUserByEmail(ctx, email) + require.NoError(t, err) + now := time.Now().Unix() + user.RevokedTimestamp = &now + _, err = ts.StorageProvider.UpdateUser(ctx, user) + require.NoError(t, err) + + resp := hitVerifyEmail(t, token) + require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode) + location := resp.Header.Get("Location") + assert.NotContains(t, location, "access_token=") + assert.Contains(t, location, "error=") + }) + + t.Run("no MFA configured still completes normally with real tokens", func(t *testing.T) { + cfgNoMFA := getTestConfig() + cfgNoMFA.IsEmailServiceEnabled = true + cfgNoMFA.EnableEmailVerification = true + tsNoMFA := initTestSetup(t, cfgNoMFA) + _, ctxNoMFA := createContext(tsNoMFA) + + email := "verify_email_rest_nomfa_" + uuid.New().String() + "@authorizer.dev" + _, err := tsNoMFA.GraphQLProvider.SignUp(ctxNoMFA, &model.SignUpRequest{ + Email: &email, + Password: password, + ConfirmPassword: password, + }) + require.NoError(t, err) + vreq, err := tsNoMFA.StorageProvider.GetVerificationRequestByEmail(ctxNoMFA, email, constants.VerificationTypeBasicAuthSignup) + require.NoError(t, err) + + reqURL := tsNoMFA.HttpServer.URL + "/verify_email?token=" + url.QueryEscape(vreq.Token) + + "&redirect_uri=" + url.QueryEscape("http://localhost:3000/callback") + resp, err := httpClient.Get(reqURL) + require.NoError(t, err) + defer func() { _ = resp.Body.Close() }() + + require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode) + assert.Contains(t, resp.Header.Get("Location"), "access_token=") + }) +} diff --git a/internal/integration_tests/verify_otp_test.go b/internal/integration_tests/verify_otp_test.go index d99178bd..9022bc76 100644 --- a/internal/integration_tests/verify_otp_test.go +++ b/internal/integration_tests/verify_otp_test.go @@ -196,6 +196,7 @@ func TestVerifyOTPNoRecord(t *testing.T) { // itself rather than reaching the no-OTP-record path this test guards. mfaSession := uuid.New().String() require.NoError(t, ts.MemoryStoreProvider.SetMfaSession(signupRes.User.ID, mfaSession, + constants.MFASessionPurposeVerified, time.Now().Add(5*time.Minute).Unix())) req.Header.Set("Cookie", fmt.Sprintf("%s=%s", constants.MfaCookieName+"_session", mfaSession)) diff --git a/internal/integration_tests/verify_otp_totp_lockout_test.go b/internal/integration_tests/verify_otp_totp_lockout_test.go index bbdd182a..b3dd420b 100644 --- a/internal/integration_tests/verify_otp_totp_lockout_test.go +++ b/internal/integration_tests/verify_otp_totp_lockout_test.go @@ -56,6 +56,7 @@ func TestVerifyOTPTOTPLockout(t *testing.T) { armMfaSession := func(userID string) { mfaSession := uuid.NewString() require.NoError(t, ts.MemoryStoreProvider.SetMfaSession(userID, mfaSession, + constants.MFASessionPurposeVerified, time.Now().Add(5*time.Minute).Unix())) req.Header.Set("Cookie", fmt.Sprintf("%s=%s", constants.MfaCookieName+"_session", mfaSession)) } diff --git a/internal/integration_tests/verify_otp_totp_test.go b/internal/integration_tests/verify_otp_totp_test.go index 1ed75c4b..20f2ade8 100644 --- a/internal/integration_tests/verify_otp_totp_test.go +++ b/internal/integration_tests/verify_otp_totp_test.go @@ -51,6 +51,7 @@ func TestVerifyOTPTOTPThroughService(t *testing.T) { armMfaSession := func() { mfaSession := uuid.NewString() require.NoError(t, ts.MemoryStoreProvider.SetMfaSession(user.ID, mfaSession, + constants.MFASessionPurposeVerified, time.Now().Add(5*time.Minute).Unix())) req.Header.Set("Cookie", fmt.Sprintf("%s=%s", constants.MfaCookieName+"_session", mfaSession)) } diff --git a/internal/integration_tests/webauthn_enforce_mfa_test.go b/internal/integration_tests/webauthn_enforce_mfa_test.go index 9f7cf31f..1753de30 100644 --- a/internal/integration_tests/webauthn_enforce_mfa_test.go +++ b/internal/integration_tests/webauthn_enforce_mfa_test.go @@ -1,6 +1,7 @@ package integration_tests import ( + "fmt" "testing" "time" @@ -8,6 +9,7 @@ import ( "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "golang.org/x/crypto/bcrypt" "github.com/authorizerdev/authorizer/internal/constants" "github.com/authorizerdev/authorizer/internal/graph/model" @@ -34,7 +36,7 @@ func registerPasskeyForNewUser(t *testing.T, ts *testSetup) (*schemas.User, virt require.NotNil(t, signupRes.AccessToken) req.Header.Set("Authorization", "Bearer "+*signupRes.AccessToken) - optRes, err := ts.GraphQLProvider.WebauthnRegistrationOptions(ctx, nil) + optRes, err := ts.GraphQLProvider.WebauthnRegistrationOptions(ctx, nil, nil) require.NoError(t, err) attOpts, err := virtualwebauthn.ParseAttestationOptions(optRes.Options) require.NoError(t, err) @@ -63,43 +65,91 @@ func assertPasskeyLogin(t *testing.T, ts *testSetup, rp virtualwebauthn.RelyingP return ts.GraphQLProvider.WebauthnLoginVerify(ctx, &model.WebauthnLoginVerifyRequest{Credential: assertResp}) } +// TestWebauthnLoginVerifyEnforceMFA locks in the decided policy: a successful +// passkey assertion (registered with UserVerification: Required — device + +// biometric bundled into one ceremony) satisfies the MFA requirement on its +// own, exactly like a verified TOTP/OTP code does in verify_otp. Every passkey +// login below therefore issues a token directly, with no TOTP re-challenge, +// regardless of EnforceMFA or any other enrolled factor. The one thing that +// must NOT change is the password path: password alone still never satisfies +// EnforceMFA (last subtest). func TestWebauthnLoginVerifyEnforceMFA(t *testing.T) { - t.Run("EnforceMFA=false — unchanged, issues token", func(t *testing.T) { + t.Run("EnforceMFA=true, TOTP also enrolled — passkey login issues the token, no TOTP re-prompt", 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.AccessToken, "EnforceMFA=false must not block passkey login") + require.NotNil(t, authRes) + require.NotNil(t, authRes.AccessToken, "a verified passkey satisfies MFA on its own — no TOTP re-challenge") + assert.NotEmpty(t, *authRes.AccessToken) + assert.False(t, refs.BoolValue(authRes.ShouldShowTotpScreen), "must not re-demand TOTP after a successful passkey verify") }) - t.Run("EnforceMFA=true, user MFA not individually enabled — unaffected", func(t *testing.T) { + t.Run("EnforceMFA=true, no other factor enrolled — passkey login still issues the token", func(t *testing.T) { cfg := getTestConfig() cfg.EnforceMFA = true + cfg.EnableTOTPLogin = 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) + 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) require.NotNil(t, authRes.AccessToken) + assert.Nil(t, authRes.AuthenticatorSecret, "no enrollment payload — the passkey itself is the satisfying factor") }) - t.Run("EnforceMFA=true, TOTP verified — blocks token, offers totp screen", func(t *testing.T) { + t.Run("EnforceMFA=true, TOTP disabled server-wide — passkey login issues the token (no longer refused)", func(t *testing.T) { cfg := getTestConfig() cfg.EnforceMFA = true - cfg.EnableTOTPLogin = 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.NoError(t, err) + require.NotNil(t, authRes) + require.NotNil(t, authRes.AccessToken, "passkey satisfies MFA even when TOTP is unavailable server-wide") + }) + + t.Run("EnforceMFA=false, optional MFA enabled — passkey login issues the token", func(t *testing.T) { + cfg := getTestConfig() + cfg.EnableWebauthnMFA = 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) + require.NotNil(t, authRes.AccessToken) + }) + + t.Run("only email-OTP enrolled — passkey login issues the token, no email-OTP challenge", func(t *testing.T) { + cfg := getTestConfig() + cfg.EnableEmailOTP = true + cfg.IsEmailServiceEnabled = true ts := initTestSetup(t, cfg) user, rp, authenticator, credential := registerPasskeyForNewUser(t, ts) user.IsMultiFactorAuthEnabled = refs.NewBoolRef(true) @@ -107,49 +157,246 @@ func TestWebauthnLoginVerifyEnforceMFA(t *testing.T) { 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, + UserID: user.ID, Method: constants.EnvKeyEmailOTPAuthenticator, 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") + require.NotNil(t, authRes.AccessToken, "a passkey login must satisfy MFA without an email-OTP challenge") + assert.False(t, refs.BoolValue(authRes.ShouldShowEmailOtpScreen), "must not send an email OTP after a successful passkey verify") }) - t.Run("EnforceMFA=true, TOTP not enrolled — blocks token, returns enrollment payload", func(t *testing.T) { + t.Run("only SMS-OTP enrolled — passkey login issues the token, no SMS-OTP challenge", func(t *testing.T) { cfg := getTestConfig() - cfg.EnforceMFA = true - cfg.EnableTOTPLogin = true + cfg.EnableSMSOTP = true + // IsSMSServiceEnabled is already true in the test config. 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.EnvKeySMSOTPAuthenticator, 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) - assert.True(t, refs.BoolValue(authRes.ShouldShowTotpScreen)) - assert.NotNil(t, authRes.AuthenticatorSecret, "not-yet-enrolled path must hand back a fresh TOTP enrollment payload") + require.NotNil(t, authRes.AccessToken, "a passkey login must satisfy MFA without an SMS-OTP challenge") + assert.False(t, refs.BoolValue(authRes.ShouldShowMobileOtpScreen), "must not send an SMS OTP after a successful passkey verify") }) - t.Run("EnforceMFA=true, TOTP disabled server-wide — refuses passkey login entirely", func(t *testing.T) { + t.Run("all three other factors enrolled simultaneously (TOTP + email-OTP + SMS-OTP) — passkey login issues the token, no challenge for any", func(t *testing.T) { cfg := getTestConfig() cfg.EnforceMFA = true - cfg.EnableTOTPLogin = false + cfg.EnableTOTPLogin = true + cfg.EnableEmailOTP = true + cfg.IsEmailServiceEnabled = true + cfg.EnableSMSOTP = 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() + for _, method := range []string{constants.EnvKeyTOTPAuthenticator, constants.EnvKeyEmailOTPAuthenticator, constants.EnvKeySMSOTPAuthenticator} { + _, err = ts.StorageProvider.AddAuthenticator(t.Context(), &schemas.Authenticator{ + UserID: user.ID, Method: method, 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) + require.NotNil(t, authRes.AccessToken, "a passkey login must satisfy MFA even when every other factor is also enrolled") + assert.False(t, refs.BoolValue(authRes.ShouldShowTotpScreen)) + assert.False(t, refs.BoolValue(authRes.ShouldShowEmailOtpScreen)) + assert.False(t, refs.BoolValue(authRes.ShouldShowMobileOtpScreen)) + }) + + t.Run("revoked account — passkey login refused despite an otherwise valid assertion", func(t *testing.T) { + cfg := getTestConfig() + ts := initTestSetup(t, cfg) + user, rp, authenticator, credential := registerPasskeyForNewUser(t, ts) + now := time.Now().Unix() + user.RevokedTimestamp = &now + _, 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.Error(t, err, "passkey login must be refused once the account is revoked") assert.Nil(t, authRes) + if err != nil { + assert.Contains(t, err.Error(), "revoked") + } }) + + t.Run("MFA-locked account — passkey login refused", func(t *testing.T) { + cfg := getTestConfig() + ts := initTestSetup(t, cfg) + user, rp, authenticator, credential := registerPasskeyForNewUser(t, ts) + now := time.Now().Unix() + user.MFALockedAt = &now + _, err := ts.StorageProvider.UpdateUser(t.Context(), user) + require.NoError(t, err) + + authRes, err := assertPasskeyLogin(t, ts, rp, authenticator, credential) + assert.Error(t, err, "passkey login must be refused while the account's MFA is locked — a passkey satisfying MFA does not bypass an explicit lock") + assert.Nil(t, authRes) + if err != nil { + assert.Contains(t, err.Error(), "locked") + } + }) + + t.Run("password-only login without a second factor is STILL blocked by EnforceMFA (unchanged)", func(t *testing.T) { + cfg := getTestConfig() + cfg.EnableMFA = true + cfg.EnforceMFA = true + cfg.EnableTOTPLogin = true + ts := initTestSetup(t, cfg) + _, ctx := createContext(ts) + + email := "enforce_pw_" + uuid.NewString() + "@authorizer.dev" + password := "Password@123" + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + require.NoError(t, err) + now := time.Now().Unix() + _, err = ts.StorageProvider.AddUser(ctx, &schemas.User{ + Email: refs.NewStringRef(email), + EmailVerifiedAt: &now, + Password: refs.NewStringRef(string(hash)), + SignupMethods: constants.AuthRecipeMethodBasicAuth, + IsMultiFactorAuthEnabled: refs.NewBoolRef(true), + }) + require.NoError(t, err) + + loginRes, err := ts.GraphQLProvider.Login(ctx, &model.LoginRequest{Email: &email, Password: password}) + require.NoError(t, err) + require.NotNil(t, loginRes) + assert.Nil(t, loginRes.AccessToken, "password alone must not satisfy EnforceMFA — the passkey policy change must not weaken the password path") + }) +} + +// TestWebauthnLoginVerifyAsSecondFactor is the original Bug 1 scenario: password +// is the primary factor and a passkey is offered as the second factor +// (ShouldOfferWebauthnMfaVerify). Completing that passkey must finish the login +// without any spurious TOTP prompt, even when TOTP is also enrolled. +func TestWebauthnLoginVerifyAsSecondFactor(t *testing.T) { + cfg := getTestConfig() + cfg.EnableWebauthnMFA = true + cfg.EnableTOTPLogin = true + // EnableMFA is left off during setup so signup still issues the bearer token + // registerPasskeyForNewUser needs to enroll the passkey; it is turned on + // (mutating the same cfg the providers read live) just before the login that + // must exercise the password MFA gate. + 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) + + cfg.EnableMFA = true + + email := refs.StringValue(user.Email) + req, ctx := createContext(ts) + + loginRes, err := ts.GraphQLProvider.Login(ctx, &model.LoginRequest{Email: &email, Password: "Password@123"}) + require.NoError(t, err) + require.Nil(t, loginRes.AccessToken, "password login with a verified second factor must withhold the token and challenge it") + require.True(t, refs.BoolValue(loginRes.ShouldOfferWebauthnMfaVerify), "the enrolled passkey must be offered as a verify method") + + mfaSession := latestMfaSessionCookie(ts) + require.NotEmpty(t, mfaSession, "password login must have armed an mfa session cookie") + req.Header.Set("Cookie", fmt.Sprintf("%s=%s", constants.MfaCookieName+"_session", mfaSession)) + + optRes, err := ts.GraphQLProvider.WebauthnLoginOptions(ctx, &email) + require.NoError(t, err) + assertOpts, err := virtualwebauthn.ParseAssertionOptions(optRes.Options) + require.NoError(t, err) + assertResp := virtualwebauthn.CreateAssertionResponse(rp, authenticator, credential, *assertOpts) + + verifyRes, err := ts.GraphQLProvider.WebauthnLoginVerify(ctx, &model.WebauthnLoginVerifyRequest{Credential: assertResp}) + require.NoError(t, err) + require.NotNil(t, verifyRes) + require.NotNil(t, verifyRes.AccessToken, "completing the offered passkey second factor must finish the login") + assert.NotEmpty(t, *verifyRes.AccessToken) + assert.False(t, refs.BoolValue(verifyRes.ShouldShowTotpScreen), "must not re-demand TOTP after the passkey second factor") +} + +// TestWebauthnLoginVerifyAsSecondFactorEmailOTPEnrolled guards the fix for a +// gap this test caught: the email-OTP branch in login.go used to return early +// — before a registered passkey was ever checked for — forcing a user with +// both an enrolled passkey and email-OTP into the email-OTP screen with no +// passkey alternative offered. login.go now computes hasWebauthnCredential +// up-front and offers it alongside email-OTP, matching how TOTP is already +// offered alongside webauthn. +func TestWebauthnLoginVerifyAsSecondFactorEmailOTPEnrolled(t *testing.T) { + cfg := getTestConfig() + cfg.EnableWebauthnMFA = true + cfg.EnableEmailOTP = true + cfg.IsEmailServiceEnabled = true + ts := initTestSetup(t, cfg) + + user, _, _, _ := 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.EnvKeyEmailOTPAuthenticator, VerifiedAt: &now, + }) + require.NoError(t, err) + + cfg.EnableMFA = true + + email := refs.StringValue(user.Email) + _, ctx := createContext(ts) + + loginRes, err := ts.GraphQLProvider.Login(ctx, &model.LoginRequest{Email: &email, Password: "Password@123"}) + require.NoError(t, err) + require.Nil(t, loginRes.AccessToken) + assert.True(t, refs.BoolValue(loginRes.ShouldShowEmailOtpScreen), "email-OTP is still challenged") + assert.True(t, refs.BoolValue(loginRes.ShouldOfferWebauthnMfaVerify), "the enrolled passkey must be offered as an alternative to email-OTP") +} + +// TestWebauthnLoginVerifyAsSecondFactorSMSOTPEnrolled mirrors the email-OTP +// test above for the SMS-OTP branch. +func TestWebauthnLoginVerifyAsSecondFactorSMSOTPEnrolled(t *testing.T) { + cfg := getTestConfig() + cfg.EnableWebauthnMFA = true + cfg.EnableSMSOTP = true + ts := initTestSetup(t, cfg) + + user, _, _, _ := 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.EnvKeySMSOTPAuthenticator, VerifiedAt: &now, + }) + require.NoError(t, err) + + cfg.EnableMFA = true + + email := refs.StringValue(user.Email) + _, ctx := createContext(ts) + + loginRes, err := ts.GraphQLProvider.Login(ctx, &model.LoginRequest{Email: &email, Password: "Password@123"}) + require.NoError(t, err) + require.Nil(t, loginRes.AccessToken) + assert.True(t, refs.BoolValue(loginRes.ShouldShowMobileOtpScreen), "SMS-OTP is still challenged") + assert.True(t, refs.BoolValue(loginRes.ShouldOfferWebauthnMfaVerify), "the enrolled passkey must be offered as an alternative to SMS-OTP") } diff --git a/internal/integration_tests/webauthn_mfa_setup_test.go b/internal/integration_tests/webauthn_mfa_setup_test.go new file mode 100644 index 00000000..e4d5d15f --- /dev/null +++ b/internal/integration_tests/webauthn_mfa_setup_test.go @@ -0,0 +1,235 @@ +package integration_tests + +import ( + "errors" + "fmt" + "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/service" + "github.com/authorizerdev/authorizer/internal/storage/schemas" +) + +// TestWebauthnRegistrationMfaSessionSetup covers registering a passkey during +// a token-withheld MFA offer (mfaGateOfferAll) via the MFA session cookie +// instead of a bearer token: it must complete the gate and issue the +// previously-withheld token, exactly like totp_mfa_setup + +// verify_otp(is_totp: true) does for TOTP. It also guards the security +// boundary this path adds: a caller who only proved a password (Verified +// session) must never be able to mint a brand-new passkey to skip challenging +// an EXISTING verified second factor (mfaGateBlockVerify). +func TestWebauthnRegistrationMfaSessionSetup(t *testing.T) { + const password = "Password@123" + + t.Run("registers via MFA session, issues the withheld token, and quiets a later login", func(t *testing.T) { + cfg := getTestConfig() + cfg.EnableMFA = true + cfg.EnableWebauthnMFA = true + ts := initTestSetup(t, cfg) + req, ctx := createContext(ts) + rp := testRelyingParty(t, ts) + credential := virtualwebauthn.NewCredential(virtualwebauthn.KeyTypeEC2) + + email := "webauthn_mfa_setup_" + 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.Nil(t, loginRes.AccessToken, "first login with optional MFA and no prior enrollment/skip must withhold the token") + require.True(t, refs.BoolValue(loginRes.ShouldOfferWebauthnMfaSetup)) + + // No Authorization header at any point in this test — proves the + // registration ceremony below authenticates via the MFA session cookie + // alone, not a bearer token. + mfaSession := latestMfaSessionCookie(ts) + require.NotEmpty(t, mfaSession, "login must have set an mfa session cookie on the response") + req.Header.Set("Cookie", fmt.Sprintf("%s=%s", constants.MfaCookieName+"_session", mfaSession)) + + optRes, err := ts.GraphQLProvider.WebauthnRegistrationOptions(ctx, &email, 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) + + verifyRes, err := ts.GraphQLProvider.WebauthnRegistrationVerify(ctx, &model.WebauthnRegistrationVerifyRequest{ + Credential: attResp, + Email: &email, + }) + require.NoError(t, err) + require.NotNil(t, verifyRes) + require.NotNil(t, verifyRes.AccessToken, "registering a passkey mid-offer must issue the token that was withheld at login") + assert.NotEmpty(t, *verifyRes.AccessToken) + + updated, err := ts.StorageProvider.GetUserByEmail(ctx, email) + require.NoError(t, err) + creds, err := ts.StorageProvider.ListWebauthnCredentialsByUserID(ctx, updated.ID) + require.NoError(t, err) + assert.Len(t, creds, 1, "the credential must be persisted, not just the token issued") + + // Unlike skip_mfa_setup (which only records a timestamp), the passkey + // just registered is a real second factor: authenticatorVerified is now + // true, so login.go correctly moves the user from mfaGateOfferAll to + // mfaGateBlockVerify and challenges it on the next login rather than + // issuing a token outright — proof the credential was actually + // persisted, not just the withheld token released once. + secondLogin, err := ts.GraphQLProvider.Login(ctx, &model.LoginRequest{Email: &email, Password: password}) + require.NoError(t, err) + require.Nil(t, secondLogin.AccessToken, "a real second factor was just enrolled; the next login must challenge it, not skip straight to a token") + assert.True(t, refs.BoolValue(secondLogin.ShouldOfferWebauthnMfaVerify), "the newly-registered passkey must be offered as the verify method on the next login") + }) + + t.Run("rejects with FailedPrecondition when the user already has a verified authenticator", func(t *testing.T) { + cfg := getTestConfig() + cfg.EnableMFA = true + cfg.EnableWebauthnMFA = true + cfg.EnableTOTPLogin = true + ts := initTestSetup(t, cfg) + req, ctx := createContext(ts) + + email := "webauthn_mfa_setup_blocked_" + 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) + + // A verified TOTP authenticator puts the user in mfaGateBlockVerify — + // their own opted-in second factor. A caller who only proved a password + // (Verified session, first factor only) must not be able to mint a + // brand-new passkey to bypass challenging it. + _, err = ts.StorageProvider.AddAuthenticator(ctx, &schemas.Authenticator{ + UserID: user.ID, + Method: constants.EnvKeyTOTPAuthenticator, + Secret: "test-secret", + VerifiedAt: &now, + }) + require.NoError(t, err) + + mfaSession := uuid.NewString() + require.NoError(t, ts.MemoryStoreProvider.SetMfaSession(user.ID, mfaSession, constants.MFASessionPurposeVerified, time.Now().Add(5*time.Minute).Unix())) + req.Header.Set("Cookie", fmt.Sprintf("%s=%s", constants.MfaCookieName+"_session", mfaSession)) + + _, err = ts.GraphQLProvider.WebauthnRegistrationOptions(ctx, &email, nil) + require.Error(t, err) + + 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, "a user with a verified second factor must not be able to enroll a new passkey to bypass it") + + creds, err := ts.StorageProvider.ListWebauthnCredentialsByUserID(ctx, user.ID) + require.NoError(t, err) + assert.Len(t, creds, 0, "a rejected attempt must not have registered anything") + }) + + t.Run("rejects with Unauthenticated when caller has no valid mfa session and no bearer token", func(t *testing.T) { + cfg := getTestConfig() + cfg.EnableMFA = true + cfg.EnableWebauthnMFA = true + ts := initTestSetup(t, cfg) + _, ctx := createContext(ts) + + email := "webauthn_mfa_setup_nosession_" + uuid.NewString() + "@authorizer.dev" + now := time.Now().Unix() + _, err := ts.StorageProvider.AddUser(ctx, &schemas.User{ + Email: refs.NewStringRef(email), + EmailVerifiedAt: &now, + SignupMethods: constants.AuthRecipeMethodBasicAuth, + IsMultiFactorAuthEnabled: refs.NewBoolRef(true), + }) + require.NoError(t, err) + + _, err = ts.GraphQLProvider.WebauthnRegistrationOptions(ctx, &email, nil) + require.Error(t, err) + + 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) + }) + + t.Run("rejects a Challenge session (ResendOTP/ForgotPassword) with Unauthenticated", func(t *testing.T) { + cfg := getTestConfig() + cfg.EnableMFA = true + cfg.EnableWebauthnMFA = true + ts := initTestSetup(t, cfg) + req, ctx := createContext(ts) + + email := "webauthn_mfa_setup_challenge_" + 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) + + // A Challenge session (no first factor proven) must never be tradeable + // for a registered credential, same as it can never be traded for a + // token via SkipMFASetup/VerifyOTP. + mfaSession := uuid.NewString() + require.NoError(t, ts.MemoryStoreProvider.SetMfaSession(user.ID, mfaSession, constants.MFASessionPurposeChallenge, time.Now().Add(5*time.Minute).Unix())) + req.Header.Set("Cookie", fmt.Sprintf("%s=%s", constants.MfaCookieName+"_session", mfaSession)) + + _, err = ts.GraphQLProvider.WebauthnRegistrationOptions(ctx, &email, nil) + require.Error(t, err) + + 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 Challenge session must be rejected like a missing session") + }) + + t.Run("settings-page bearer-token registration is unaffected: AuthResponse carries no access_token", func(t *testing.T) { + cfg := getTestConfig() + ts := initTestSetup(t, cfg) + req, ctx := createContext(ts) + rp := testRelyingParty(t, ts) + credential := virtualwebauthn.NewCredential(virtualwebauthn.KeyTypeEC2) + + email := "webauthn_settings_page_" + uuid.NewString() + "@authorizer.dev" + 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, 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) + + verifyRes, err := ts.GraphQLProvider.WebauthnRegistrationVerify(ctx, &model.WebauthnRegistrationVerifyRequest{Credential: attResp}) + require.NoError(t, err) + require.NotNil(t, verifyRes) + assert.Nil(t, verifyRes.AccessToken, "an already-authenticated settings-page caller has a token already; this path must not mint a second one") + assert.NotEmpty(t, verifyRes.Message) + }) +} diff --git a/internal/integration_tests/webauthn_test.go b/internal/integration_tests/webauthn_test.go index b95132f2..4542a44b 100644 --- a/internal/integration_tests/webauthn_test.go +++ b/internal/integration_tests/webauthn_test.go @@ -62,7 +62,7 @@ func TestWebauthnPasskeyRegistrationAndLogin(t *testing.T) { var credentialID string t.Run("register a passkey for the authenticated user", func(t *testing.T) { - optRes, err := ts.GraphQLProvider.WebauthnRegistrationOptions(ctx, nil) + optRes, err := ts.GraphQLProvider.WebauthnRegistrationOptions(ctx, nil, nil) require.NoError(t, err) attOpts, err := virtualwebauthn.ParseAttestationOptions(optRes.Options) require.NoError(t, err) @@ -105,6 +105,7 @@ func TestWebauthnPasskeyRegistrationAndLogin(t *testing.T) { require.NotNil(t, signupRes.User, "test needs the signed-up user's id to arm a real MFA session") mfaSession := uuid.New().String() require.NoError(t, ts.MemoryStoreProvider.SetMfaSession(signupRes.User.ID, mfaSession, + constants.MFASessionPurposeVerified, time.Now().Add(5*time.Minute).Unix())) req.Header.Set("Cookie", fmt.Sprintf("%s=%s", constants.MfaCookieName+"_session", mfaSession)) @@ -169,7 +170,7 @@ func TestWebauthnLoginRequiresVerifiedEmail(t *testing.T) { // Register a passkey while the account is (test-config default) verified, // then flip the account back to unverified directly in storage to isolate // the login-time gate from signup/verification-flow plumbing. - optRes, err := ts.GraphQLProvider.WebauthnRegistrationOptions(ctx, nil) + optRes, err := ts.GraphQLProvider.WebauthnRegistrationOptions(ctx, nil, nil) require.NoError(t, err) attOpts, err := virtualwebauthn.ParseAttestationOptions(optRes.Options) require.NoError(t, err) @@ -228,7 +229,7 @@ func TestWebauthnLoginOptionsScopedRequiresMfaSession(t *testing.T) { require.NotNil(t, signupRes.AccessToken) req.Header.Set("Authorization", "Bearer "+*signupRes.AccessToken) - optRes, err := ts.GraphQLProvider.WebauthnRegistrationOptions(ctx, nil) + optRes, err := ts.GraphQLProvider.WebauthnRegistrationOptions(ctx, nil, nil) require.NoError(t, err) attOpts, err := virtualwebauthn.ParseAttestationOptions(optRes.Options) require.NoError(t, err) @@ -262,6 +263,7 @@ func TestWebauthnLoginOptionsScopedRequiresMfaSession(t *testing.T) { otherUserID := uuid.New().String() mfaSession := uuid.New().String() require.NoError(t, ts.MemoryStoreProvider.SetMfaSession(otherUserID, mfaSession, + constants.MFASessionPurposeVerified, time.Now().Add(5*time.Minute).Unix())) req.Header.Set("Cookie", fmt.Sprintf("%s=%s", constants.MfaCookieName+"_session", mfaSession)) diff --git a/internal/memory_store/db/provider.go b/internal/memory_store/db/provider.go index ca683603..80f4db5d 100644 --- a/internal/memory_store/db/provider.go +++ b/internal/memory_store/db/provider.go @@ -3,6 +3,7 @@ package db import ( "context" "fmt" + "strings" "time" "github.com/google/uuid" @@ -14,6 +15,13 @@ import ( "github.com/authorizerdev/authorizer/internal/storage/schemas" ) +// mfaPurposeSeparator joins the session key and its purpose inside the +// persisted KeyName. The MFASession schema has no dedicated purpose column and +// adding one would touch every DB provider (cassandra uses an explicit column +// list), so the purpose rides along in KeyName as "::". The key +// is a UUID, which never contains "::", so the split is unambiguous. +const mfaPurposeSeparator = "::" + // Dependencies struct for db store provider type Dependencies struct { Log *zerolog.Logger @@ -129,13 +137,15 @@ func (p *provider) DeleteSessionForNamespace(namespace string) error { return p.deleteSessionTokensByNamespace(ctx, namespace) } -// SetMfaSession sets the mfa session with key and value of userId -func (p *provider) SetMfaSession(userId, key string, expiration int64) error { +// SetMfaSession sets the mfa session, storing purpose in the persisted KeyName +// alongside the key (see mfaPurposeSeparator). +func (p *provider) SetMfaSession(userId, key, purpose string, expiration int64) error { ctx := context.Background() + storedKey := key + mfaPurposeSeparator + purpose mfaSession := &schemas.MFASession{ ID: uuid.New().String(), UserID: userId, - KeyName: key, + KeyName: storedKey, ExpiresAt: expiration, CreatedAt: time.Now().Unix(), UpdatedAt: time.Now().Unix(), @@ -149,7 +159,7 @@ func (p *provider) SetMfaSession(userId, key string, expiration int64) error { } // Delete existing if any - err = p.deleteMFASessionByUserIDAndKey(ctx, userId, key) + err = p.deleteMFASessionByUserIDAndKey(ctx, userId, storedKey) if err != nil { p.dependencies.Log.Debug().Err(err).Msg("Error deleting existing MFA session") // Continue anyway @@ -162,7 +172,8 @@ func (p *provider) SetMfaSession(userId, key string, expiration int64) error { return nil } -// GetMfaSession returns value of given mfa session +// GetMfaSession returns the stored purpose of the given mfa session. The key is +// looked up against the "::" prefix of the persisted KeyName. func (p *provider) GetMfaSession(userId, key string) (string, error) { ctx := context.Background() @@ -172,20 +183,24 @@ func (p *provider) GetMfaSession(userId, key string) (string, error) { p.dependencies.Log.Debug().Err(err).Msg("Error cleaning expired MFA sessions") } - mfaSession, err := p.getMFASessionByUserIDAndKey(ctx, userId, key) + sessions, err := p.getAllMFASessionsByUserID(ctx, userId) if err != nil { return "", fmt.Errorf("not found") } - // Check expiration + prefix := key + mfaPurposeSeparator currentTime := time.Now().Unix() - if mfaSession.ExpiresAt < currentTime { - // Delete expired session - _ = p.deleteMFASession(ctx, mfaSession.ID) - return "", fmt.Errorf("not found") + for _, session := range sessions { + if !strings.HasPrefix(session.KeyName, prefix) { + continue + } + if session.ExpiresAt < currentTime { + _ = p.deleteMFASession(ctx, session.ID) + return "", fmt.Errorf("not found") + } + return strings.TrimPrefix(session.KeyName, prefix), nil } - - return mfaSession.UserID, nil + return "", fmt.Errorf("not found") } // GetAllMfaSessions returns all mfa sessions for given userId @@ -209,15 +224,56 @@ func (p *provider) GetAllMfaSessions(userId string) ([]string, error) { keys := make([]string, 0, len(sessions)) for _, session := range sessions { - keys = append(keys, session.KeyName) + k := session.KeyName + if idx := strings.Index(k, mfaPurposeSeparator); idx >= 0 { + k = k[:idx] + } + keys = append(keys, k) } return keys, nil } -// DeleteMfaSession deletes given mfa session from in-memory store. +// DeleteMfaSession deletes given mfa session from the store. KeyName carries a +// "::" suffix, so match by prefix rather than exact key. func (p *provider) DeleteMfaSession(userId, key string) error { ctx := context.Background() - return p.deleteMFASessionByUserIDAndKey(ctx, userId, key) + sessions, err := p.getAllMFASessionsByUserID(ctx, userId) + if err != nil { + return nil + } + prefix := key + mfaPurposeSeparator + for _, session := range sessions { + if strings.HasPrefix(session.KeyName, prefix) { + _ = p.deleteMFASession(ctx, session.ID) + } + } + return nil +} + +// GetMfaSessionOwner resolves the userID and purpose for a bare mfa session +// key, without knowing the owning userID. The persisted KeyName carries a +// "::" form, so match on the "::" prefix. +func (p *provider) GetMfaSessionOwner(key string) (string, string, error) { + ctx := context.Background() + + // Clean expired entries first + err := p.cleanExpiredMFASessions(ctx) + if err != nil { + p.dependencies.Log.Debug().Err(err).Msg("Error cleaning expired MFA sessions") + } + + sessions, err := p.getAllMFASessions(ctx) + if err != nil { + return "", "", fmt.Errorf("not found") + } + + prefix := key + mfaPurposeSeparator + for _, session := range sessions { + if strings.HasPrefix(session.KeyName, prefix) { + return session.UserID, strings.TrimPrefix(session.KeyName, prefix), nil + } + } + return "", "", fmt.Errorf("not found") } // SetState sets the login state (key, value form) in the session store @@ -364,10 +420,6 @@ func (p *provider) addMFASession(ctx context.Context, session *schemas.MFASessio return p.storageProvider.AddMFASession(ctx, session) } -func (p *provider) getMFASessionByUserIDAndKey(ctx context.Context, userId, key string) (*schemas.MFASession, error) { - return p.storageProvider.GetMFASessionByUserIDAndKey(ctx, userId, key) -} - func (p *provider) deleteMFASession(ctx context.Context, id string) error { return p.storageProvider.DeleteMFASession(ctx, id) } diff --git a/internal/memory_store/db/provider_test.go b/internal/memory_store/db/provider_test.go index a5a43140..7eaf0a70 100644 --- a/internal/memory_store/db/provider_test.go +++ b/internal/memory_store/db/provider_test.go @@ -89,12 +89,32 @@ func TestDBMemoryStoreProvider(t *testing.T) { assert.Empty(t, key) assert.Error(t, err) - err = p.SetMfaSession("auth_provider:123", "session123", time.Now().Add(60*time.Second).Unix()) + err = p.SetMfaSession("auth_provider:123", "session123", "test-purpose", time.Now().Add(60*time.Second).Unix()) assert.NoError(t, err) key, err = p.GetMfaSession("auth_provider:123", "session123") assert.NoError(t, err) - assert.Equal(t, "auth_provider:123", key) + assert.Equal(t, "test-purpose", key) + + // GetMfaSessionOwner resolves the owning userID and purpose from a + // bare session key, without the caller knowing the userID. + ownerID, ownerPurpose, err := p.GetMfaSessionOwner("session123") + assert.NoError(t, err) + assert.Equal(t, "auth_provider:123", ownerID) + assert.Equal(t, "test-purpose", ownerPurpose) + // Unknown session key is not found. + ownerID, ownerPurpose, err = p.GetMfaSessionOwner("does-not-exist") + assert.Error(t, err) + assert.Empty(t, ownerID) + assert.Empty(t, ownerPurpose) + + // Expired session is cleaned and not resolvable. + err = p.SetMfaSession("auth_provider:999", "expiredsession", "test-purpose", time.Now().Add(-60*time.Second).Unix()) + assert.NoError(t, err) + ownerID, ownerPurpose, err = p.GetMfaSessionOwner("expiredsession") + assert.Error(t, err) + assert.Empty(t, ownerID) + assert.Empty(t, ownerPurpose) err = p.DeleteMfaSession("auth_provider:123", "session123") assert.NoError(t, err) diff --git a/internal/memory_store/in_memory/store.go b/internal/memory_store/in_memory/store.go index ab03cec3..b2ae5250 100644 --- a/internal/memory_store/in_memory/store.go +++ b/internal/memory_store/in_memory/store.go @@ -47,9 +47,9 @@ func (c *provider) DeleteSessionForNamespace(namespace string) error { return nil } -// SetMfaSession sets the mfa session with key and value of userId -func (c *provider) SetMfaSession(userId, key string, expiration int64) error { - c.mfasessionStore.Set(userId, key, userId, expiration) +// SetMfaSession sets the mfa session, storing purpose as its value. +func (c *provider) SetMfaSession(userId, key, purpose string, expiration int64) error { + c.mfasessionStore.Set(userId, key, purpose, expiration) return nil } @@ -77,6 +77,15 @@ func (c *provider) DeleteMfaSession(userId, key string) error { return nil } +// GetMfaSessionOwner resolves the userID and purpose for a bare mfa session key. +func (c *provider) GetMfaSessionOwner(key string) (string, string, error) { + ownerKey, value, found := c.mfasessionStore.FindOwnerBySuffix(key) + if !found { + return "", "", fmt.Errorf("not found") + } + return ownerKey, value, nil +} + // SetState sets the state in the in-memory store. func (c *provider) SetState(key, state string) error { c.mutex.Lock() diff --git a/internal/memory_store/in_memory/stores/session_store.go b/internal/memory_store/in_memory/stores/session_store.go index 00279ba6..4dbd0439 100644 --- a/internal/memory_store/in_memory/stores/session_store.go +++ b/internal/memory_store/in_memory/stores/session_store.go @@ -105,6 +105,28 @@ func (s *SessionStore) GetAll(key string) []string { return values } +// FindOwnerBySuffix scans for any stored key ending in ":" and returns +// the owner portion (the userID prefix) and its value (the purpose). Used to +// resolve an mfa session by its bare session key when the caller does not know +// the owning userID. Expired entries are skipped (and deleted), same as Get. +func (s *SessionStore) FindOwnerBySuffix(subKey string) (ownerKey, value string, found bool) { + s.mutex.Lock() + defer s.mutex.Unlock() + currentTime := time.Now().Unix() + suffix := ":" + subKey + for k, v := range s.store { + if !strings.HasSuffix(k, suffix) { + continue + } + if v.ExpiresAt > currentTime { + return strings.TrimSuffix(k, suffix), v.Value, true + } + // Delete expired items + delete(s.store, k) + } + return "", "", false +} + // Set sets the value of the key in state store func (s *SessionStore) Set(key string, subKey, value string, expiration int64) { s.mutex.Lock() diff --git a/internal/memory_store/provider.go b/internal/memory_store/provider.go index 4b7c16d6..4c1ff0b9 100644 --- a/internal/memory_store/provider.go +++ b/internal/memory_store/provider.go @@ -48,14 +48,22 @@ type Provider interface { DeleteAllUserSessions(userId string) error // DeleteSessionForNamespace deletes the session for a given namespace DeleteSessionForNamespace(namespace string) error - // SetMfaSession sets the mfa session with key and value of userId - SetMfaSession(userId, key string, expiration int64) error - // GetMfaSession returns value of given mfa session + // SetMfaSession sets the mfa session, storing purpose as its value so a + // consumer can tell how the session was obtained (see + // constants.MFASessionPurpose*). + SetMfaSession(userId, key, purpose string, expiration int64) error + // GetMfaSession returns the stored purpose of the given mfa session GetMfaSession(userId, key string) (string, error) // GetAllMfaSessions returns all mfa sessions for given userId GetAllMfaSessions(userId string) ([]string, error) // DeleteMfaSession deletes given mfa session from in-memory store. DeleteMfaSession(userId, key string) error + // GetMfaSessionOwner resolves the userID and purpose for a bare mfa session + // key, without the caller already knowing the owning userID — used when a + // caller has a valid session cookie but no identifier (e.g. continuing an + // OAuth-originated MFA challenge, where the frontend never learns the + // account's email/phone). + GetMfaSessionOwner(key string) (userID, purpose string, err error) // SetState sets the login state (key, value form) in the session store SetState(key, state string) error diff --git a/internal/memory_store/provider_test.go b/internal/memory_store/provider_test.go index 1cb07010..e747d74f 100644 --- a/internal/memory_store/provider_test.go +++ b/internal/memory_store/provider_test.go @@ -171,16 +171,47 @@ func TestMemoryStoreProvider(t *testing.T) { assert.Empty(t, key) assert.Error(t, err) - err = p.SetMfaSession("auth_provider:123", "session123", time.Now().Add(60*time.Second).Unix()) + err = p.SetMfaSession("auth_provider:123", "session123", "test-purpose", time.Now().Add(60*time.Second).Unix()) assert.NoError(t, err) key, err = p.GetMfaSession("auth_provider:123", "session123") assert.NoError(t, err) - assert.Equal(t, "auth_provider:123", key) + assert.Equal(t, "test-purpose", key) + + // GetMfaSessionOwner resolves the owning userID and purpose from a + // bare session key, without the caller knowing the userID. + ownerID, ownerPurpose, err := p.GetMfaSessionOwner("session123") + assert.NoError(t, err) + assert.Equal(t, "auth_provider:123", ownerID) + assert.Equal(t, "test-purpose", ownerPurpose) + // Unknown session key is not found. + ownerID, ownerPurpose, err = p.GetMfaSessionOwner("does-not-exist") + assert.Error(t, err) + assert.Empty(t, ownerID) + assert.Empty(t, ownerPurpose) + err = p.DeleteMfaSession("auth_provider:123", "session123") assert.NoError(t, err) key, err = p.GetMfaSession("auth_provider:123", "session123") assert.Error(t, err) assert.Empty(t, key) + + // Deleted session is no longer resolvable by owner lookup either. + ownerID, ownerPurpose, err = p.GetMfaSessionOwner("session123") + assert.Error(t, err) + assert.Empty(t, ownerID) + assert.Empty(t, ownerPurpose) + + // Expired session is not resolvable. Redis rejects a negative TTL + // on Set, so this in-memory-only check exercises the same + // expiry-skip path the DB provider test covers deterministically. + if storeType == memoryStoreTypeInMemory { + err = p.SetMfaSession("auth_provider:999", "expiredsession", "test-purpose", time.Now().Add(-60*time.Second).Unix()) + assert.NoError(t, err) + ownerID, ownerPurpose, err = p.GetMfaSessionOwner("expiredsession") + assert.Error(t, err) + assert.Empty(t, ownerID) + assert.Empty(t, ownerPurpose) + } }) } } diff --git a/internal/memory_store/redis/store.go b/internal/memory_store/redis/store.go index 67760b59..6704e085 100644 --- a/internal/memory_store/redis/store.go +++ b/internal/memory_store/redis/store.go @@ -2,6 +2,7 @@ package redis import ( "fmt" + "strings" "time" "github.com/authorizerdev/authorizer/internal/constants" @@ -110,12 +111,12 @@ func (p *provider) DeleteSessionForNamespace(namespace string) error { return nil } -// SetMfaSession sets the mfa session with key and value of userId -func (p *provider) SetMfaSession(userId, key string, expiration int64) error { +// SetMfaSession sets the mfa session, storing purpose as its value. +func (p *provider) SetMfaSession(userId, key, purpose string, expiration int64) error { currentTime := time.Now() expireTime := time.Unix(expiration, 0) duration := expireTime.Sub(currentTime) - err := p.store.Set(p.ctx, fmt.Sprintf("%s%s:%s", mfaSessionPrefix, userId, key), userId, duration).Err() + err := p.store.Set(p.ctx, fmt.Sprintf("%s%s:%s", mfaSessionPrefix, userId, key), purpose, duration).Err() if err != nil { p.dependencies.Log.Debug().Err(err).Msg("Error saving mfa session to redis") return err @@ -155,6 +156,29 @@ func (p *provider) DeleteMfaSession(userId, key string) error { return nil } +// GetMfaSessionOwner resolves the userID and purpose for a bare mfa session +// key, without knowing the owning userID. Session keys are UUIDs (no colons), +// so the "*:" glob matches exactly one user's entry. +func (p *provider) GetMfaSessionOwner(key string) (string, string, error) { + res := p.store.Keys(p.ctx, fmt.Sprintf("%s*:%s", mfaSessionPrefix, key)) + if res.Err() != nil { + p.dependencies.Log.Debug().Err(res.Err()).Msg("Error getting mfa session owner from redis") + return "", "", res.Err() + } + keys := res.Val() + if len(keys) != 1 { + p.dependencies.Log.Debug().Int("matches", len(keys)).Msg("Expected exactly one mfa session owner match") + return "", "", fmt.Errorf("not found") + } + matchedKey := keys[0] + userID := strings.TrimSuffix(strings.TrimPrefix(matchedKey, mfaSessionPrefix), ":"+key) + purpose, err := p.store.Get(p.ctx, matchedKey).Result() + if err != nil { + return "", "", err + } + return userID, purpose, nil +} + // SetState sets the state in redis store. func (p *provider) SetState(key, value string) error { // OAuth state should be short-lived; keeping it forever leaks keys in Redis. diff --git a/internal/server/http_routes.go b/internal/server/http_routes.go index d9f16e16..6bb2cf50 100644 --- a/internal/server/http_routes.go +++ b/internal/server/http_routes.go @@ -13,15 +13,25 @@ import ( ) // spaBuildCacheMiddleware sets cache headers for SPA build assets: -// - "index.js" / "main.css" (unhashed entry points the shell HTML loads -// by name) → no-cache, so browsers always pick up new chunk references -// after a deploy. +// - the unhashed entry points the shell HTML loads by name → no-cache, so +// browsers always pick up new chunk references after a deploy. Shared +// across web/app and web/dashboard, whose Vite configs disagree on the +// entry CSS filename: web/dashboard emits main.css, web/app emits +// index.css (its assetFileNames names the CSS after the entry chunk, +// "index", not a fixed "main") — both must be listed, or the app whose +// name doesn't match falls into the immutable branch below and its CSS +// gets cached for a year past any style fix. // - everything else (content-hashed chunks, immutable assets) → long-lived // immutable cache, since a content change produces a new filename. func spaBuildCacheMiddleware() gin.HandlerFunc { + noCacheEntryFiles := map[string]bool{ + "index.js": true, + "index.css": true, + "main.css": true, + } return func(c *gin.Context) { base := path.Base(c.Request.URL.Path) - if base == "index.js" || base == "main.css" { + if noCacheEntryFiles[base] { c.Header("Cache-Control", "no-cache, must-revalidate") } else { c.Header("Cache-Control", "public, max-age=31536000, immutable") @@ -78,8 +88,13 @@ func (s *server) NewRouter() *gin.Engine { // OPEN ID routes router.GET("/.well-known/openid-configuration", s.Dependencies.HTTPProvider.OpenIDConfigurationHandler()) router.GET("/.well-known/jwks.json", s.Dependencies.HTTPProvider.JWKsHandler()) + // RFC 6749 §3.1 / OIDC Core §3.1.2.1: the authorization endpoint MUST + // support GET and MAY support POST. router.GET("/authorize", s.Dependencies.HTTPProvider.AuthorizeHandler()) + router.POST("/authorize", s.Dependencies.HTTPProvider.AuthorizeHandler()) + // OIDC Core §5.3.1: the UserInfo Endpoint MUST support both GET and POST. router.GET("/userinfo", s.Dependencies.HTTPProvider.UserInfoHandler()) + router.POST("/userinfo", s.Dependencies.HTTPProvider.UserInfoHandler()) router.GET("/logout", s.Dependencies.HTTPProvider.LogoutHandler()) router.POST("/logout", s.Dependencies.HTTPProvider.LogoutHandler()) router.POST("/oauth/token", s.Dependencies.HTTPProvider.TokenHandler()) diff --git a/internal/server/http_routes_test.go b/internal/server/http_routes_test.go new file mode 100644 index 00000000..3a556b64 --- /dev/null +++ b/internal/server/http_routes_test.go @@ -0,0 +1,52 @@ +package server + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" +) + +// TestSpaBuildCacheMiddleware guards the entry-file allowlist that decides +// whether a build asset gets a revalidate-on-every-load header or a +// year-long immutable cache. web/app and web/dashboard's Vite configs +// disagree on the entry CSS filename (index.css vs main.css) - a request +// path whose base name isn't in the allowlist silently falls into the +// immutable branch, so a real deploy fixing a style bug would leave any +// browser that already cached the old file broken for up to a year. +func TestSpaBuildCacheMiddleware(t *testing.T) { + gin.SetMode(gin.TestMode) + + cases := []struct { + path string + wantNoCache bool + }{ + {"/app/build/index.js", true}, + {"/app/build/index.css", true}, + {"/dashboard/build/index.js", true}, + {"/dashboard/build/main.css", true}, + {"/app/build/chunk-login-abc123.js", false}, + {"/app/build/assets/logo-def456.png", false}, + } + + for _, tc := range cases { + t.Run(tc.path, func(t *testing.T) { + r := gin.New() + r.Use(spaBuildCacheMiddleware()) + r.GET("/*path", func(c *gin.Context) { c.Status(http.StatusOK) }) + + req := httptest.NewRequest(http.MethodGet, tc.path, nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + cacheControl := w.Header().Get("Cache-Control") + if tc.wantNoCache { + assert.Equal(t, "no-cache, must-revalidate", cacheControl) + } else { + assert.Equal(t, "public, max-age=31536000, immutable", cacheControl) + } + }) + } +} diff --git a/internal/service/admin_organizations.go b/internal/service/admin_organizations.go index c4015e1d..81073b09 100644 --- a/internal/service/admin_organizations.go +++ b/internal/service/admin_organizations.go @@ -159,10 +159,16 @@ func (p *provider) DeleteOrganization(ctx context.Context, meta RequestMetadata, }, nil, nil } -// Organization returns a single organization by id. Requires super-admin auth. +// Organization returns a single organization by id. Requires super-admin, or +// org-admin of this specific org. func (p *provider) Organization(ctx context.Context, meta RequestMetadata, params *model.OrganizationRequest) (*model.Organization, *ResponseSideEffects, error) { log := p.Log.With().Str("func", "Organization").Logger() - if err := p.requireSuperAdmin(ctx, meta); err != nil { + // Super-admin or an org-admin of this specific org — same gating as + // OrgMembers/AddOrgMember, so an org-scoped admin can view their own + // org's detail page (the dashboard's OrganizationDetail view) without + // needing instance-wide super-admin rights. requireOrgAdmin rejects + // access to any OTHER org. + if err := p.requireOrgAdmin(ctx, meta, params.ID); err != nil { return nil, nil, err } diff --git a/internal/service/admin_users.go b/internal/service/admin_users.go index 525089d6..910e7c3f 100644 --- a/internal/service/admin_users.go +++ b/internal/service/admin_users.go @@ -30,7 +30,8 @@ import ( // MFA state that login would be unable to honor. func (p *provider) isMFAServiceAvailable() bool { c := p.Config - return c.EnableMFA && ((c.EnableEmailOTP && c.IsEmailServiceEnabled) || + return c.EnableMFA && (c.EnableWebauthnMFA || + (c.EnableEmailOTP && c.IsEmailServiceEnabled) || (c.EnableSMSOTP && c.IsSMSServiceEnabled) || c.EnableTOTPLogin) } @@ -124,6 +125,7 @@ func (p *provider) UpdateUser(ctx context.Context, meta RequestMetadata, params params.PhoneNumber == nil && params.Roles == nil && params.IsMultiFactorAuthEnabled == nil && + params.ResetMfa == nil && params.AppData == nil { log.Debug().Msg("please enter atleast one param to update") return nil, nil, fmt.Errorf("please enter atleast one param to update") @@ -180,6 +182,13 @@ func (p *provider) UpdateUser(ctx context.Context, meta RequestMetadata, params log.Debug().Msg("cannot enable multi factor authentication as no mfa method is available") return nil, nil, errors.New("cannot enable MFA: no MFA method is available on this server — ensure TOTP is enabled (do not set --disable-totp-login) or configure an email (SMTP) or SMS (Twilio) provider for OTP") } + // EnforceMFA is absolute: an admin must not be able to persist an opt-out + // while the org enforces MFA (same guard self-service update_profile.go + // already applies). + if p.Config.EnforceMFA && !refs.BoolValue(params.IsMultiFactorAuthEnabled) { + log.Debug().Msg("cannot disable multi factor authentication as it is enforced by organization") + return nil, nil, errors.New("cannot disable multi factor authentication as it is enforced by organization") + } user.IsMultiFactorAuthEnabled = params.IsMultiFactorAuthEnabled } @@ -308,6 +317,28 @@ func (p *provider) UpdateUser(ctx context.Context, meta RequestMetadata, params if rolesToSave != "" { user.Roles = rolesToSave } + + if refs.BoolValue(params.ResetMfa) { + user.MFALockedAt = nil + user.IsMultiFactorAuthEnabled = nil + user.HasSkippedMFASetupAt = nil + if err := p.StorageProvider.DeleteAuthenticatorsByUserID(ctx, user.ID); err != nil { + log.Debug().Err(err).Msg("failed to delete authenticators during MFA reset") + return nil, nil, err + } + creds, err := p.StorageProvider.ListWebauthnCredentialsByUserID(ctx, user.ID) + if err != nil { + log.Debug().Err(err).Msg("failed to list webauthn credentials during MFA reset") + return nil, nil, err + } + for _, c := range creds { + if err := p.StorageProvider.DeleteWebauthnCredential(ctx, c); err != nil { + log.Debug().Err(err).Msg("failed to delete webauthn credential during MFA reset") + return nil, nil, err + } + } + } + user, err = p.StorageProvider.UpdateUser(ctx, user) if err != nil { log.Debug().Err(err).Msg("failed UpdateUser") diff --git a/internal/service/clientauth/jwks_cache_test.go b/internal/service/clientauth/jwks_cache_test.go new file mode 100644 index 00000000..42ab432a --- /dev/null +++ b/internal/service/clientauth/jwks_cache_test.go @@ -0,0 +1,124 @@ +package clientauth + +// Tests for loadJWKS's caching contract (client_assertion.go): the H7 +// guarantee that two TrustedIssuer rows never share a cached JWKS even when +// they point at the same URL, that a cache hit avoids a second fetch, and +// that an expired/evicted entry triggers a refetch. The rest of this +// package's tests cover assertion-verification logic but never assert +// anything about the cache itself. + +import ( + "context" + "testing" + + "github.com/rs/zerolog" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/authorizerdev/authorizer/internal/config" + "github.com/authorizerdev/authorizer/internal/constants" + "github.com/authorizerdev/authorizer/internal/storage/schemas" +) + +// countingFetchURL wraps a fixed JWKS payload in a fetchURL stub that counts +// every invocation, so a test can distinguish a cache hit from a real fetch. +func countingFetchURL(jwks []byte) (fn func(ctx context.Context, rawURL string) ([]byte, error), calls *int) { + n := 0 + return func(_ context.Context, _ string) ([]byte, error) { + n++ + return jwks, nil + }, &n +} + +// jwksProvider builds a bare clientauth provider wired only with the two +// fields loadJWKS touches: MemoryStoreProvider and fetchURL. No +// StorageProvider is needed — loadJWKS never calls it. +func jwksProvider(t *testing.T, fetchURL func(ctx context.Context, rawURL string) ([]byte, error)) *provider { + t.Helper() + logger := zerolog.Nop() + p := New( + &config.Config{ClientID: "reserved", ClientSecret: "reserved-secret"}, + &Dependencies{Log: &logger, MemoryStoreProvider: newFakeMemStore()}, + ).(*provider) + p.fetchURL = fetchURL + return p +} + +func staticIssuer(id, jwksURL string) *schemas.TrustedIssuer { + return &schemas.TrustedIssuer{ + ID: id, + KeySourceType: constants.KeySourceStaticJWKSURL, + JWKSUrl: refString(jwksURL), + } +} + +func TestJWKSCache_PerIssuerIsolation(t *testing.T) { + key := genKey(t) + jwks := jwksBytes(t, &key.PublicKey, testKID) + fetchURL, calls := countingFetchURL(jwks) + p := jwksProvider(t, fetchURL) + + const sharedURL = "https://issuer.test.example.com/jwks.json" + issuerA := staticIssuer("issuer-a", sharedURL) + issuerB := staticIssuer("issuer-b", sharedURL) + + _, err := p.loadJWKS(context.Background(), issuerA) + require.NoError(t, err) + _, err = p.loadJWKS(context.Background(), issuerB) + require.NoError(t, err) + + // A cache key derived from the URL alone would let issuerB's lookup hit + // issuerA's cache entry (an H7 violation). Two rows sharing a JWKS URL must + // each fetch on their own first load. + assert.Equal(t, 2, *calls, "two distinct TrustedIssuer rows sharing a JWKS URL must each trigger their own fetch") + + store := p.MemoryStoreProvider.(*fakeMemStore) + store.mu.Lock() + _, hasA := store.cache["jwks_cache:issuer-a"] + _, hasB := store.cache["jwks_cache:issuer-b"] + store.mu.Unlock() + assert.True(t, hasA, "issuer-a must have its own cache entry") + assert.True(t, hasB, "issuer-b must have its own cache entry") +} + +func TestJWKSCache_HitAvoidsRefetch(t *testing.T) { + key := genKey(t) + jwks := jwksBytes(t, &key.PublicKey, testKID) + fetchURL, calls := countingFetchURL(jwks) + p := jwksProvider(t, fetchURL) + + issuer := staticIssuer("issuer-a", "https://issuer.test.example.com/jwks.json") + + _, err := p.loadJWKS(context.Background(), issuer) + require.NoError(t, err) + _, err = p.loadJWKS(context.Background(), issuer) + require.NoError(t, err) + + assert.Equal(t, 1, *calls, "a second load within the TTL must be served from cache, not refetched") +} + +func TestJWKSCache_RefetchesAfterEviction(t *testing.T) { + key := genKey(t) + jwks := jwksBytes(t, &key.PublicKey, testKID) + fetchURL, calls := countingFetchURL(jwks) + p := jwksProvider(t, fetchURL) + + issuer := staticIssuer("issuer-a", "https://issuer.test.example.com/jwks.json") + + _, err := p.loadJWKS(context.Background(), issuer) + require.NoError(t, err) + require.Equal(t, 1, *calls) + + // jwksCacheTTLSeconds is a hardcoded package const with no test seam to + // shorten it (finding, not a bug — see task report). Expiry is simulated by + // evicting the entry directly from the fake store, exactly what a real TTL + // store does once the deadline passes. + store := p.MemoryStoreProvider.(*fakeMemStore) + store.mu.Lock() + delete(store.cache, "jwks_cache:issuer-a") + store.mu.Unlock() + + _, err = p.loadJWKS(context.Background(), issuer) + require.NoError(t, err) + assert.Equal(t, 2, *calls, "loadJWKS must refetch once the cache entry has expired/evicted") +} diff --git a/internal/service/forgot_password.go b/internal/service/forgot_password.go index 55065df1..64b34a28 100644 --- a/internal/service/forgot_password.go +++ b/internal/service/forgot_password.go @@ -160,12 +160,14 @@ func (p *provider) ForgotPassword(ctx context.Context, meta RequestMetadata, par return nil, nil, err } mfaSession := uuid.NewString() - err = p.MemoryStoreProvider.SetMfaSession(user.ID, mfaSession, expiresAt) + // Reached with only a phone number and no first factor, so this session + // is a Challenge — it can never skip MFA setup or lock the account. + err = p.MemoryStoreProvider.SetMfaSession(user.ID, mfaSession, constants.MFASessionPurposeChallenge, expiresAt) if err != nil { log.Debug().Err(err).Msg("Failed to set mfa session") return nil, nil, err } - for _, c := range cookie.BuildMfaSessionCookies(hostname, mfaSession, p.Config.AppCookieSecure) { + for _, c := range cookie.BuildMfaSessionCookies(hostname, mfaSession, p.Config.AppCookieSecure, expiresAt) { side.AddCookie(c) } smsBody := strings.Builder{} diff --git a/internal/service/lock_mfa.go b/internal/service/lock_mfa.go new file mode 100644 index 00000000..ff6fba5e --- /dev/null +++ b/internal/service/lock_mfa.go @@ -0,0 +1,115 @@ +// internal/service/lock_mfa.go +package service + +import ( + "context" + "strings" + "time" + + "github.com/gin-gonic/gin" + + "github.com/authorizerdev/authorizer/internal/audit" + "github.com/authorizerdev/authorizer/internal/constants" + "github.com/authorizerdev/authorizer/internal/cookie" + "github.com/authorizerdev/authorizer/internal/graph/model" + "github.com/authorizerdev/authorizer/internal/refs" + "github.com/authorizerdev/authorizer/internal/storage/schemas" +) + +// LockMFA marks the user identified by the current MFA session as locked: +// they have no working path to complete MFA verification and must contact +// an admin. Permissions: none — identified via the MFA session cookie plus +// email/phone_number, same as SkipMFASetup/VerifyOTP. +func (p *provider) LockMFA(ctx context.Context, meta RequestMetadata, params *model.LockMfaRequest) (*model.Response, *ResponseSideEffects, error) { + log := p.Log.With().Str("func", "LockMFA").Logger() + + gc := &gin.Context{Request: meta.Request} + mfaSession, err := cookie.GetMfaSession(gc) + if err != nil { + log.Debug().Err(err).Msg("Failed to get mfa session") + return nil, nil, Unauthenticated(`invalid session`) + } + + email := strings.TrimSpace(refs.StringValue(params.Email)) + phoneNumber := strings.TrimSpace(refs.StringValue(params.PhoneNumber)) + + var user *schemas.User + if email == "" && phoneNumber == "" { + // No identifier supplied (OAuth-return MFA continuation): resolve the + // account from the session cookie alone. Ownership plus a Verified + // purpose prove the first factor, exactly as the GetMfaSession + + // purpose check does on the identifier-supplied path below. + ownerID, purpose, oErr := p.MemoryStoreProvider.GetMfaSessionOwner(mfaSession) + if oErr != nil || purpose != constants.MFASessionPurposeVerified { + log.Debug().Err(oErr).Msg("Failed to resolve mfa session owner") + return nil, nil, Unauthenticated(`invalid session`) + } + user, err = p.StorageProvider.GetUserByID(ctx, ownerID) + if user == nil || err != nil { + log.Debug().Err(err).Msg("Failed to resolve user from mfa session") + return nil, nil, Unauthenticated(`invalid session`) + } + } else { + if email != "" { + user, err = p.StorageProvider.GetUserByEmail(ctx, email) + } else { + user, err = p.StorageProvider.GetUserByPhoneNumber(ctx, phoneNumber) + } + if user == nil || err != nil { + log.Debug().Err(err).Msg("User not found") + return nil, nil, NotFound("invalid request") + } + + // A bare session must not lock an account. Require a Verified session + // (first factor completed for THIS user); a Challenge session + // (ResendOTP/ForgotPassword) is rejected with the same shape as a missing + // one, closing an unauthenticated account-lockout DoS. + purpose, err := p.MemoryStoreProvider.GetMfaSession(user.ID, mfaSession) + if err != nil || purpose != constants.MFASessionPurposeVerified { + log.Debug().Err(err).Msg("Failed to get mfa session") + return nil, nil, Unauthenticated(`invalid session`) + } + } + + if p.hasVerifiedOTPFallback(ctx, user.ID) { + log.Debug().Msg("User has a verified OTP fallback, refusing to lock") + return nil, nil, FailedPrecondition("a verified email or SMS OTP fallback is available — use it instead of locking your account") + } + + now := time.Now().Unix() + user.MFALockedAt = &now + if _, err := p.StorageProvider.UpdateUser(ctx, user); err != nil { + log.Debug().Err(err).Msg("Failed to update user") + return nil, nil, err + } + // Single-use: drop the session so a captured cookie cannot be replayed. + _ = p.MemoryStoreProvider.DeleteMfaSession(user.ID, mfaSession) + + p.AuditProvider.LogEvent(audit.Event{ + Action: constants.AuditMFALockedEvent, + Protocol: meta.Protocol, ActorID: user.ID, + ActorType: constants.AuditActorTypeUser, + ActorEmail: refs.StringValue(user.Email), + ResourceType: constants.AuditResourceTypeUser, + ResourceID: user.ID, + IPAddress: meta.IPAddress, + UserAgent: meta.UserAgent, + }) + + return &model.Response{Message: "Your account is locked. Contact your administrator to regain access."}, nil, nil +} + +// hasVerifiedOTPFallback reports whether userID has a verified Email-OTP or +// SMS-OTP MFA enrollment (constants.EnvKeyEmailOTPAuthenticator / +// constants.EnvKeySMSOTPAuthenticator) — the one case where locking is +// refused because a working recovery path already exists and should be used +// instead. +func (p *provider) hasVerifiedOTPFallback(ctx context.Context, userID string) bool { + for _, method := range []string{constants.EnvKeyEmailOTPAuthenticator, constants.EnvKeySMSOTPAuthenticator} { + a, err := p.StorageProvider.GetAuthenticatorDetailsByUserId(ctx, userID, method) + if err == nil && a != nil && a.VerifiedAt != nil { + return true + } + } + return false +} diff --git a/internal/service/login.go b/internal/service/login.go index b19eb237..5c5864b1 100644 --- a/internal/service/login.go +++ b/internal/service/login.go @@ -14,7 +14,6 @@ import ( "github.com/authorizerdev/authorizer/internal/audit" "github.com/authorizerdev/authorizer/internal/constants" "github.com/authorizerdev/authorizer/internal/cookie" - "github.com/authorizerdev/authorizer/internal/crypto" "github.com/authorizerdev/authorizer/internal/graph/model" "github.com/authorizerdev/authorizer/internal/metrics" "github.com/authorizerdev/authorizer/internal/refs" @@ -38,10 +37,13 @@ const loginGenericErrMsg = "invalid credentials" // 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 { + // Every caller of this helper (login, webauthn-verify, oauth callback) has + // already confirmed a first factor for userID, so the session is Verified — + // the only purpose skip_mfa_setup/lock_mfa will act on. + if err := p.MemoryStoreProvider.SetMfaSession(userID, mfaSession, constants.MFASessionPurposeVerified, expiresAt); err != nil { return err } - for _, c := range cookie.BuildMfaSessionCookies(meta.HostURL, mfaSession, p.Config.AppCookieSecure) { + for _, c := range cookie.BuildMfaSessionCookies(meta.HostURL, mfaSession, p.Config.AppCookieSecure, expiresAt) { side.AddCookie(c) } return nil @@ -70,7 +72,7 @@ func loginPerformDummyPasswordCheck(password string) { // 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. +// mfaGateOfferAll (optional) paths of the TOTP MFA branch below. type totpEnrollment struct { ScannerImage string Secret string @@ -153,32 +155,6 @@ func (p *provider) Login(ctx context.Context, meta RequestMetadata, params *mode } isEmailServiceEnabled := p.Config.IsEmailServiceEnabled isSMSServiceEnabled := p.Config.IsSMSServiceEnabled - // If multi factor authentication is enabled and we need to generate OTP for mail / sms based MFA - generateOTP := func(expiresAt int64) (*schemas.OTP, error) { - otp, err := utils.GenerateOTP() - if err != nil { - log.Debug().Err(err).Msg("Failed to generate OTP") - return nil, err - } - // Store the HMAC digest (defence-in-depth: an offline DB dump no - // longer reveals usable codes). The plaintext is held in the - // returned struct's Otp field for the caller's email/SMS body. - otpData, err := p.StorageProvider.UpsertOTP(ctx, &schemas.OTP{ - Email: refs.StringValue(user.Email), - PhoneNumber: refs.StringValue(user.PhoneNumber), - Otp: crypto.HashOTP(otp, p.Config.JWTSecret), - ExpiresAt: expiresAt, - }) - if err != nil { - log.Debug().Msg("Failed to upsert otp") - return nil, err - } - // Replace the persisted hash with the plaintext on the returned - // struct so the caller can read otpData.Otp for email/SMS without - // having to thread two values through the closure. - otpData.Otp = otp - return otpData, nil - } if isEmailLogin { if !strings.Contains(user.SignupMethods, constants.AuthRecipeMethodBasicAuth) { log.Debug().Str("reason", "wrong_signup_method").Msg("login failed") @@ -211,7 +187,7 @@ func (p *provider) Login(ctx context.Context, meta RequestMetadata, params *mode } } expiresAt := time.Now().Add(1 * time.Minute).Unix() - otpData, err := generateOTP(expiresAt) + otpData, err := p.generateAndStoreOTP(ctx, user, expiresAt) if err != nil { log.Debug().Msg("Failed to generate otp") return nil, nil, err @@ -252,7 +228,7 @@ func (p *provider) Login(ctx context.Context, meta RequestMetadata, params *mode return nil, nil, Unauthenticated(loginGenericErrMsg) } else { expiresAt := time.Now().Add(1 * time.Minute).Unix() - otpData, err := generateOTP(expiresAt) + otpData, err := p.generateAndStoreOTP(ctx, user, expiresAt) if err != nil { log.Debug().Msg("Failed to generate otp") return nil, nil, err @@ -322,10 +298,36 @@ func (p *provider) Login(ctx context.Context, meta RequestMetadata, params *mode isMailOTPEnabled := p.Config.EnableEmailOTP isSMSOTPEnabled := p.Config.EnableSMSOTP - // If multi factor authentication is enabled and is email based login and email otp is enabled - if refs.BoolValue(user.IsMultiFactorAuthEnabled) && isMFAEnabled && isMailOTPEnabled && isEmailServiceEnabled && isEmailLogin { + // A single check protecting all three MFA branches below (email-OTP, + // SMS-OTP, TOTP/resolveMFAGate) — not one check per branch. Lockout is + // set only by explicit user action (lock_mfa), never inferred here. + if user.MFALockedAt != nil { + log.Debug().Msg("User's MFA is locked, refusing login") + return nil, nil, FailedPrecondition("your account's multi-factor authentication is locked; contact your administrator to regain access") + } + + // Computed up-front (not just inside the TOTP/resolveMFAGate branch below) + // so the email-OTP and SMS-OTP branches can offer it too: a registered + // passkey satisfies MFA on its own (see webauthn.go's WebauthnLoginVerify), + // so it must be offered as an alternative alongside whichever OTP method + // gets challenged first — the same way it's already offered alongside + // TOTP. Ignore a list error the same way the TOTP/resolveMFAGate branch + // does: treat "couldn't check" as "found none" rather than failing login. + webauthnCreds, _ := p.StorageProvider.ListWebauthnCredentialsByUserID(ctx, user.ID) + hasWebauthnCredential := len(webauthnCreds) > 0 + + // A verified Email-OTP second factor is challenged on enrollment alone, + // independent of which identifier (email or phone) the caller logged in + // with: a user who signed up with email, later verified a phone number, + // and picked SMS/Email-OTP as their factor must still be challenged for it + // on an email+password login. The code is sent to the account's own stored + // contact (user.Email), not the login params, which may be empty for the + // non-matching identifier. + emailOTPAuthenticator, _ := p.StorageProvider.GetAuthenticatorDetailsByUserId(ctx, user.ID, constants.EnvKeyEmailOTPAuthenticator) + emailOTPEnrolled := emailOTPAuthenticator != nil && emailOTPAuthenticator.VerifiedAt != nil + if effectiveMFAEnabled(p.Config, user) && isMFAEnabled && isMailOTPEnabled && isEmailServiceEnabled && emailOTPEnrolled { expiresAt := time.Now().Add(1 * time.Minute).Unix() - otpData, err := generateOTP(expiresAt) + otpData, err := p.generateAndStoreOTP(ctx, user, expiresAt) if err != nil { log.Debug().Msg("Failed to generate otp") return nil, nil, err @@ -337,7 +339,7 @@ func (p *provider) Login(ctx context.Context, meta RequestMetadata, params *mode go func() { ctx := context.WithoutCancel(ctx) // exec it as go routine so that we can reduce the api latency - if err := p.EmailProvider.SendEmail([]string{email}, constants.VerificationTypeOTP, map[string]any{ + if err := p.EmailProvider.SendEmail([]string{refs.StringValue(user.Email)}, constants.VerificationTypeOTP, map[string]any{ "user": user.ToMap(), "organization": utils.GetOrganization(p.Config), "otp": otpData.Otp, @@ -346,15 +348,24 @@ func (p *provider) Login(ctx context.Context, meta RequestMetadata, params *mode } _ = p.EventsProvider.RegisterEvent(ctx, constants.UserLoginWebhookEvent, constants.AuthRecipeMethodBasicAuth, user) }() - return &model.AuthResponse{ + res := &model.AuthResponse{ Message: "Please check email inbox for the OTP", - ShouldShowEmailOtpScreen: refs.NewBoolRef(isEmailLogin), - }, side, nil + ShouldShowEmailOtpScreen: refs.NewBoolRef(true), + } + if hasWebauthnCredential { + res.ShouldOfferWebauthnMfaVerify = refs.NewBoolRef(true) + } + return res, side, nil } - // If multi factor authentication is enabled and is sms based login and sms otp is enabled - if refs.BoolValue(user.IsMultiFactorAuthEnabled) && isMFAEnabled && isSMSOTPEnabled && isSMSServiceEnabled && isMobileLogin { + // SMS-OTP twin of the email branch above: challenged on enrollment alone, + // sent to user.PhoneNumber regardless of the login identifier. Email wins + // deterministically if a user somehow enrolled both (email branch returns + // first). + smsOTPAuthenticator, _ := p.StorageProvider.GetAuthenticatorDetailsByUserId(ctx, user.ID, constants.EnvKeySMSOTPAuthenticator) + smsOTPEnrolled := smsOTPAuthenticator != nil && smsOTPAuthenticator.VerifiedAt != nil + if effectiveMFAEnabled(p.Config, user) && isMFAEnabled && isSMSOTPEnabled && isSMSServiceEnabled && smsOTPEnrolled { expiresAt := time.Now().Add(1 * time.Minute).Unix() - otpData, err := generateOTP(expiresAt) + otpData, err := p.generateAndStoreOTP(ctx, user, expiresAt) if err != nil { log.Debug().Msg("Failed to generate otp") return nil, nil, err @@ -369,29 +380,34 @@ func (p *provider) Login(ctx context.Context, meta RequestMetadata, params *mode smsBody.WriteString("Your verification code is: ") smsBody.WriteString(otpData.Otp) _ = p.EventsProvider.RegisterEvent(ctx, constants.UserLoginWebhookEvent, constants.AuthRecipeMethodMobileBasicAuth, user) - if err := p.SMSProvider.SendSMS(phoneNumber, smsBody.String()); err != nil { + if err := p.SMSProvider.SendSMS(refs.StringValue(user.PhoneNumber), smsBody.String()); err != nil { log.Debug().Msg("Failed to send sms") } }() - return &model.AuthResponse{ + res := &model.AuthResponse{ Message: "Please check text message for the OTP", - ShouldShowMobileOtpScreen: refs.NewBoolRef(isMobileLogin), - }, side, nil + ShouldShowMobileOtpScreen: refs.NewBoolRef(true), + } + if hasWebauthnCredential { + res.ShouldOfferWebauthnMfaVerify = refs.NewBoolRef(true) + } + return res, side, nil } - // If mfa enabled and also totp enabled - if isMFAEnabled && isTOTPLoginEnabled { + // Gate runs whenever MFA applies at all -- NOT scoped to "TOTP + // specifically is available" (that was the I1 bypass: a WebAuthn-only + // enforced-MFA server, EnableTOTPLogin=false, skipped this block + // entirely and issued tokens unconditionally). Mirrors webauthn.go's + // WebauthnLoginVerify, which calls resolveMFAGate unconditionally and + // only conditions the TOTP-specific parts of the response on + // isTOTPLoginEnabled below. + if isMFAEnabled { 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 + // hasWebauthnCredential is computed once, up-front (see above) — reused + // here rather than re-queried. authenticatorVerified := totpVerified || hasWebauthnCredential gate := resolveMFAGate( - refs.BoolValue(user.IsMultiFactorAuthEnabled), + effectiveMFAEnabled(p.Config, user), p.Config.EnforceMFA, authenticatorVerified, user.HasSkippedMFASetupAt != nil, @@ -404,7 +420,11 @@ func (p *provider) Login(ctx context.Context, meta RequestMetadata, params *mode return nil, nil, err } res := &model.AuthResponse{Message: `Proceed to mfa verification`} - if totpVerified { + // Defense-in-depth: totpVerified can only be true if a TOTP row + // exists, but a server could have disabled TOTP login after the + // row was created (stale enrollment). Don't offer a screen the + // user can no longer complete. + if totpVerified && isTOTPLoginEnabled { res.ShouldShowTotpScreen = refs.NewBoolRef(true) } if hasWebauthnCredential { @@ -417,27 +437,48 @@ func (p *provider) Login(ctx context.Context, meta RequestMetadata, params *mode 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 + res := &model.AuthResponse{ + Message: `Proceed to mfa setup`, + ShouldOfferWebauthnMfaSetup: refs.NewBoolRef(p.Config.EnableWebauthnMFA), + ShouldOfferEmailOtpMfaSetup: refs.NewBoolRef(p.Config.EnableEmailOTP && p.Config.IsEmailServiceEnabled), + ShouldOfferSmsOtpMfaSetup: refs.NewBoolRef(p.Config.EnableSMSOTP && p.Config.IsSMSServiceEnabled), + } + if isTOTPLoginEnabled { + enrollment, err := p.generateTOTPEnrollment(ctx, user.ID) + if err != nil { + log.Debug().Msg("Failed to generate totp") + return nil, nil, err + } + res.ShouldShowTotpScreen = refs.NewBoolRef(true) + res.AuthenticatorScannerImage = refs.NewStringRef(enrollment.ScannerImage) + res.AuthenticatorSecret = refs.NewStringRef(enrollment.Secret) + res.AuthenticatorRecoveryCodes = enrollment.RecoveryCodes } - 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 - case mfaGateOfferSetup: - enrollment, err := p.generateTOTPEnrollment(ctx, user.ID) - if err != nil { - log.Debug().Msg("Failed to generate totp for optional setup") + return res, side, nil + case mfaGateOfferAll: + 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 } - // Falls through to normal token issuance below, with the offer - // flag and enrollment payload attached after CreateAuthToken. - side.PendingTOTPOffer = enrollment + res := &model.AuthResponse{ + Message: `Proceed to mfa setup`, + ShouldOfferWebauthnMfaSetup: refs.NewBoolRef(p.Config.EnableWebauthnMFA), + ShouldOfferEmailOtpMfaSetup: refs.NewBoolRef(p.Config.EnableEmailOTP && p.Config.IsEmailServiceEnabled), + ShouldOfferSmsOtpMfaSetup: refs.NewBoolRef(p.Config.EnableSMSOTP && p.Config.IsSMSServiceEnabled), + } + if isTOTPLoginEnabled { + enrollment, err := p.generateTOTPEnrollment(ctx, user.ID) + if err != nil { + log.Debug().Msg("Failed to generate totp for optional setup") + return nil, nil, err + } + res.ShouldShowTotpScreen = refs.NewBoolRef(true) + res.AuthenticatorScannerImage = refs.NewStringRef(enrollment.ScannerImage) + res.AuthenticatorSecret = refs.NewStringRef(enrollment.Secret) + res.AuthenticatorRecoveryCodes = enrollment.RecoveryCodes + } + return res, side, nil case mfaGateSkippedSetup: side.OfferMFASetupQuiet = true case mfaGateNone: @@ -517,13 +558,6 @@ 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 4f73b143..7213f49a 100644 --- a/internal/service/meta.go +++ b/internal/service/meta.go @@ -35,8 +35,10 @@ func (p *provider) Meta(ctx context.Context, meta RequestMetadata) (*model.Meta, 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, + // WebAuthn/passkey as an MFA factor is gated by --disable-webauthn-mfa + // (EnableWebauthnMFA), same shape as the other per-method flags. Does + // not affect WebAuthn/passkey as a primary login method. + IsWebauthnEnabled: c.EnableMFA && c.EnableWebauthnMFA, IsMfaEnforced: c.EnforceMFA, }, nil, nil } diff --git a/internal/service/mfa_gate.go b/internal/service/mfa_gate.go index eef151d2..b46ae344 100644 --- a/internal/service/mfa_gate.go +++ b/internal/service/mfa_gate.go @@ -1,6 +1,15 @@ // internal/service/mfa_gate.go package service +import ( + "context" + + "github.com/authorizerdev/authorizer/internal/config" + "github.com/authorizerdev/authorizer/internal/constants" + "github.com/authorizerdev/authorizer/internal/refs" + "github.com/authorizerdev/authorizer/internal/storage/schemas" +) + // mfaGateDecision is what login.go should do once it knows a user has MFA // available. See resolveMFAGate for the truth table. type mfaGateDecision int @@ -16,11 +25,14 @@ const ( // 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 + // mfaGateOfferAll: MFA is available but not enforced, the user hasn't + // enrolled, and they've never skipped before. Token is WITHHELD (same + // group as mfaGateBlockVerify/mfaGateBlockEnroll) until the user + // completes one method or explicitly calls skip_mfa_setup — both of + // which authenticate via the MFA session cookie this decision triggers, + // not a bearer token, since none has been issued yet. + mfaGateOfferAll + // mfaGateSkippedSetup: same as mfaGateOfferAll but the user has already // chosen Skip in the past. Issue the token, don't nag. mfaGateSkippedSetup ) @@ -39,7 +51,10 @@ const ( // hasSkippedSetup // - hasSkippedSetup: schemas.User.HasSkippedMFASetupAt != nil func resolveMFAGate(userMFAEnabled, enforceMFA, authenticatorVerified, hasSkippedSetup bool) mfaGateDecision { - if !userMFAEnabled { + // EnforceMFA is absolute: an org-wide mandate overrides a user's persisted + // opt-out (IsMultiFactorAuthEnabled=false). Only skip the gate entirely + // when MFA does not apply to this user AND the org is not enforcing it. + if !userMFAEnabled && !enforceMFA { return mfaGateNone } if authenticatorVerified { @@ -53,5 +68,74 @@ func resolveMFAGate(userMFAEnabled, enforceMFA, authenticatorVerified, hasSkippe if hasSkippedSetup { return mfaGateSkippedSetup } - return mfaGateOfferSetup + return mfaGateOfferAll +} + +// effectiveMFAEnabled reports whether MFA applies to this user right now. +// Never persisted — recomputed from current config plus the user's own +// explicit choice, if any. Replaces the old signup-time default-write and +// login-time backfill: IsMultiFactorAuthEnabled is non-nil ONLY when a +// caller explicitly set it (SignUp params, _update_user params) — everyone +// else follows whatever cfg.EnableMFA currently is, live, every call. +func effectiveMFAEnabled(cfg *config.Config, user *schemas.User) bool { + if user.IsMultiFactorAuthEnabled != nil { + return refs.BoolValue(user.IsMultiFactorAuthEnabled) + } + return cfg.EnableMFA +} + +// EnrolledMFAMethods returns the MFA method identifiers userID has actually +// verified/enrolled: any of constants.EnvKeyTOTPAuthenticator ("totp"), +// constants.AuthRecipeMethodWebauthn ("webauthn"), +// constants.EnvKeyEmailOTPAuthenticator ("email_otp"), +// constants.EnvKeySMSOTPAuthenticator ("sms_otp"). Never nil — an empty slice +// means nothing is enrolled. +// +// It applies the exact same verified checks as authenticatorVerified / +// oauth_mfa_gate.go, but reports the complete set instead of short-circuiting, +// so unlike authenticatorVerified it always runs all four storage reads. That +// is fine because this is only ever invoked lazily by the +// User.enrolled_mfa_methods GraphQL field resolver (when the field is +// selected) — never on the hot login path, where authenticatorVerified's +// short-circuit stays in use. +// +// Storage lookup errors are treated as "not enrolled" (mirroring the existing +// `a, _ :=` pattern), since a missing authenticator row surfaces as an error +// in some providers. +func (p *provider) EnrolledMFAMethods(ctx context.Context, userID string) ([]string, error) { + methods := []string{} + if a, _ := p.StorageProvider.GetAuthenticatorDetailsByUserId(ctx, userID, constants.EnvKeyTOTPAuthenticator); a != nil && a.VerifiedAt != nil { + methods = append(methods, constants.EnvKeyTOTPAuthenticator) + } + if creds, _ := p.StorageProvider.ListWebauthnCredentialsByUserID(ctx, userID); len(creds) > 0 { + methods = append(methods, constants.AuthRecipeMethodWebauthn) + } + if a, _ := p.StorageProvider.GetAuthenticatorDetailsByUserId(ctx, userID, constants.EnvKeyEmailOTPAuthenticator); a != nil && a.VerifiedAt != nil { + methods = append(methods, constants.EnvKeyEmailOTPAuthenticator) + } + if a, _ := p.StorageProvider.GetAuthenticatorDetailsByUserId(ctx, userID, constants.EnvKeySMSOTPAuthenticator); a != nil && a.VerifiedAt != nil { + methods = append(methods, constants.EnvKeySMSOTPAuthenticator) + } + return methods, nil +} + +// authenticatorVerified reports whether userID has any completed/verified MFA +// method: a verified TOTP authenticator, a registered WebAuthn credential, a +// verified Email-OTP, or a verified SMS-OTP authenticator. This is the user's +// own opted-in second factor — its presence maps to mfaGateBlockVerify (never +// skippable). Mirrors the four-way check oauth_mfa_gate.go already performs. +func (p *provider) authenticatorVerified(ctx context.Context, userID string) bool { + if a, _ := p.StorageProvider.GetAuthenticatorDetailsByUserId(ctx, userID, constants.EnvKeyTOTPAuthenticator); a != nil && a.VerifiedAt != nil { + return true + } + if creds, _ := p.StorageProvider.ListWebauthnCredentialsByUserID(ctx, userID); len(creds) > 0 { + return true + } + if a, _ := p.StorageProvider.GetAuthenticatorDetailsByUserId(ctx, userID, constants.EnvKeyEmailOTPAuthenticator); a != nil && a.VerifiedAt != nil { + return true + } + if a, _ := p.StorageProvider.GetAuthenticatorDetailsByUserId(ctx, userID, constants.EnvKeySMSOTPAuthenticator); a != nil && a.VerifiedAt != nil { + return true + } + return false } diff --git a/internal/service/mfa_gate_test.go b/internal/service/mfa_gate_test.go index 41d096c7..095f0a8a 100644 --- a/internal/service/mfa_gate_test.go +++ b/internal/service/mfa_gate_test.go @@ -1,7 +1,12 @@ // internal/service/mfa_gate_test.go package service -import "testing" +import ( + "testing" + + "github.com/authorizerdev/authorizer/internal/config" + "github.com/authorizerdev/authorizer/internal/storage/schemas" +) func TestResolveMFAGate(t *testing.T) { cases := []struct { @@ -13,13 +18,13 @@ func TestResolveMFAGate(t *testing.T) { 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}, + {"mfa off for user, but org enforces -> enforcement is absolute, must enroll", false, true, false, false, mfaGateBlockEnroll}, {"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, never skipped -> offer all methods, withhold token", true, false, false, false, mfaGateOfferAll}, {"optional, not enrolled, already skipped -> quiet login", true, false, false, true, mfaGateSkippedSetup}, } for _, c := range cases { @@ -31,3 +36,29 @@ func TestResolveMFAGate(t *testing.T) { }) } } + +func TestEffectiveMFAEnabled(t *testing.T) { + cases := []struct { + name string + cfgEnableMFA bool + userOptIn *bool // nil = never explicitly set + want bool + }{ + {"MFA available server-wide, user never set it explicitly -> follows config", true, nil, true}, + {"MFA unavailable server-wide, user never set it explicitly -> follows config", false, nil, false}, + {"MFA available server-wide, user explicitly opted out -> respects opt-out", true, boolPtr(false), false}, + {"MFA unavailable server-wide, user explicitly opted in -> respects opt-in", false, boolPtr(true), true}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + cfg := &config.Config{EnableMFA: c.cfgEnableMFA} + user := &schemas.User{IsMultiFactorAuthEnabled: c.userOptIn} + got := effectiveMFAEnabled(cfg, user) + if got != c.want { + t.Errorf("effectiveMFAEnabled(EnableMFA=%v, opt-in=%v) = %v, want %v", c.cfgEnableMFA, c.userOptIn, got, c.want) + } + }) + } +} + +func boolPtr(b bool) *bool { return &b } diff --git a/internal/service/oauth_mfa_gate.go b/internal/service/oauth_mfa_gate.go new file mode 100644 index 00000000..634d7dee --- /dev/null +++ b/internal/service/oauth_mfa_gate.go @@ -0,0 +1,105 @@ +// internal/service/oauth_mfa_gate.go +package service + +import ( + "context" + "net/url" + "strings" + "time" + + "github.com/authorizerdev/authorizer/internal/constants" + "github.com/authorizerdev/authorizer/internal/storage/schemas" +) + +// EvaluateMFAGateForOAuth is oauth_callback.go's entry point into the same +// gate Login/SignUp/WebauthnLoginVerify use. See interface doc comment on +// Provider.EvaluateMFAGateForOAuth. Also used by the REST VerifyEmailHandler +// (magic-link/email-verification click-through), which needs the same +// gate-then-redirect-with-mfa_required shape - name is a historical +// carryover from its original caller, not OAuth-specific. +// +// Like WebauthnLoginVerify, an OAuth/social login is only one factor +// (something you have — the provider's own session) and does not itself +// satisfy an MFA requirement. Unlike login.go, OAuth has no +// isEmailLogin/isMobileLogin concept to short-circuit into an inline +// "send the OTP now" branch, so a verified Email/SMS-OTP authenticator is +// folded directly into authenticatorVerified here — same enrollment check +// (GetAuthenticatorDetailsByUserId + VerifiedAt) login.go already uses to +// decide whether to take its own email/SMS branches. The frontend resolves +// a mfaGateBlockVerify+email_otp/sms_otp hint via ResendOTP, which sends +// the code and sets the MFA session cookie for the verify step. +func (p *provider) EvaluateMFAGateForOAuth(ctx context.Context, meta RequestMetadata, side *ResponseSideEffects, user *schemas.User) (bool, string, error) { + if user.MFALockedAt != nil { + return false, "", FailedPrecondition("your account's multi-factor authentication is locked; contact your administrator to regain access") + } + + webauthnCreds, _ := p.StorageProvider.ListWebauthnCredentialsByUserID(ctx, user.ID) + totpAuthenticator, _ := p.StorageProvider.GetAuthenticatorDetailsByUserId(ctx, user.ID, constants.EnvKeyTOTPAuthenticator) + totpVerified := totpAuthenticator != nil && totpAuthenticator.VerifiedAt != nil + emailOTPAuthenticator, _ := p.StorageProvider.GetAuthenticatorDetailsByUserId(ctx, user.ID, constants.EnvKeyEmailOTPAuthenticator) + emailOTPVerified := emailOTPAuthenticator != nil && emailOTPAuthenticator.VerifiedAt != nil + smsOTPAuthenticator, _ := p.StorageProvider.GetAuthenticatorDetailsByUserId(ctx, user.ID, constants.EnvKeySMSOTPAuthenticator) + smsOTPVerified := smsOTPAuthenticator != nil && smsOTPAuthenticator.VerifiedAt != nil + authenticatorVerified := totpVerified || len(webauthnCreds) > 0 || emailOTPVerified || smsOTPVerified + + gate := resolveMFAGate(effectiveMFAEnabled(p.Config, user), p.Config.EnforceMFA, authenticatorVerified, user.HasSkippedMFASetupAt != nil) + switch gate { + case mfaGateNone, mfaGateSkippedSetup: + return false, "", nil + } + + expiresAt := time.Now().Add(3 * time.Minute).Unix() + if err := p.setMFASession(meta, side, user.ID, expiresAt); err != nil { + return false, "", err + } + + methods := []string{} + switch gate { + case mfaGateBlockVerify: + if totpVerified { + methods = append(methods, constants.EnvKeyTOTPAuthenticator) + } + if len(webauthnCreds) > 0 { + methods = append(methods, constants.AuthRecipeMethodWebauthn) + } + if emailOTPVerified { + methods = append(methods, constants.EnvKeyEmailOTPAuthenticator) + } + if smsOTPVerified { + methods = append(methods, constants.EnvKeySMSOTPAuthenticator) + } + case mfaGateBlockEnroll, mfaGateOfferAll: + if p.Config.EnableTOTPLogin { + methods = append(methods, constants.EnvKeyTOTPAuthenticator) + } + if p.Config.EnableWebauthnMFA { + methods = append(methods, constants.AuthRecipeMethodWebauthn) + } + if p.Config.EnableEmailOTP && p.Config.IsEmailServiceEnabled { + methods = append(methods, constants.EnvKeyEmailOTPAuthenticator) + } + if p.Config.EnableSMSOTP && p.Config.IsSMSServiceEnabled { + methods = append(methods, constants.EnvKeySMSOTPAuthenticator) + } + } + q := url.Values{} + q.Set("mfa_required", "1") + q.Set("mfa_methods", strings.Join(methods, ",")) + // mfa_gate distinguishes a challenge for an already-configured factor + // (mfaGateBlockVerify) from a first-time enrollment offer with a Skip + // option (mfaGateBlockEnroll/mfaGateOfferAll) - the frontend must render + // the verify (code/passkey-assertion) screen for the former and the setup + // (enroll-a-method) screen for the latter; they are not interchangeable. + if gate == mfaGateBlockVerify { + q.Set("mfa_gate", "verify") + } else { + q.Set("mfa_gate", "offer") + } + // Deliberately no email/phone_number here: OAuth's continuation calls + // (verify_otp/skip_mfa_setup/lock_mfa) resolve the account from the MFA + // session cookie alone when no identifier is supplied — see + // MemoryStoreProvider.GetMfaSessionOwner. Putting PII in a redirect URL + // risks referrer leakage to third-party scripts, proxy/CDN access logs, + // and browser history — avoided entirely by not needing it here. + return true, q.Encode(), nil +} diff --git a/internal/service/otp_mfa_setup.go b/internal/service/otp_mfa_setup.go new file mode 100644 index 00000000..c774f175 --- /dev/null +++ b/internal/service/otp_mfa_setup.go @@ -0,0 +1,314 @@ +// internal/service/otp_mfa_setup.go +package service + +import ( + "context" + "strings" + "time" + + "github.com/gin-gonic/gin" + + "github.com/authorizerdev/authorizer/internal/audit" + "github.com/authorizerdev/authorizer/internal/constants" + "github.com/authorizerdev/authorizer/internal/cookie" + "github.com/authorizerdev/authorizer/internal/crypto" + "github.com/authorizerdev/authorizer/internal/graph/model" + "github.com/authorizerdev/authorizer/internal/refs" + "github.com/authorizerdev/authorizer/internal/storage/schemas" + "github.com/authorizerdev/authorizer/internal/utils" +) + +// resolveOTPSetupCaller resolves the caller for EmailOTPMFASetup / +// SMSOTPMFASetup / TOTPMFASetup under either of two auth modes: +// +// 1. Bearer token / session (unchanged, existing behavior) — an +// already-logged-in user adding a second factor from account settings. +// Any email/phone_number param is ignored; the token already identifies +// the user. +// 2. MFA session cookie — a caller in the token-withheld first-time-offer +// state (mfaGateOfferAll) has no bearer token yet. Falls back to the +// same cookie + email/phone_number identity-resolution pattern already +// used by SkipMFASetup/LockMFA: resolve the user by the given +// email/phone_number, then validate the MFA session cookie is actually +// theirs. +// +// Either way, re-arms a fresh MFA session before returning: verify_otp +// requires a valid MFA session cookie unconditionally, and a bearer-token +// caller (the realistic settings-screen path) has no MFA session at all +// yet — without this, setup would succeed but the immediately-following +// verify_otp call would fail with "invalid session" for anyone who isn't +// still holding a live session from a recent login-time MFA gate. A +// cookie-mode caller already has a valid session, but re-arming it too +// keeps both modes uniform and refreshes a session that may be close to +// its original expiry. +// +// Returns Unauthenticated if neither mode resolves a caller. +func (p *provider) resolveOTPSetupCaller(ctx context.Context, meta RequestMetadata, side *ResponseSideEffects, params *model.OtpMfaSetupRequest) (*schemas.User, error) { + user, err := p.resolveOTPSetupCallerIdentity(ctx, meta, params) + if err != nil { + return nil, err + } + expiresAt := time.Now().Add(3 * time.Minute).Unix() + if err := p.setMFASession(meta, side, user.ID, expiresAt); err != nil { + return nil, err + } + return user, nil +} + +// resolveOTPSetupCallerIdentity is resolveOTPSetupCaller's identity-only +// half, split out so the MFA-session re-arming above has a single call site +// rather than being duplicated across every return branch below. +func (p *provider) resolveOTPSetupCallerIdentity(ctx context.Context, meta RequestMetadata, params *model.OtpMfaSetupRequest) (*schemas.User, error) { + if tokenData, err := p.callerTokenData(ctx, meta); err == nil && tokenData != nil && tokenData.UserID != "" { + return p.StorageProvider.GetUserByID(ctx, tokenData.UserID) + } + + gc := &gin.Context{Request: meta.Request} + mfaSession, err := cookie.GetMfaSession(gc) + if err != nil { + return nil, Unauthenticated(`unauthorized`) + } + + var email, phoneNumber string + if params != nil { + email = strings.TrimSpace(refs.StringValue(params.Email)) + phoneNumber = strings.TrimSpace(refs.StringValue(params.PhoneNumber)) + } + if email == "" && phoneNumber == "" { + // No identifier supplied (OAuth-return first-time-offer): resolve the + // account from the session cookie alone. + ownerID, _, oErr := p.MemoryStoreProvider.GetMfaSessionOwner(mfaSession) + if oErr != nil { + return nil, Unauthenticated(`unauthorized`) + } + user, uErr := p.StorageProvider.GetUserByID(ctx, ownerID) + if user == nil || uErr != nil { + return nil, Unauthenticated(`unauthorized`) + } + return user, nil + } + + var user *schemas.User + if email != "" { + user, err = p.StorageProvider.GetUserByEmail(ctx, email) + } else { + user, err = p.StorageProvider.GetUserByPhoneNumber(ctx, phoneNumber) + } + if user == nil || err != nil { + return nil, Unauthenticated(`unauthorized`) + } + + if _, err := p.MemoryStoreProvider.GetMfaSession(user.ID, mfaSession); err != nil { + return nil, Unauthenticated(`unauthorized`) + } + + return user, nil +} + +// EmailOTPMFASetup sends a one-time code to the caller's own email and +// creates (or refreshes) an unverified email-OTP Authenticator row. +// Permissions: authenticated caller (bearer token) — the settings-screen +// "add a second factor" action — OR, absent a token, the MFA session cookie +// plus params.email/phone_number for a caller in the withheld first-time- +// offer state. See resolveOTPSetupCaller. +func (p *provider) EmailOTPMFASetup(ctx context.Context, meta RequestMetadata, params *model.OtpMfaSetupRequest) (*model.Response, *ResponseSideEffects, error) { + log := p.Log.With().Str("func", "EmailOTPMFASetup").Logger() + side := &ResponseSideEffects{} + + user, err := p.resolveOTPSetupCaller(ctx, meta, side, params) + if err != nil { + log.Debug().Err(err).Msg("Failed to resolve caller") + return nil, nil, err + } + + if !p.Config.EnableEmailOTP || !p.Config.IsEmailServiceEnabled { + return nil, nil, FailedPrecondition("email OTP is not available on this server") + } + + email := strings.TrimSpace(refs.StringValue(user.Email)) + if email == "" { + return nil, nil, FailedPrecondition("account has no email address to send an OTP to") + } + + expiresAt := time.Now().Add(1 * time.Minute).Unix() + otpData, err := p.generateAndStoreOTP(ctx, user, expiresAt) + if err != nil { + log.Debug().Err(err).Msg("Failed to generate otp") + return nil, nil, err + } + + if err := p.upsertUnverifiedAuthenticator(ctx, user.ID, constants.EnvKeyEmailOTPAuthenticator); err != nil { + log.Debug().Err(err).Msg("Failed to record pending enrollment") + return nil, nil, err + } + + go func() { + if err := p.EmailProvider.SendEmail([]string{email}, constants.VerificationTypeOTP, map[string]any{ + "user": user.ToMap(), + "organization": utils.GetOrganization(p.Config), + "otp": otpData.Otp, + }); err != nil { + log.Debug().Msg("Failed to send otp email") + } + }() + + p.AuditProvider.LogEvent(audit.Event{ + Action: constants.AuditMFAEnabledEvent, + Protocol: meta.Protocol, ActorID: user.ID, + ActorType: constants.AuditActorTypeUser, + ActorEmail: email, + ResourceType: constants.AuditResourceTypeUser, + ResourceID: user.ID, + IPAddress: meta.IPAddress, + UserAgent: meta.UserAgent, + }) + + return &model.Response{Message: "Check your email for the verification code"}, side, nil +} + +// SMSOTPMFASetup is EmailOTPMFASetup's SMS twin. +func (p *provider) SMSOTPMFASetup(ctx context.Context, meta RequestMetadata, params *model.OtpMfaSetupRequest) (*model.Response, *ResponseSideEffects, error) { + log := p.Log.With().Str("func", "SMSOTPMFASetup").Logger() + side := &ResponseSideEffects{} + + user, err := p.resolveOTPSetupCaller(ctx, meta, side, params) + if err != nil { + log.Debug().Err(err).Msg("Failed to resolve caller") + return nil, nil, err + } + + if !p.Config.EnableSMSOTP || !p.Config.IsSMSServiceEnabled { + return nil, nil, FailedPrecondition("SMS OTP is not available on this server") + } + + phone := strings.TrimSpace(refs.StringValue(user.PhoneNumber)) + if phone == "" { + return nil, nil, FailedPrecondition("account has no phone number to send an OTP to") + } + + expiresAt := time.Now().Add(1 * time.Minute).Unix() + otpData, err := p.generateAndStoreOTP(ctx, user, expiresAt) + if err != nil { + log.Debug().Err(err).Msg("Failed to generate otp") + return nil, nil, err + } + + if err := p.upsertUnverifiedAuthenticator(ctx, user.ID, constants.EnvKeySMSOTPAuthenticator); err != nil { + log.Debug().Err(err).Msg("Failed to record pending enrollment") + return nil, nil, err + } + + go func() { + smsBody := strings.Builder{} + smsBody.WriteString("Your verification code is: ") + smsBody.WriteString(otpData.Otp) + if err := p.SMSProvider.SendSMS(phone, smsBody.String()); err != nil { + log.Debug().Msg("Failed to send sms") + } + }() + + p.AuditProvider.LogEvent(audit.Event{ + Action: constants.AuditMFAEnabledEvent, + Protocol: meta.Protocol, ActorID: user.ID, + ActorType: constants.AuditActorTypeUser, + ActorEmail: refs.StringValue(user.Email), + ResourceType: constants.AuditResourceTypeUser, + ResourceID: user.ID, + IPAddress: meta.IPAddress, + UserAgent: meta.UserAgent, + }) + + return &model.Response{Message: "Check your phone for the verification code"}, side, nil +} + +// TOTPMFASetup generates a fresh TOTP secret/QR/recovery-codes for the +// caller to enroll as an MFA method. Unlike EmailOTPMFASetup/SMSOTPMFASetup, +// nothing is sent anywhere - generateTOTPEnrollment (login.go) already +// persists the unverified Authenticator row itself (via +// AuthenticatorProvider.Generate), so the enrollment payload is simply +// returned to the caller to scan/enter, then completed via +// VerifyOTP(is_totp: true) same as the login-gate TOTP flow. +// Permissions: same dual mode as EmailOTPMFASetup/SMSOTPMFASetup - see +// resolveOTPSetupCaller. +func (p *provider) TOTPMFASetup(ctx context.Context, meta RequestMetadata, params *model.OtpMfaSetupRequest) (*model.AuthResponse, *ResponseSideEffects, error) { + log := p.Log.With().Str("func", "TOTPMFASetup").Logger() + side := &ResponseSideEffects{} + + user, err := p.resolveOTPSetupCaller(ctx, meta, side, params) + if err != nil { + log.Debug().Err(err).Msg("Failed to resolve caller") + return nil, nil, err + } + + if !p.Config.EnableTOTPLogin { + return nil, nil, FailedPrecondition("authenticator app MFA is not available on this server") + } + + enrollment, err := p.generateTOTPEnrollment(ctx, user.ID) + if err != nil { + log.Debug().Err(err).Msg("Failed to generate totp enrollment") + return nil, nil, err + } + + p.AuditProvider.LogEvent(audit.Event{ + Action: constants.AuditMFAEnabledEvent, + Protocol: meta.Protocol, ActorID: user.ID, + ActorType: constants.AuditActorTypeUser, + ActorEmail: refs.StringValue(user.Email), + ResourceType: constants.AuditResourceTypeUser, + ResourceID: user.ID, + IPAddress: meta.IPAddress, + UserAgent: meta.UserAgent, + }) + + 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 +} + +// generateAndStoreOTP is the single OTP-generation implementation shared by +// login.go's email/SMS-verification and TOTP-alternative branches, +// resend_otp.go, and this file's Email/SMS-OTP-as-MFA setup: generates a +// plaintext OTP, persists its HMAC digest via UpsertOTP (keyed by the user's +// email/phone), and returns the plaintext on the returned struct for the +// caller's email/SMS body. Callers log their own error context at the call +// site rather than this method logging internally. +func (p *provider) generateAndStoreOTP(ctx context.Context, user *schemas.User, expiresAt int64) (*schemas.OTP, error) { + otp, err := utils.GenerateOTP() + if err != nil { + return nil, err + } + otpData, err := p.StorageProvider.UpsertOTP(ctx, &schemas.OTP{ + Email: refs.StringValue(user.Email), + PhoneNumber: refs.StringValue(user.PhoneNumber), + Otp: crypto.HashOTP(otp, p.Config.JWTSecret), + ExpiresAt: expiresAt, + }) + if err != nil { + return nil, err + } + otpData.Otp = otp + return otpData, nil +} + +// upsertUnverifiedAuthenticator creates the (user, method) Authenticator row +// if absent, or leaves an existing unverified one in place (a fresh OTP was +// just sent for it — the row's Secret field is unused for OTP methods, only +// VerifiedAt matters). Never touches an already-verified row: re-running +// setup after enrollment is a no-op enrollment-wise, only the send-a-code +// side effect repeats. +func (p *provider) upsertUnverifiedAuthenticator(ctx context.Context, userID, method string) error { + existing, err := p.StorageProvider.GetAuthenticatorDetailsByUserId(ctx, userID, method) + if err == nil && existing != nil { + return nil // already exists (verified or not) — nothing to create + } + _, err = p.StorageProvider.AddAuthenticator(ctx, &schemas.Authenticator{ + UserID: userID, + Method: method, + }) + return err +} diff --git a/internal/service/provider.go b/internal/service/provider.go index 60d81804..ad952bc6 100644 --- a/internal/service/provider.go +++ b/internal/service/provider.go @@ -17,6 +17,7 @@ import ( "github.com/authorizerdev/authorizer/internal/rate_limit" "github.com/authorizerdev/authorizer/internal/sms" "github.com/authorizerdev/authorizer/internal/storage" + "github.com/authorizerdev/authorizer/internal/storage/schemas" "github.com/authorizerdev/authorizer/internal/token" ) @@ -68,6 +69,13 @@ type Provider interface { // Profile returns the authenticated user. Requires session/bearer auth. Profile(ctx context.Context, meta RequestMetadata) (*model.User, *ResponseSideEffects, error) + // EnrolledMFAMethods returns the MFA method identifiers the user has + // verified/enrolled ("totp", "webauthn", "email_otp", "sms_otp"). Read + // helper backing the User.enrolled_mfa_methods GraphQL field resolver; + // takes a resolved user ID (not caller input), so it carries no meta and + // no side effects. Never nil. + EnrolledMFAMethods(ctx context.Context, userID string) ([]string, error) + // CheckPermissions evaluates one or more fine-grained permission checks // for the caller (or, for super-admins, an explicit subject). Requires // session/bearer auth and a configured FGA engine (fail-closed). @@ -100,9 +108,34 @@ 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) + // SkipMFASetup completes a token-withheld first-time MFA offer by + // recording the decline and issuing the previously-withheld token. + // Identified via the MFA session cookie, not a bearer token — none + // exists yet at this point in the flow. + SkipMFASetup(ctx context.Context, meta RequestMetadata, params *model.SkipMfaSetupRequest) (*model.AuthResponse, *ResponseSideEffects, error) + + // LockMFA records that the authenticated-in-progress caller lost access + // to their only MFA factor(s). Requires no verified Email/SMS OTP + // fallback exists for the user — otherwise that should be used instead. + // Does not issue a token. + LockMFA(ctx context.Context, meta RequestMetadata, params *model.LockMfaRequest) (*model.Response, *ResponseSideEffects, error) + + // EmailOTPMFASetup sends a one-time code to the caller's own email and + // begins an email-OTP MFA enrollment. Verified via VerifyOTP. Dual-mode: + // an authenticated caller (bearer token, params ignored) — the + // settings-screen action — OR, absent a token, the MFA session cookie + // plus params.email/phone_number for a caller in the withheld + // first-time-offer state. + EmailOTPMFASetup(ctx context.Context, meta RequestMetadata, params *model.OtpMfaSetupRequest) (*model.Response, *ResponseSideEffects, error) + // SMSOTPMFASetup is EmailOTPMFASetup's SMS twin. + SMSOTPMFASetup(ctx context.Context, meta RequestMetadata, params *model.OtpMfaSetupRequest) (*model.Response, *ResponseSideEffects, error) + // TOTPMFASetup generates a fresh TOTP secret/QR/recovery-codes for the + // caller to enroll as an MFA method, same dual-mode permissions as + // EmailOTPMFASetup/SMSOTPMFASetup. Unlike those, nothing is sent + // anywhere - the enrollment payload is returned directly (same shape + // login.go's gate response uses) so the caller scans the QR/enters the + // code, then completes enrollment via VerifyOTP(is_totp: true). + TOTPMFASetup(ctx context.Context, meta RequestMetadata, params *model.OtpMfaSetupRequest) (*model.AuthResponse, *ResponseSideEffects, error) // ResendVerifyEmail re-issues a pending email-verification link. Public — // response is generic to avoid account enumeration. @@ -143,11 +176,13 @@ type Provider interface { VerifyOTP(ctx context.Context, meta RequestMetadata, params *model.VerifyOTPRequest) (*model.AuthResponse, *ResponseSideEffects, error) // WebauthnRegistrationOptions begins a passkey registration ceremony for the - // authenticated caller. Requires a session. Public (self-service). - WebauthnRegistrationOptions(ctx context.Context, meta RequestMetadata, email *string) (*model.WebauthnRegistrationOptionsResponse, error) + // caller: bearer-token authenticated (settings page) or, mid MFA-offer, + // MFA-session-cookie authenticated. Public (self-service). + WebauthnRegistrationOptions(ctx context.Context, meta RequestMetadata, email, phoneNumber *string) (*model.WebauthnRegistrationOptionsResponse, error) // WebauthnRegistrationVerify verifies the attestation and stores the passkey - // for the authenticated caller. Requires a session. Public (self-service). - WebauthnRegistrationVerify(ctx context.Context, meta RequestMetadata, params *model.WebauthnRegistrationVerifyRequest) (*model.Response, error) + // for the caller. When MFA-session authenticated, also completes the MFA + // gate and issues the withheld auth token. Public (self-service). + WebauthnRegistrationVerify(ctx context.Context, meta RequestMetadata, params *model.WebauthnRegistrationVerifyRequest) (*model.AuthResponse, *ResponseSideEffects, error) // WebauthnLoginOptions begins a passkey login ceremony — usernameless when // email is nil, else scoped to that user's credentials. Public. WebauthnLoginOptions(ctx context.Context, meta RequestMetadata, email *string) (*model.WebauthnLoginOptionsResponse, error) @@ -160,6 +195,17 @@ type Provider interface { // WebauthnDeleteCredential deletes one of the authenticated caller's own // passkeys. Requires a session. Public (self-service). WebauthnDeleteCredential(ctx context.Context, meta RequestMetadata, id string) (*model.Response, error) + + // EvaluateMFAGateForOAuth runs the same MFA gate Login/SignUp/ + // WebauthnLoginVerify use, for a user who just completed an OAuth/ + // social-provider callback - or, via VerifyEmailHandler, a magic-link/ + // email-verification click-through, which needs the identical + // gate-then-redirect shape. On a withhold-group outcome it sets the MFA + // session cookie via side and returns (true, redirectSuffix) where + // redirectSuffix is the query string to append instead of the normal + // state/code params. On mfaGateNone/mfaGateSkippedSetup it returns + // (false, "") and the caller proceeds with cookie.SetSession as today. + EvaluateMFAGateForOAuth(ctx context.Context, meta RequestMetadata, side *ResponseSideEffects, user *schemas.User) (withheld bool, redirectSuffix string, err error) } // New constructs a new service provider. diff --git a/internal/service/resend_otp.go b/internal/service/resend_otp.go index 6bfd6400..f5c4bf17 100644 --- a/internal/service/resend_otp.go +++ b/internal/service/resend_otp.go @@ -6,12 +6,12 @@ import ( "strings" "time" + "github.com/gin-gonic/gin" "github.com/google/uuid" "github.com/authorizerdev/authorizer/internal/audit" "github.com/authorizerdev/authorizer/internal/constants" "github.com/authorizerdev/authorizer/internal/cookie" - "github.com/authorizerdev/authorizer/internal/crypto" "github.com/authorizerdev/authorizer/internal/graph/model" "github.com/authorizerdev/authorizer/internal/refs" "github.com/authorizerdev/authorizer/internal/storage/schemas" @@ -27,12 +27,44 @@ func (p *provider) ResendOTP(ctx context.Context, meta RequestMetadata, params * phoneNumber := strings.Trim(refs.StringValue(params.PhoneNumber), " ") log := p.Log.With().Str("func", "ResendOTP").Str("email", email).Str("phone_number", phoneNumber).Logger() side := &ResponseSideEffects{} - if email == "" && phoneNumber == "" { - log.Debug().Msg("Email or phone number is required") - return nil, nil, InvalidArgument("email or phone number is required") - } var user *schemas.User var err error + // The identifier-supplied path mints a Challenge session (the C1-fix + // behavior); the session-only fallback below upgrades this to Verified. + mfaSessionPurpose := constants.MFASessionPurposeChallenge + if email == "" && phoneNumber == "" { + // Session-only fallback: an OAuth-return caller has a Verified MFA + // session cookie but no identifier (email/phone never travels in the + // redirect, to avoid referrer/log/history leakage). Resolve the account + // from the session alone, then run the normal resend body. Only a + // Verified session qualifies — a bare Challenge session must not spawn + // further resends without an identifier (preserves the C1-fix + // invariant), so anything short of a resolvable Verified session is + // treated exactly as if no identifier was supplied. + gc := &gin.Context{Request: meta.Request} + mfaSession, cErr := cookie.GetMfaSession(gc) + if cErr != nil { + log.Debug().Msg("Email or phone number is required") + return nil, nil, InvalidArgument("email or phone number is required") + } + ownerID, purpose, oErr := p.MemoryStoreProvider.GetMfaSessionOwner(mfaSession) + if oErr != nil || purpose != constants.MFASessionPurposeVerified { + log.Debug().Msg("Email or phone number is required") + return nil, nil, InvalidArgument("email or phone number is required") + } + user, err = p.StorageProvider.GetUserByID(ctx, ownerID) + if user == nil || err != nil { + log.Debug().Msg("Email or phone number is required") + return nil, nil, InvalidArgument("email or phone number is required") + } + // Drive the rest of the flow off the resolved account, preferring email. + email = strings.ToLower(strings.Trim(refs.StringValue(user.Email), " ")) + phoneNumber = strings.Trim(refs.StringValue(user.PhoneNumber), " ") + if email != "" { + phoneNumber = "" + } + mfaSessionPurpose = constants.MFASessionPurposeVerified + } var isEmailServiceEnabled, isSMSServiceEnabled bool if email != "" { isEmailServiceEnabled = p.Config.IsEmailServiceEnabled @@ -105,42 +137,25 @@ func (p *provider) ResendOTP(ctx context.Context, meta RequestMetadata, params * Message: "Failed to get for given email", }, nil, errors.New("failed to get otp for given email") } - // If multi factor authentication is enabled and we need to generate OTP for mail / sms based MFA - generateOTP := func(expiresAt int64) (*schemas.OTP, error) { - otp, err := utils.GenerateOTP() - if err != nil { - log.Debug().Err(err).Msg("Failed to generate OTP") - return nil, err - } - // Store HMAC digest; the plaintext is restored on the returned - // struct so the caller's email/SMS body can read otpData.Otp. - otpData, err := p.StorageProvider.UpsertOTP(ctx, &schemas.OTP{ - Email: refs.StringValue(user.Email), - PhoneNumber: refs.StringValue(user.PhoneNumber), - Otp: crypto.HashOTP(otp, p.Config.JWTSecret), - ExpiresAt: expiresAt, - }) - if err != nil { - log.Debug().Msg("Failed to upsert otp") - return nil, err - } - otpData.Otp = otp - return otpData, nil - } setOTPMFaSession := func(expiresAt int64) error { mfaSession := uuid.NewString() - err = p.MemoryStoreProvider.SetMfaSession(user.ID, mfaSession, expiresAt) + // Identifier-supplied callers only proved they can trigger an OTP send + // to this email/phone — no first factor — so they get a Challenge + // session that can never skip MFA setup or lock the account. The + // session-only fallback above resolved an already-Verified caller, so + // it keeps that Verified status (mfaSessionPurpose). + err = p.MemoryStoreProvider.SetMfaSession(user.ID, mfaSession, mfaSessionPurpose, 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) { + for _, c := range cookie.BuildMfaSessionCookies(meta.HostURL, mfaSession, p.Config.AppCookieSecure, expiresAt) { side.AddCookie(c) } return nil } expiresAt := time.Now().Add(1 * time.Minute).Unix() - otpData, err = generateOTP(expiresAt) + otpData, err = p.generateAndStoreOTP(ctx, user, expiresAt) if err != nil { log.Debug().Msg("Failed to generate otp") return nil, nil, err diff --git a/internal/service/session.go b/internal/service/session.go index 935e1458..32f51cb1 100644 --- a/internal/service/session.go +++ b/internal/service/session.go @@ -107,6 +107,7 @@ func (p *provider) Session(ctx context.Context, meta RequestMetadata, params *mo Scope: scope, LoginMethod: claims.LoginMethod, HostName: hostname, + AuthTime: claims.EffectiveAuthTime(), }) if err != nil { log.Debug().Err(err).Msg("Failed to CreateAuthToken") diff --git a/internal/service/sideeffects.go b/internal/service/sideeffects.go index 621779e1..49f4f3fc 100644 --- a/internal/service/sideeffects.go +++ b/internal/service/sideeffects.go @@ -66,10 +66,6 @@ type ResponseSideEffects struct { // 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. diff --git a/internal/service/signup.go b/internal/service/signup.go index 419ac227..af76527b 100644 --- a/internal/service/signup.go +++ b/internal/service/signup.go @@ -269,12 +269,14 @@ func (p *provider) SignUp(ctx context.Context, meta RequestMetadata, params *mod return nil, nil, err } mfaSession := uuid.NewString() - err = p.MemoryStoreProvider.SetMfaSession(user.ID, mfaSession, expiresAt) + // The caller just created this account with an accepted signup + // credential, so the session is Verified. + err = p.MemoryStoreProvider.SetMfaSession(user.ID, mfaSession, constants.MFASessionPurposeVerified, expiresAt) if err != nil { log.Debug().Err(err).Msg("Failed to add mfasession") return nil, nil, err } - for _, c := range cookie.BuildMfaSessionCookies(hostname, mfaSession, p.Config.AppCookieSecure) { + for _, c := range cookie.BuildMfaSessionCookies(hostname, mfaSession, p.Config.AppCookieSecure, expiresAt) { side.AddCookie(c) } go func() { @@ -321,6 +323,51 @@ func (p *provider) SignUp(ctx context.Context, meta RequestMetadata, params *mod if nonce == "" { nonce = uuid.New().String() } + // Gate runs whenever MFA applies at all -- NOT scoped to "TOTP + // specifically is available" (I1: a WebAuthn-only enforced-MFA server, + // EnableTOTPLogin=false, skipped this block entirely and issued tokens + // unconditionally). Mirrors login.go's own guard/switch, which mirrors + // webauthn.go's WebauthnLoginVerify: resolveMFAGate runs unconditionally + // and only the TOTP-specific parts of the response are conditioned on + // p.Config.EnableTOTPLogin (AuthenticatorProvider is nil, and + // generateTOTPEnrollment panics, whenever EnableTOTPLogin is off). + if p.Config.EnableMFA { + gate := resolveMFAGate(effectiveMFAEnabled(p.Config, user), p.Config.EnforceMFA, false, false) + switch gate { + case mfaGateOfferAll, mfaGateBlockEnroll: + expiresAt := time.Now().Add(3 * time.Minute).Unix() + if err := p.setMFASession(meta, side, user.ID, expiresAt); err != nil { + log.Debug().Err(err).Msg("Failed to set mfa session") + return nil, nil, err + } + res := &model.AuthResponse{ + Message: `Proceed to mfa setup`, + ShouldOfferWebauthnMfaSetup: refs.NewBoolRef(p.Config.EnableWebauthnMFA), + ShouldOfferEmailOtpMfaSetup: refs.NewBoolRef(p.Config.EnableEmailOTP && p.Config.IsEmailServiceEnabled), + ShouldOfferSmsOtpMfaSetup: refs.NewBoolRef(p.Config.EnableSMSOTP && p.Config.IsSMSServiceEnabled), + } + if p.Config.EnableTOTPLogin { + enrollment, err := p.generateTOTPEnrollment(ctx, user.ID) + if err != nil { + log.Debug().Err(err).Msg("Failed to generate totp") + return nil, nil, err + } + res.ShouldShowTotpScreen = refs.NewBoolRef(true) + res.AuthenticatorScannerImage = refs.NewStringRef(enrollment.ScannerImage) + res.AuthenticatorSecret = refs.NewStringRef(enrollment.Secret) + res.AuthenticatorRecoveryCodes = enrollment.RecoveryCodes + } + return res, side, nil + case mfaGateNone, mfaGateBlockVerify, mfaGateSkippedSetup: + // A brand-new signup can never be mfaGateBlockVerify (no + // authenticator exists yet) or mfaGateSkippedSetup (HasSkippedMFASetupAt + // can't be set yet) — resolveMFAGate is called here with both + // authenticatorVerified and hasSkippedSetup hardcoded false, so only + // mfaGateNone and mfaGateOfferAll/mfaGateBlockEnroll are reachable. + // Fall through to normal token issuance below. + } + } + // TokenProvider.CreateAuthToken takes *gin.Context but doesn't read from // it (only AccessToken-getter and ID-token-getter helpers in the same // file do). Synthesize a minimal gin.Context wrapping the inbound diff --git a/internal/service/skip_mfa_setup.go b/internal/service/skip_mfa_setup.go index 604dfbbb..5afb3c33 100644 --- a/internal/service/skip_mfa_setup.go +++ b/internal/service/skip_mfa_setup.go @@ -3,48 +3,111 @@ package service import ( "context" + "strings" "time" + "github.com/gin-gonic/gin" + + "github.com/authorizerdev/authorizer/internal/constants" + "github.com/authorizerdev/authorizer/internal/cookie" "github.com/authorizerdev/authorizer/internal/graph/model" + "github.com/authorizerdev/authorizer/internal/refs" + "github.com/authorizerdev/authorizer/internal/storage/schemas" ) -// 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) { +// SkipMFASetup completes a token-withheld mfaGateOfferAll offer: it records +// that the caller declined every offered MFA method, then issues the token +// that was withheld at login/signup/oauth-callback time. Permissions: none — +// like VerifyOTP, it completes an in-progress authentication identified by +// the MFA session cookie plus email/phone_number, not a bearer token. +func (p *provider) SkipMFASetup(ctx context.Context, meta RequestMetadata, params *model.SkipMfaSetupRequest) (*model.AuthResponse, *ResponseSideEffects, error) { log := p.Log.With().Str("func", "SkipMFASetup").Logger() + side := &ResponseSideEffects{} - // 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") + gc := &gin.Context{Request: meta.Request} + mfaSession, err := cookie.GetMfaSession(gc) + if err != nil { + log.Debug().Err(err).Msg("Failed to get mfa session") + return nil, nil, Unauthenticated(`invalid session`) } - 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") + email := strings.TrimSpace(refs.StringValue(params.Email)) + phoneNumber := strings.TrimSpace(refs.StringValue(params.PhoneNumber)) + + var user *schemas.User + if email == "" && phoneNumber == "" { + // No identifier supplied (OAuth-return MFA continuation): resolve the + // account from the session cookie alone. Ownership plus a Verified + // purpose prove the first factor, exactly as the GetMfaSession + + // purpose check does on the identifier-supplied path below. + ownerID, purpose, oErr := p.MemoryStoreProvider.GetMfaSessionOwner(mfaSession) + if oErr != nil || purpose != constants.MFASessionPurposeVerified { + log.Debug().Err(oErr).Msg("Failed to resolve mfa session owner") + return nil, nil, Unauthenticated(`invalid session`) + } + user, err = p.StorageProvider.GetUserByID(ctx, ownerID) + if user == nil || err != nil { + log.Debug().Err(err).Msg("Failed to resolve user from mfa session") + return nil, nil, Unauthenticated(`invalid session`) + } + } else { + if email != "" { + user, err = p.StorageProvider.GetUserByEmail(ctx, email) + } else { + user, err = p.StorageProvider.GetUserByPhoneNumber(ctx, phoneNumber) + } + if user == nil || err != nil { + log.Debug().Err(err).Msg("User not found") + return nil, nil, NotFound("invalid request") + } + + // Validate the MFA session before touching any state — same ordering + // rationale as VerifyOTP: proves the caller actually completed the + // password/passkey step for THIS user before we act on their behalf. A + // Challenge session (ResendOTP/ForgotPassword — no first factor) is + // rejected here with the same shape as a missing session, so it can never + // be traded for a token. + purpose, err := p.MemoryStoreProvider.GetMfaSession(user.ID, mfaSession) + if err != nil || purpose != constants.MFASessionPurposeVerified { + log.Debug().Err(err).Msg("Failed to get mfa session") + return nil, nil, Unauthenticated(`invalid session`) + } } - 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 + // Recompute the gate: only a genuine mfaGateOfferAll offer (MFA available, + // not enforced, no verified factor yet, never skipped before) may be + // skipped. Anything else — enforcement, a verified second factor the user + // must not bypass (mfaGateBlockVerify), or an already-decided state — is + // not skippable. + gate := resolveMFAGate( + effectiveMFAEnabled(p.Config, user), + p.Config.EnforceMFA, + p.authenticatorVerified(ctx, user.ID), + user.HasSkippedMFASetupAt != nil, + ) + if gate != mfaGateOfferAll { + log.Debug().Int("gate", int(gate)).Msg("MFA setup is not skippable in the current gate state") + return nil, nil, FailedPrecondition("cannot skip multi factor authentication setup") } + now := time.Now().Unix() user.HasSkippedMFASetupAt = &now - if _, err := p.StorageProvider.UpdateUser(ctx, user); err != nil { + user, err = p.StorageProvider.UpdateUser(ctx, user) + if err != nil { log.Debug().Err(err).Msg("Failed to update user") return nil, nil, err } - return &model.Response{Message: "MFA setup skipped"}, nil, nil + // Single-use: drop the session so a captured cookie cannot be replayed. + _ = p.MemoryStoreProvider.DeleteMfaSession(user.ID, mfaSession) + + // Known simplification: issueAuthResponse always stamps loginMethod into + // the audit/webhook trail. The caller may have actually arrived via + // passkey or OAuth, not password, but issueAuthResponse has no way to + // recover the original login method from the MFA session today. Out of + // scope for this task. + res, err := p.issueAuthResponse(ctx, meta, side, user, constants.AuthRecipeMethodBasicAuth, "MFA setup skipped", params.State, false) + if err != nil { + return nil, nil, err + } + return res, side, nil } diff --git a/internal/service/validate_jwt_token.go b/internal/service/validate_jwt_token.go index c8fe9115..36147f29 100644 --- a/internal/service/validate_jwt_token.go +++ b/internal/service/validate_jwt_token.go @@ -60,12 +60,21 @@ func (p *provider) ValidateJwtToken(ctx context.Context, meta RequestMetadata, p } } + // This API validates an arbitrary bearer token without knowing which + // OAuth client is asking, so it can't assert a specific expected + // audience the way the /oauth/token and /userinfo endpoints do. Trust the + // token's own "aud" claim as the expected value — a no-op audience check + // that still requires the claim be present and well-formed; the token's + // signature (already verified by ParseJWTToken) is what actually + // guarantees the claim wasn't forged. + aud, _ := claims["aud"].(string) hostname := meta.HostURL if nonce != "" { if ok, err := p.TokenProvider.ValidateJWTClaims(claims, &token.AuthTokenConfig{ HostName: hostname, Nonce: nonce, User: &schemas.User{ID: userID}, + ClientID: aud, }); !ok || err != nil { log.Debug().Err(err).Msg("Failed to validate jwt claims") return nil, nil, Unauthenticated("invalid claims") @@ -74,6 +83,7 @@ func (p *provider) ValidateJwtToken(ctx context.Context, meta RequestMetadata, p if ok, err := p.TokenProvider.ValidateJWTTokenWithoutNonce(claims, &token.AuthTokenConfig{ HostName: hostname, User: &schemas.User{ID: userID}, + ClientID: aud, }); !ok || err != nil { log.Debug().Err(err).Msg("Failed to validate jwt claims") return nil, nil, Unauthenticated("invalid claims") diff --git a/internal/service/verify_email.go b/internal/service/verify_email.go index 1c1f1914..d471284d 100644 --- a/internal/service/verify_email.go +++ b/internal/service/verify_email.go @@ -15,6 +15,7 @@ import ( "github.com/authorizerdev/authorizer/internal/refs" "github.com/authorizerdev/authorizer/internal/storage/schemas" "github.com/authorizerdev/authorizer/internal/token" + "github.com/authorizerdev/authorizer/internal/utils" ) // VerifyEmail verifies a user's email using a verification token, completing @@ -64,58 +65,151 @@ func (p *provider) VerifyEmail(ctx context.Context, meta RequestMetadata, params return nil, nil, FailedPrecondition("user access has been revoked") } - isMFAEnabled := p.Config.EnableMFA + // A single check protecting every MFA branch below, mirroring login.go — + // lockout is set only by explicit user action (lock_mfa), never inferred + // here, and must block magic-link/email-verify completion exactly like + // it blocks password login. + if user.MFALockedAt != nil { + log.Debug().Msg("User's MFA is locked, refusing login") + return nil, nil, FailedPrecondition("your account's multi-factor authentication is locked; contact your administrator to regain access") + } + + loginMethod := constants.AuthRecipeMethodBasicAuth + if verificationRequest.Identifier == constants.VerificationTypeMagicLinkLogin { + loginMethod = constants.AuthRecipeMethodMagicLinkLogin + } + isTOTPLoginEnabled := p.Config.EnableTOTPLogin + isMFAEnabled := p.Config.EnableMFA - setOTPMFaSession := func(expiresAt int64) error { - mfaSession := uuid.NewString() - err = p.MemoryStoreProvider.SetMfaSession(user.ID, mfaSession, expiresAt) + // A verified Email-OTP second factor is challenged on enrollment alone, + // mirroring login.go's identical early branch — ported here because this + // endpoint used to fall straight into the TOTP/WebAuthn-only gate below + // with no way to ever challenge an email/SMS-OTP factor at all. + emailOTPAuthenticator, _ := p.StorageProvider.GetAuthenticatorDetailsByUserId(ctx, user.ID, constants.EnvKeyEmailOTPAuthenticator) + emailOTPEnrolled := emailOTPAuthenticator != nil && emailOTPAuthenticator.VerifiedAt != nil + if effectiveMFAEnabled(p.Config, user) && isMFAEnabled && p.Config.EnableEmailOTP && p.Config.IsEmailServiceEnabled && emailOTPEnrolled { + expiresAt := time.Now().Add(1 * time.Minute).Unix() + otpData, err := p.generateAndStoreOTP(ctx, user, expiresAt) if err != nil { - log.Debug().Err(err).Msg("Failed to set mfa session") - return err + log.Debug().Err(err).Msg("Failed to generate otp") + return nil, nil, err } - for _, c := range cookie.BuildMfaSessionCookies(hostname, mfaSession, p.Config.AppCookieSecure) { - side.AddCookie(c) + if err := p.setMFASession(meta, side, user.ID, expiresAt); err != nil { + log.Debug().Err(err).Msg("Failed to set mfa session") + return nil, nil, err } - return nil + go func() { + ctx := context.WithoutCancel(ctx) + if err := p.EmailProvider.SendEmail([]string{refs.StringValue(user.Email)}, constants.VerificationTypeOTP, map[string]any{ + "user": user.ToMap(), + "organization": utils.GetOrganization(p.Config), + "otp": otpData.Otp, + }); err != nil { + log.Debug().Err(err).Msg("Failed to send otp email") + } + _ = p.EventsProvider.RegisterEvent(ctx, constants.UserLoginWebhookEvent, loginMethod, user) + }() + return &model.AuthResponse{ + Message: "Please check email inbox for the OTP", + ShouldShowEmailOtpScreen: refs.NewBoolRef(true), + }, 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 { + // SMS-OTP twin of the email branch above. + smsOTPAuthenticator, _ := p.StorageProvider.GetAuthenticatorDetailsByUserId(ctx, user.ID, constants.EnvKeySMSOTPAuthenticator) + smsOTPEnrolled := smsOTPAuthenticator != nil && smsOTPAuthenticator.VerifiedAt != nil + if effectiveMFAEnabled(p.Config, user) && isMFAEnabled && p.Config.EnableSMSOTP && p.Config.IsSMSServiceEnabled && smsOTPEnrolled { + expiresAt := time.Now().Add(1 * time.Minute).Unix() + otpData, err := p.generateAndStoreOTP(ctx, user, expiresAt) + if err != nil { + log.Debug().Err(err).Msg("Failed to generate otp") + return nil, nil, err + } + if err := p.setMFASession(meta, side, user.ID, expiresAt); err != nil { log.Debug().Err(err).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 err != nil { - log.Debug().Err(err).Msg("Failed to generate totp") + go func() { + ctx := context.WithoutCancel(ctx) + smsBody := strings.Builder{} + smsBody.WriteString("Your verification code is: ") + smsBody.WriteString(otpData.Otp) + _ = p.EventsProvider.RegisterEvent(ctx, constants.UserLoginWebhookEvent, loginMethod, user) + if err := p.SMSProvider.SendSMS(refs.StringValue(user.PhoneNumber), smsBody.String()); err != nil { + log.Debug().Err(err).Msg("Failed to send sms") + } + }() + return &model.AuthResponse{ + Message: "Please check text message for the OTP", + ShouldShowMobileOtpScreen: refs.NewBoolRef(true), + }, side, nil + } + + // Gate runs whenever MFA applies at all, exactly like login.go/signup.go — + // this used to be an ad-hoc TOTP-only check (refs.BoolValue(user.IsMultiFactorAuthEnabled) + // && isMFAEnabled && isTOTPLoginEnabled) that silently skipped WebAuthn, + // email/SMS-OTP-as-MFA, EnforceMFA, and HasSkippedMFASetupAt entirely — a + // user whose only configured factor was WebAuthn or email/SMS-OTP (or + // whose account required first-time MFA setup/enforcement) could + // complete a magic-link login or signup-email-verification with zero MFA + // challenge. Replaced with the same resolveMFAGate call every other + // entry point uses. Reaching this point means neither email-OTP nor + // SMS-OTP is the user's enrolled factor (those returned above), so + // totpVerified/hasWebauthnCredential is the correct authenticatorVerified + // set here — mirrors login.go's identical structure and reasoning. + if isMFAEnabled { + totpAuthenticator, totpErr := p.StorageProvider.GetAuthenticatorDetailsByUserId(ctx, user.ID, constants.EnvKeyTOTPAuthenticator) + totpVerified := totpErr == nil && totpAuthenticator != nil && totpAuthenticator.VerifiedAt != nil + webauthnCreds, _ := p.StorageProvider.ListWebauthnCredentialsByUserID(ctx, user.ID) + hasWebauthnCredential := len(webauthnCreds) > 0 + authenticatorVerified := totpVerified || hasWebauthnCredential + gate := resolveMFAGate( + effectiveMFAEnabled(p.Config, user), + 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().Err(err).Msg("Failed to set mfa session") return nil, nil, err } - recoveryCodes := []*string{} - for _, code := range authConfig.RecoveryCodes { - recoveryCodes = append(recoveryCodes, refs.NewStringRef(code)) + res := &model.AuthResponse{Message: `Proceed to mfa verification`} + if totpVerified && isTOTPLoginEnabled { + res.ShouldShowTotpScreen = refs.NewBoolRef(true) } - // when user is first time registering for totp - res := &model.AuthResponse{ - Message: `Proceed to totp verification screen`, - ShouldShowTotpScreen: refs.NewBoolRef(true), - AuthenticatorScannerImage: refs.NewStringRef(authConfig.ScannerImage), - AuthenticatorSecret: refs.NewStringRef(authConfig.Secret), - AuthenticatorRecoveryCodes: recoveryCodes, + if hasWebauthnCredential { + res.ShouldOfferWebauthnMfaVerify = refs.NewBoolRef(true) } return res, side, nil - } else { - // when user is already register for totp + case mfaGateBlockEnroll, mfaGateOfferAll: + expiresAt := time.Now().Add(3 * time.Minute).Unix() + if err := p.setMFASession(meta, side, user.ID, expiresAt); err != nil { + log.Debug().Err(err).Msg("Failed to set mfa session") + return nil, nil, err + } res := &model.AuthResponse{ - Message: `Proceed to totp screen`, - ShouldShowTotpScreen: refs.NewBoolRef(true), + Message: `Proceed to mfa setup`, + ShouldOfferWebauthnMfaSetup: refs.NewBoolRef(p.Config.EnableWebauthnMFA), + ShouldOfferEmailOtpMfaSetup: refs.NewBoolRef(p.Config.EnableEmailOTP && p.Config.IsEmailServiceEnabled), + ShouldOfferSmsOtpMfaSetup: refs.NewBoolRef(p.Config.EnableSMSOTP && p.Config.IsSMSServiceEnabled), + } + if isTOTPLoginEnabled { + enrollment, err := p.generateTOTPEnrollment(ctx, user.ID) + if err != nil { + log.Debug().Err(err).Msg("Failed to generate totp") + return nil, nil, err + } + res.ShouldShowTotpScreen = refs.NewBoolRef(true) + res.AuthenticatorScannerImage = refs.NewStringRef(enrollment.ScannerImage) + res.AuthenticatorSecret = refs.NewStringRef(enrollment.Secret) + res.AuthenticatorRecoveryCodes = enrollment.RecoveryCodes } return res, side, nil + case mfaGateSkippedSetup, mfaGateNone: + // fall through, nothing to do } } @@ -138,11 +232,6 @@ func (p *provider) VerifyEmail(ctx context.Context, meta RequestMetadata, params return nil, nil, err } - loginMethod := constants.AuthRecipeMethodBasicAuth - if verificationRequest.Identifier == constants.VerificationTypeMagicLinkLogin { - loginMethod = constants.AuthRecipeMethodMagicLinkLogin - } - roles := strings.Split(user.Roles, ",") scope := []string{"openid", "email", "profile"} code := "" diff --git a/internal/service/verify_otp.go b/internal/service/verify_otp.go index ddc43a6d..f84f72fa 100644 --- a/internal/service/verify_otp.go +++ b/internal/service/verify_otp.go @@ -55,16 +55,34 @@ func (p *provider) VerifyOTP(ctx context.Context, meta RequestMetadata, params * email := strings.TrimSpace(refs.StringValue(params.Email)) phoneNumber := strings.TrimSpace(refs.StringValue(params.PhoneNumber)) - if email == "" && phoneNumber == "" { - log.Debug().Msg("Email or phone number is required") - return nil, nil, InvalidArgument(`email or phone number is required`) - } isEmailVerification := email != "" isMobileVerification := phoneNumber != "" log = log.With().Str("email", email).Str("phone_number", phoneNumber).Logger() // Get user by email or phone number var user *schemas.User - if isEmailVerification { + // sessionResolved is true when the caller supplied no identifier and the + // account was resolved from the MFA session cookie alone (OAuth-return MFA + // continuation, where the frontend never learns the account's email/phone). + // Session ownership is then already proven by GetMfaSessionOwner, so the + // later GetMfaSession re-check is skipped for this path. + sessionResolved := false + if email == "" && phoneNumber == "" { + ownerID, _, oErr := p.MemoryStoreProvider.GetMfaSessionOwner(mfaSession) + if oErr != nil { + log.Debug().Err(oErr).Msg("Failed to resolve mfa session owner") + return nil, nil, Unauthenticated(`invalid session`) + } + user, err = p.StorageProvider.GetUserByID(ctx, ownerID) + if user == nil || err != nil { + log.Debug().Err(err).Msg("Failed to resolve user from mfa session") + return nil, nil, Unauthenticated(`invalid session`) + } + email = strings.TrimSpace(refs.StringValue(user.Email)) + phoneNumber = strings.TrimSpace(refs.StringValue(user.PhoneNumber)) + isEmailVerification = email != "" + isMobileVerification = !isEmailVerification && phoneNumber != "" + sessionResolved = true + } else if isEmailVerification { user, err = p.StorageProvider.GetUserByEmail(ctx, refs.StringValue(params.Email)) if err != nil { log.Debug().Err(err).Msg("Failed to get user by email") @@ -92,9 +110,11 @@ func (p *provider) VerifyOTP(ctx context.Context, meta RequestMetadata, params * // attacker who only knows a victim's email - with no valid session - is // rejected before they can touch (and so exhaust) the victim's lockout // counter, closing an unauthenticated account-lockout DoS. - if _, err := p.MemoryStoreProvider.GetMfaSession(user.ID, mfaSession); err != nil { - log.Debug().Err(err).Msg("Failed to get mfa session") - return nil, nil, Unauthenticated(`invalid session`) + if !sessionResolved { + if _, err := p.MemoryStoreProvider.GetMfaSession(user.ID, mfaSession); err != nil { + log.Debug().Err(err).Msg("Failed to get mfa session") + return nil, nil, Unauthenticated(`invalid session`) + } } // Verify OTP based on TOPT or OTP @@ -151,12 +171,12 @@ func (p *provider) VerifyOTP(ctx context.Context, meta RequestMetadata, params * } else { var otp *schemas.OTP if isEmailVerification { - otp, err = p.StorageProvider.GetOTPByEmail(ctx, refs.StringValue(params.Email)) + otp, err = p.StorageProvider.GetOTPByEmail(ctx, email) if err != nil { log.Debug().Err(err).Msg("Failed to get otp request for email") } } else { - otp, err = p.StorageProvider.GetOTPByPhoneNumber(ctx, refs.StringValue(params.PhoneNumber)) + otp, err = p.StorageProvider.GetOTPByPhoneNumber(ctx, phoneNumber) if err != nil { log.Debug().Err(err).Msg("Failed to get otp request for phone number") } @@ -181,6 +201,26 @@ func (p *provider) VerifyOTP(ctx context.Context, meta RequestMetadata, params * if err := p.StorageProvider.DeleteOTP(ctx, otp); err != nil { log.Debug().Err(err).Msg("Failed to delete otp") } + + // Mark the corresponding email/SMS-OTP MFA enrollment verified, but + // ONLY when a pending (unverified) Authenticator row already exists + // for this method — i.e. the caller went through + // EmailOTPMFASetup/SMSOTPMFASetup first. A plain login-time OTP + // send/verify (login.go's pre-enrollment challenge, or a signup + // email/phone verification) never created that row, so this is a + // no-op for those: routine login-time OTP must not silently + // "enroll" anyone as MFA. + method := constants.EnvKeyEmailOTPAuthenticator + if isMobileVerification { + method = constants.EnvKeySMSOTPAuthenticator + } + if authenticator, aErr := p.StorageProvider.GetAuthenticatorDetailsByUserId(ctx, user.ID, method); aErr == nil && authenticator != nil && authenticator.VerifiedAt == nil { + now := time.Now().Unix() + authenticator.VerifiedAt = &now + if _, err := p.StorageProvider.UpdateAuthenticator(ctx, authenticator); err != nil { + log.Debug().Err(err).Msg("Failed to mark otp authenticator verified") + } + } } isSignUp := false @@ -229,6 +269,10 @@ func (p *provider) VerifyOTP(ctx context.Context, meta RequestMetadata, params * }) } + // Single-use: the OTP is verified, so drop the session to prevent replay of + // a captured cookie within its remaining TTL. + _ = p.MemoryStoreProvider.DeleteMfaSession(user.ID, mfaSession) + res, err := p.issueAuthResponse(ctx, meta, side, user, loginMethod, `OTP verified successfully.`, params.State, isSignUp) if err != nil { return nil, nil, err diff --git a/internal/service/webauthn.go b/internal/service/webauthn.go index 6c01b00a..f99f37c9 100644 --- a/internal/service/webauthn.go +++ b/internal/service/webauthn.go @@ -3,31 +3,88 @@ package service import ( "context" "strings" - "time" "github.com/authorizerdev/authorizer/internal/audit" "github.com/authorizerdev/authorizer/internal/constants" "github.com/authorizerdev/authorizer/internal/cookie" "github.com/authorizerdev/authorizer/internal/graph/model" "github.com/authorizerdev/authorizer/internal/refs" + "github.com/authorizerdev/authorizer/internal/storage/schemas" "github.com/gin-gonic/gin" ) +// resolveWebauthnSetupCaller resolves who is calling +// WebauthnRegistrationOptions/WebauthnRegistrationVerify. The ordinary +// settings-page caller is bearer-token authenticated (unchanged). During a +// token-withheld MFA offer (mfaGateOfferAll/mfaGateBlockEnroll — see +// mfa_gate.go) there is no bearer token yet, only the MFA session cookie, so +// registering a passkey there authenticates the same way VerifyOTP/ +// SkipMFASetup do: the cookie's Verified purpose proves the first factor +// already completed for this exact user. The gate is recomputed and must +// still be a genuine enrollment offer — never mfaGateBlockVerify, or a caller +// who only proved a password could mint a brand-new passkey and skip +// challenging their EXISTING second factor, defeating it entirely. +func (p *provider) resolveWebauthnSetupCaller(ctx context.Context, meta RequestMetadata, email, phoneNumber string) (*schemas.User, bool, error) { + if tokenData, tErr := p.callerTokenData(ctx, meta); tErr == nil && tokenData != nil && tokenData.UserID != "" { + user, err := p.StorageProvider.GetUserByID(ctx, tokenData.UserID) + if err != nil { + return nil, false, err + } + return user, false, nil + } + + gc := &gin.Context{Request: meta.Request} + mfaSession, err := cookie.GetMfaSession(gc) + if err != nil { + return nil, false, Unauthenticated("unauthorized") + } + + email = strings.TrimSpace(email) + phoneNumber = strings.TrimSpace(phoneNumber) + var user *schemas.User + if email == "" && phoneNumber == "" { + ownerID, purpose, oErr := p.MemoryStoreProvider.GetMfaSessionOwner(mfaSession) + if oErr != nil || purpose != constants.MFASessionPurposeVerified { + return nil, false, Unauthenticated("invalid session") + } + user, err = p.StorageProvider.GetUserByID(ctx, ownerID) + } else if email != "" { + user, err = p.StorageProvider.GetUserByEmail(ctx, email) + } else { + user, err = p.StorageProvider.GetUserByPhoneNumber(ctx, phoneNumber) + } + if user == nil || err != nil { + return nil, false, Unauthenticated("invalid session") + } + if email != "" || phoneNumber != "" { + purpose, pErr := p.MemoryStoreProvider.GetMfaSession(user.ID, mfaSession) + if pErr != nil || purpose != constants.MFASessionPurposeVerified { + return nil, false, Unauthenticated("invalid session") + } + } + + gate := resolveMFAGate( + effectiveMFAEnabled(p.Config, user), + p.Config.EnforceMFA, + p.authenticatorVerified(ctx, user.ID), + user.HasSkippedMFASetupAt != nil, + ) + if gate != mfaGateOfferAll && gate != mfaGateBlockEnroll { + return nil, false, FailedPrecondition("cannot set up a passkey in the current state") + } + return user, true, nil +} + // WebauthnRegistrationOptions begins a passkey registration ceremony for the -// authenticated caller. The session — not the email argument — identifies the -// user, so a caller can only register a passkey against their own account. +// caller — either bearer-token authenticated (settings page) or MFA-session +// authenticated (login-time enrollment offer); see resolveWebauthnSetupCaller. // -// Permissions: authenticated:user -func (p *provider) WebauthnRegistrationOptions(ctx context.Context, meta RequestMetadata, email *string) (*model.WebauthnRegistrationOptionsResponse, error) { +// Permissions: authenticated:user, or an MFA-session-cookie caller mid-offer. +func (p *provider) WebauthnRegistrationOptions(ctx context.Context, meta RequestMetadata, email, phoneNumber *string) (*model.WebauthnRegistrationOptionsResponse, error) { log := p.Log.With().Str("func", "WebauthnRegistrationOptions").Logger() - tokenData, err := p.callerTokenData(ctx, meta) - if err != nil || tokenData == nil || tokenData.UserID == "" { - log.Debug().Err(err).Msg("Failed to resolve caller") - return nil, Unauthenticated("unauthorized") - } - user, err := p.StorageProvider.GetUserByID(ctx, tokenData.UserID) + user, _, err := p.resolveWebauthnSetupCaller(ctx, meta, refs.StringValue(email), refs.StringValue(phoneNumber)) if err != nil { - log.Debug().Err(err).Msg("Failed to get user by id") + log.Debug().Err(err).Msg("Failed to resolve caller") return nil, err } options, err := p.WebAuthnProvider.BeginRegistration(ctx, meta.HostURL, user) @@ -39,20 +96,20 @@ func (p *provider) WebauthnRegistrationOptions(ctx context.Context, meta Request } // WebauthnRegistrationVerify verifies the attestation from the browser and -// persists the passkey for the authenticated caller. +// persists the passkey for the caller (see resolveWebauthnSetupCaller). When +// resolved via the MFA session (login-time enrollment offer, not the +// settings page), this also completes the MFA gate and issues the +// previously-withheld auth token — exactly like totp_mfa_setup + +// verify_otp(is_totp: true) does for TOTP. // -// Permissions: authenticated:user -func (p *provider) WebauthnRegistrationVerify(ctx context.Context, meta RequestMetadata, params *model.WebauthnRegistrationVerifyRequest) (*model.Response, error) { +// Permissions: authenticated:user, or an MFA-session-cookie caller mid-offer. +func (p *provider) WebauthnRegistrationVerify(ctx context.Context, meta RequestMetadata, params *model.WebauthnRegistrationVerifyRequest) (*model.AuthResponse, *ResponseSideEffects, error) { log := p.Log.With().Str("func", "WebauthnRegistrationVerify").Logger() - tokenData, err := p.callerTokenData(ctx, meta) - if err != nil || tokenData == nil || tokenData.UserID == "" { - log.Debug().Err(err).Msg("Failed to resolve caller") - return nil, Unauthenticated("unauthorized") - } - user, err := p.StorageProvider.GetUserByID(ctx, tokenData.UserID) + side := &ResponseSideEffects{} + user, sessionAuthenticated, err := p.resolveWebauthnSetupCaller(ctx, meta, refs.StringValue(params.Email), refs.StringValue(params.PhoneNumber)) if err != nil { - log.Debug().Err(err).Msg("Failed to get user by id") - return nil, err + log.Debug().Err(err).Msg("Failed to resolve caller") + return nil, nil, err } name := "" if params.Name != nil { @@ -61,7 +118,7 @@ func (p *provider) WebauthnRegistrationVerify(ctx context.Context, meta RequestM cred, err := p.WebAuthnProvider.FinishRegistration(ctx, meta.HostURL, user, name, params.Credential) if err != nil { log.Debug().Err(err).Msg("Failed to finish registration") - return nil, InvalidArgument(err.Error()) + return nil, nil, InvalidArgument(err.Error()) } p.AuditProvider.LogEvent(audit.Event{ Action: constants.AuditWebauthnCredentialAddedEvent, @@ -73,7 +130,19 @@ func (p *provider) WebauthnRegistrationVerify(ctx context.Context, meta RequestM IPAddress: meta.IPAddress, UserAgent: meta.UserAgent, }) - return &model.Response{Message: "Passkey registered successfully."}, nil + if !sessionAuthenticated { + return &model.AuthResponse{Message: "Passkey registered successfully."}, side, nil + } + // Single-use: drop the session so a captured cookie cannot be replayed. + gc := &gin.Context{Request: meta.Request} + if mfaSession, sErr := cookie.GetMfaSession(gc); sErr == nil { + _ = p.MemoryStoreProvider.DeleteMfaSession(user.ID, mfaSession) + } + res, err := p.issueAuthResponse(ctx, meta, side, user, constants.AuthRecipeMethodWebauthn, "Passkey registered and MFA setup complete.", params.State, false) + if err != nil { + return nil, nil, err + } + return res, side, nil } // WebauthnLoginOptions begins a passkey login ceremony. With no email it is a @@ -155,43 +224,21 @@ 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 + if user.MFALockedAt != nil { + log.Debug().Msg("User's MFA is locked, refusing passkey login") + return nil, nil, FailedPrecondition("your account's multi-factor authentication is locked; contact your administrator to regain access") } + // A successful WebAuthn assertion satisfies the MFA requirement on its own, + // full stop — whether this is the user's primary/first login action or an + // explicitly-offered second factor after a password login, and regardless of + // what other factors (TOTP, email-OTP, SMS-OTP) are also enrolled. This + // deployment registers passkeys with UserVerification: Required (see the + // webauthn provider), so every ceremony already bundles device possession + // with a local biometric/PIN; it is treated as sufficient the same way + // verify_otp issues a token once a TOTP/OTP code validates. There is no + // further gate and no OTP/TOTP re-challenge: reaching this point (past the + // revoked/email-verified/MFA-locked guards above) issues the token directly. p.AuditProvider.LogEvent(audit.Event{ Action: constants.AuditLoginSuccessEvent, Protocol: meta.Protocol, ActorID: user.ID, diff --git a/internal/storage/db/arangodb/authenticator.go b/internal/storage/db/arangodb/authenticator.go index 327c9e47..38c05af9 100644 --- a/internal/storage/db/arangodb/authenticator.go +++ b/internal/storage/db/arangodb/authenticator.go @@ -49,6 +49,21 @@ func (p *provider) UpdateAuthenticator(ctx context.Context, authenticators *sche return authenticators, nil } +// DeleteAuthenticatorsByUserID removes every authenticator row for a user. +// Used by admin MFA reset. +func (p *provider) DeleteAuthenticatorsByUserID(ctx context.Context, userID string) error { + query := fmt.Sprintf("FOR d IN %s FILTER d.user_id == @user_id REMOVE d IN %s", schemas.Collections.Authenticators, schemas.Collections.Authenticators) + bindVars := map[string]interface{}{ + "user_id": userID, + } + cursor, err := p.db.Query(ctx, query, bindVars) + if err != nil { + return err + } + defer func() { _ = cursor.Close() }() + return nil +} + func (p *provider) GetAuthenticatorDetailsByUserId(ctx context.Context, userId string, authenticatorType string) (*schemas.Authenticator, error) { var authenticators *schemas.Authenticator query := fmt.Sprintf("FOR d in %s FILTER d.user_id == @user_id AND d.method == @method LIMIT 1 RETURN d", schemas.Collections.Authenticators) diff --git a/internal/storage/db/cassandradb/authenticator.go b/internal/storage/db/cassandradb/authenticator.go index b4b9280a..bd68a8b6 100644 --- a/internal/storage/db/cassandradb/authenticator.go +++ b/internal/storage/db/cassandradb/authenticator.go @@ -106,3 +106,31 @@ func (p *provider) GetAuthenticatorDetailsByUserId(ctx context.Context, userId s } return &authenticators, nil } + +// DeleteAuthenticatorsByUserID removes every authenticator row for a user. +// user_id is not the partition key (id is), so DELETE cannot filter on it +// directly — mirrors DeleteUser's session cleanup: look up matching ids via +// ALLOW FILTERING, then delete each by its partition key. Used by admin MFA +// reset. +func (p *provider) DeleteAuthenticatorsByUserID(ctx context.Context, userID string) error { + getIDsQuery := fmt.Sprintf("SELECT id FROM %s WHERE user_id = ? ALLOW FILTERING", KeySpace+"."+schemas.Collections.Authenticators) + scanner := p.db.Query(getIDsQuery, userID).Iter().Scanner() + var ids []string + for scanner.Next() { + var id string + if err := scanner.Scan(&id); err != nil { + return err + } + ids = append(ids, id) + } + if err := scanner.Err(); err != nil { + return err + } + for _, id := range ids { + deleteQuery := fmt.Sprintf("DELETE FROM %s WHERE id = ?", KeySpace+"."+schemas.Collections.Authenticators) + if err := p.db.Query(deleteQuery, id).Exec(); err != nil { + return err + } + } + return nil +} diff --git a/internal/storage/db/cassandradb/provider.go b/internal/storage/db/cassandradb/provider.go index 99604820..c7501bfb 100644 --- a/internal/storage/db/cassandradb/provider.go +++ b/internal/storage/db/cassandradb/provider.go @@ -189,6 +189,13 @@ func NewProvider(cfg *config.Config, deps *Dependencies) (*provider, error) { deps.Log.Debug().Err(err).Msg("Failed to alter table as has_skipped_mfa_setup_at column exists") // continue } + // add mfa_locked_at on users table + userMFALockedAtAlterQuery := fmt.Sprintf(`ALTER TABLE %s.%s ADD mfa_locked_at bigint`, KeySpace, schemas.Collections.User) + err = session.Query(userMFALockedAtAlterQuery).Exec() + if err != nil { + deps.Log.Debug().Err(err).Msg("Failed to alter table as mfa_locked_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 1d5d253a..72c55cea 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, has_skipped_mfa_setup_at, 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, mfa_locked_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.HasSkippedMFASetupAt, &user.AppData, &user.ExternalID, &user.IsActive, &user.CreatedAt, &user.UpdatedAt) + &user.HasSkippedMFASetupAt, &user.MFALockedAt, &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, 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) + 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, mfa_locked_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.MFALockedAt, &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, 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) + 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, mfa_locked_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.MFALockedAt, &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, 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) + 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, mfa_locked_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.MFALockedAt, &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, 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) + 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, mfa_locked_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.MFALockedAt, &user.AppData, &user.ExternalID, &user.IsActive, &user.CreatedAt, &user.UpdatedAt) if err != nil { return nil, err } diff --git a/internal/storage/db/couchbase/authenticator.go b/internal/storage/db/couchbase/authenticator.go index a228b379..fe472cf6 100644 --- a/internal/storage/db/couchbase/authenticator.go +++ b/internal/storage/db/couchbase/authenticator.go @@ -89,3 +89,18 @@ func (p *provider) GetAuthenticatorDetailsByUserId(ctx context.Context, userId s } return authenticators, nil } + +// DeleteAuthenticatorsByUserID removes every authenticator row for a user. +// Used by admin MFA reset. +func (p *provider) DeleteAuthenticatorsByUserID(ctx context.Context, userID string) error { + query := fmt.Sprintf("DELETE FROM %s.%s WHERE user_id = $1", p.scopeName, schemas.Collections.Authenticators) + _, err := p.db.Query(query, &gocb.QueryOptions{ + ScanConsistency: gocb.QueryScanConsistencyRequestPlus, + Context: ctx, + PositionalParameters: []interface{}{userID}, + }) + if err != nil { + return err + } + return nil +} diff --git a/internal/storage/db/couchbase/user.go b/internal/storage/db/couchbase/user.go index 609fef72..afbd1883 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, 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) + 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, mfa_locked_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, 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) + 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, mfa_locked_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, 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) + 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, mfa_locked_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, 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) + 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, mfa_locked_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, 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) + 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, mfa_locked_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/authenticator.go b/internal/storage/db/dynamodb/authenticator.go index 673a6510..042159cb 100644 --- a/internal/storage/db/dynamodb/authenticator.go +++ b/internal/storage/db/dynamodb/authenticator.go @@ -51,3 +51,23 @@ func (p *provider) GetAuthenticatorDetailsByUserId(ctx context.Context, userId s } return &a, nil } + +// DeleteAuthenticatorsByUserID removes every authenticator row for a user. +// Used by admin MFA reset. +func (p *provider) DeleteAuthenticatorsByUserID(ctx context.Context, userID string) error { + f := expression.Name("user_id").Equal(expression.Value(userID)) + items, err := p.scanFilteredAll(ctx, schemas.Collections.Authenticators, nil, &f) + if err != nil { + return err + } + for _, item := range items { + var a schemas.Authenticator + if err := unmarshalItem(item, &a); err != nil { + return err + } + if err := p.deleteItemByHash(ctx, schemas.Collections.Authenticators, "id", a.ID); err != nil { + return err + } + } + return nil +} diff --git a/internal/storage/db/dynamodb/user.go b/internal/storage/db/dynamodb/user.go index a12f09c7..6c8c41d1 100644 --- a/internal/storage/db/dynamodb/user.go +++ b/internal/storage/db/dynamodb/user.go @@ -115,6 +115,9 @@ func userDynamoRemoveAttrsIfNil(u *schemas.User) []string { if u.HasSkippedMFASetupAt == nil { remove = append(remove, "has_skipped_mfa_setup_at") } + if u.MFALockedAt == nil { + remove = append(remove, "mfa_locked_at") + } if u.AppData == nil { remove = append(remove, "app_data") } diff --git a/internal/storage/db/mongodb/authenticator.go b/internal/storage/db/mongodb/authenticator.go index 31439981..38394806 100644 --- a/internal/storage/db/mongodb/authenticator.go +++ b/internal/storage/db/mongodb/authenticator.go @@ -58,3 +58,11 @@ func (p *provider) GetAuthenticatorDetailsByUserId(ctx context.Context, userId s } return authenticators, nil } + +// DeleteAuthenticatorsByUserID removes every authenticator row for a user. +// Used by admin MFA reset. +func (p *provider) DeleteAuthenticatorsByUserID(ctx context.Context, userID string) error { + authenticatorsCollection := p.db.Collection(schemas.Collections.Authenticators, options.Collection()) + _, err := authenticatorsCollection.DeleteMany(ctx, bson.M{"user_id": userID}) + return err +} diff --git a/internal/storage/db/mongodb/provider.go b/internal/storage/db/mongodb/provider.go index 5733dc9a..d8c4eeed 100644 --- a/internal/storage/db/mongodb/provider.go +++ b/internal/storage/db/mongodb/provider.go @@ -66,12 +66,27 @@ func NewProvider(config *config.Config, deps *Dependencies) (*provider, error) { }, options.CreateIndexes()) _ = mongodb.CreateCollection(ctx, schemas.Collections.VerificationRequest, options.CreateCollection()) verificationRequestCollection := mongodb.Collection(schemas.Collections.VerificationRequest, options.Collection()) - _, _ = verificationRequestCollection.Indexes().CreateMany(ctx, []mongo.IndexModel{ - { - Keys: bson.M{"email": 1, "identifier": 1}, + if _, err := verificationRequestCollection.Indexes().CreateMany(ctx, []mongo.IndexModel{ + { + // Compound index keys MUST use the ordered bson.D — the driver + // rejects a multi-key bson.M ("multi-key map passed in for ordered + // parameter keys"), so this unique constraint was silently never + // created; same bug already found and fixed for the authenticator + // index below. + Keys: bson.D{{Key: "email", Value: 1}, {Key: "identifier", Value: 1}}, Options: options.Index().SetUnique(true).SetSparse(true), }, - }, options.CreateIndexes()) + }, options.CreateIndexes()); err != nil { + // Unlike the rest of this file's index creation, this one is worth a + // loud warning rather than a silent discard: a database that already + // accumulated duplicate (email, identifier) rows from the bson.M bug + // this replaces will fail this unique-index build and stay + // unprotected until an operator dedupes and retries - swallowing the + // error would hide that a fresh install still needs attention. + if deps != nil && deps.Log != nil { + deps.Log.Warn().Err(err).Msg("failed to create unique index on verification_requests(email, identifier) - if this database has pre-existing duplicates, dedupe them and restart") + } + } _, _ = verificationRequestCollection.Indexes().CreateMany(ctx, []mongo.IndexModel{ { Keys: bson.M{"token": 1}, @@ -151,7 +166,8 @@ func NewProvider(config *config.Config, deps *Dependencies) (*provider, error) { sessionTokenCollection := mongodb.Collection(schemas.Collections.SessionToken, options.Collection()) _, _ = sessionTokenCollection.Indexes().CreateMany(ctx, []mongo.IndexModel{ { - Keys: bson.M{"user_id": 1, "key_name": 1}, + // bson.D, not bson.M - see the verification-request index comment above. + Keys: bson.D{{Key: "user_id", Value: 1}, {Key: "key_name", Value: 1}}, Options: options.Index().SetSparse(true), }, { @@ -165,7 +181,8 @@ func NewProvider(config *config.Config, deps *Dependencies) (*provider, error) { mfaSessionCollection := mongodb.Collection(schemas.Collections.MFASession, options.Collection()) _, _ = mfaSessionCollection.Indexes().CreateMany(ctx, []mongo.IndexModel{ { - Keys: bson.M{"user_id": 1, "key_name": 1}, + // bson.D, not bson.M - see the verification-request index comment above. + Keys: bson.D{{Key: "user_id", Value: 1}, {Key: "key_name", Value: 1}}, Options: options.Index().SetSparse(true), }, { diff --git a/internal/storage/db/provider_template/authenticator.go b/internal/storage/db/provider_template/authenticator.go index 5bc5702e..d0f3ef61 100644 --- a/internal/storage/db/provider_template/authenticator.go +++ b/internal/storage/db/provider_template/authenticator.go @@ -32,3 +32,9 @@ func (p *provider) GetAuthenticatorDetailsByUserId(ctx context.Context, userId s var authenticators *schemas.Authenticator return authenticators, nil } + +// DeleteAuthenticatorsByUserID removes every authenticator row for a user. +// Used by admin MFA reset. +func (p *provider) DeleteAuthenticatorsByUserID(ctx context.Context, userID string) error { + return nil +} diff --git a/internal/storage/db/sql/authenticator.go b/internal/storage/db/sql/authenticator.go index b1f71aac..1b221a78 100644 --- a/internal/storage/db/sql/authenticator.go +++ b/internal/storage/db/sql/authenticator.go @@ -54,3 +54,9 @@ func (p *provider) GetAuthenticatorDetailsByUserId(ctx context.Context, userId s } return &authenticators, nil } + +// DeleteAuthenticatorsByUserID removes every authenticator row for a user. +// Used by admin MFA reset. +func (p *provider) DeleteAuthenticatorsByUserID(ctx context.Context, userID string) error { + return p.db.Where("user_id = ?", userID).Delete(&schemas.Authenticator{}).Error +} diff --git a/internal/storage/db/sql/provider_migration_test.go b/internal/storage/db/sql/provider_migration_test.go index cc23d834..bfc621d6 100644 --- a/internal/storage/db/sql/provider_migration_test.go +++ b/internal/storage/db/sql/provider_migration_test.go @@ -263,3 +263,23 @@ func TestStaleUniqueConstraintMigration(t *testing.T) { }) } } + +// TestOAuthStateTableName asserts the SQL OAuthState table is named +// authorizer_oauth_states, not the authorizer_o_auth_states GORM's naming +// strategy would otherwise derive from the struct name (it splits "OAuth" +// into "O"+"Auth"). Every other storage provider already uses +// authorizer_oauth_states; schemas.OAuthState.TableName() keeps SQL in sync. +func TestOAuthStateTableName(t *testing.T) { + for _, dbType := range sqlMigrationTestDBTypes() { + t.Run(dbType, func(t *testing.T) { + cfg := sqlMigrationTestConfig(t, dbType) + p, err := NewProvider(cfg, sqlTestDeps(t)) + require.NoError(t, err) + + assert.True(t, p.db.Migrator().HasTable(schemas.Collections.OAuthState), + "authorizer_oauth_states should exist") + assert.False(t, p.db.Migrator().HasTable(schemas.Prefix+"o_auth_states"), + "mangled authorizer_o_auth_states should not exist") + }) + } +} diff --git a/internal/storage/provider.go b/internal/storage/provider.go index b00af17a..dc5505ea 100644 --- a/internal/storage/provider.go +++ b/internal/storage/provider.go @@ -130,6 +130,9 @@ type Provider interface { // GetAuthenticatorDetailsByUserId retrieves details of an authenticator document based on user ID and authenticator type. // If found, the authenticator document is returned, or an error if not found or an error occurs during the retrieval. GetAuthenticatorDetailsByUserId(ctx context.Context, userId string, authenticatorType string) (*schemas.Authenticator, error) + // DeleteAuthenticatorsByUserID removes every authenticator row (TOTP, + // email OTP, SMS OTP) for a user. Used by admin MFA reset. + DeleteAuthenticatorsByUserID(ctx context.Context, userID string) error // Session Token methods (for database-backed memory store) // AddSessionToken adds a session token to the database diff --git a/internal/storage/provider_test.go b/internal/storage/provider_test.go index b5f9c686..3fffd326 100644 --- a/internal/storage/provider_test.go +++ b/internal/storage/provider_test.go @@ -161,7 +161,7 @@ func TestStorageProvider(t *testing.T) { }) t.Run("Verification Request Operations", func(t *testing.T) { - testVerificationRequestOperations(t, ctx, provider) + testVerificationRequestOperations(t, ctx, provider, dbType) }) t.Run("Webhook Operations", func(t *testing.T) { @@ -570,7 +570,7 @@ func testUserOperations(t *testing.T, ctx context.Context, provider Provider, db } -func testVerificationRequestOperations(t *testing.T, ctx context.Context, provider Provider) { +func testVerificationRequestOperations(t *testing.T, ctx context.Context, provider Provider, dbType string) { vr := &schemas.VerificationRequest{ Token: uuid.New().String(), Email: "test_" + uuid.New().String() + "@test.com", @@ -621,6 +621,42 @@ func testVerificationRequestOperations(t *testing.T, ctx context.Context, provid assert.Equal(t, vr.RedirectURI, listed.RedirectURI) assert.Equal(t, vr.ExpiresAt, listed.ExpiresAt) + // A second AddVerificationRequest for the same (email, identifier) - the + // normal resend-verification-email flow always deletes the old request + // first (see resend_verify_email.go), so this exercises what happens if + // that invariant is ever violated (e.g. a caller bug, or a race between + // two concurrent resends). Backend behavior intentionally differs here: + // SQL upserts on conflict (clause.OnConflict...DoUpdates, so the second + // call succeeds and replaces the pending request in place); Mongo has no + // such upsert logic, so its unique index must hard-reject instead, or a + // second silent row would accumulate. + dupToken := uuid.New().String() + dup, dupErr := provider.AddVerificationRequest(ctx, &schemas.VerificationRequest{ + Token: dupToken, + Email: vr.Email, + ExpiresAt: time.Now().Add(24 * time.Hour).Unix(), + Identifier: vr.Identifier, + Nonce: uuid.New().String(), + RedirectURI: "https://app.example.com/callback", + }) + if isSQLTestDB(dbType) { + assert.NoError(t, dupErr, "SQL upserts a duplicate (email, identifier) request rather than erroring") + refetched, err := provider.GetVerificationRequestByEmail(ctx, vr.Email, vr.Identifier) + require.NoError(t, err) + assert.Equal(t, dupToken, refetched.Token, "upsert must replace the pending request, not add a second one") + } else if dbType == constants.DbTypeMongoDB { + // Regression guard: this compound index used a multi-key bson.M, which + // the driver silently rejects at CreateIndexes time - the constraint + // was never actually created, so this duplicate would have been + // accepted, leaving two pending requests for the same identity. + assert.Error(t, dupErr, "duplicate (email, identifier) verification request must be rejected") + } else if dupErr == nil { + // Cassandra/DynamoDB/ArangoDB/Couchbase: neither upsert nor a unique + // constraint exist yet for this pair - known gap, not fixed here. + // Clean up the extra row so it doesn't leak into other subtests. + _ = provider.DeleteVerificationRequest(ctx, dup) + } + // Test DeleteVerificationRequest err = provider.DeleteVerificationRequest(ctx, vr) assert.NoError(t, err) diff --git a/internal/storage/schemas/client.go b/internal/storage/schemas/client.go index 926db964..c250703c 100644 --- a/internal/storage/schemas/client.go +++ b/internal/storage/schemas/client.go @@ -133,6 +133,21 @@ func (s *Client) ParsedAllowedScopes() []string { return scopes } +// ParsedRedirectURIs returns RedirectURIs as a slice: comma-separated, +// whitespace trimmed, empty segments dropped. An empty result means this +// client has no registered redirect URIs — callers must not treat that as +// "any redirect_uri is allowed"; it means per-client exact-match enforcement +// does not apply to this client (see AuthorizeHandler's redirect_uri check). +func (s *Client) ParsedRedirectURIs() []string { + uris := []string{} + for _, u := range strings.Split(s.RedirectURIs, ",") { + if u = strings.TrimSpace(u); u != "" { + uris = append(uris, u) + } + } + return uris +} + // AsAPIClient converts the storage record into the GraphQL model. // It never exposes ClientSecret — there is no client_secret field on // model.Client by design; the plaintext is surfaced only once via diff --git a/internal/storage/schemas/oauth_state.go b/internal/storage/schemas/oauth_state.go index 3eec5e71..7265465d 100644 --- a/internal/storage/schemas/oauth_state.go +++ b/internal/storage/schemas/oauth_state.go @@ -1,5 +1,13 @@ package schemas +// TableName pins the SQL table name to authorizer_oauth_states. Without this, +// GORM's naming strategy derives "o_auth_states" from the struct name (a known +// GORM quirk splitting "OAuth" into "O"+"Auth"), diverging from the +// authorizer_oauth_states name every other storage provider uses. +func (OAuthState) TableName() string { + return Collections.OAuthState +} + // OAuthState model for storing OAuth state in database // This replaces the in-memory storage for OAuth state when Redis is not configured type OAuthState struct { diff --git a/internal/storage/schemas/user.go b/internal/storage/schemas/user.go index 554c4364..e8f464de 100644 --- a/internal/storage/schemas/user.go +++ b/internal/storage/schemas/user.go @@ -37,10 +37,15 @@ type User struct { // 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"` + 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"` + // MFALockedAt is set when the user explicitly reports losing access to + // their only MFA factor(s) (no verified Email/SMS OTP fallback + // enrolled) via lock_mfa. Nil means not locked. Only an admin clearing + // it via _update_user{reset_mfa: true} removes it — see admin_users.go. + MFALockedAt *int64 `json:"mfa_locked_at" bson:"mfa_locked_at" cql:"mfa_locked_at" dynamo:"mfa_locked_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 @@ -85,6 +90,7 @@ func (user *User) AsAPIUser() *model.User { RevokedTimestamp: user.RevokedTimestamp, IsMultiFactorAuthEnabled: user.IsMultiFactorAuthEnabled, HasSkippedMfaSetupAt: user.HasSkippedMFASetupAt, + MfaLockedAt: user.MFALockedAt, CreatedAt: refs.NewInt64Ref(user.CreatedAt), UpdatedAt: refs.NewInt64Ref(user.UpdatedAt), AppData: appDataMap, diff --git a/internal/token/auth_time_test.go b/internal/token/auth_time_test.go new file mode 100644 index 00000000..18b8404c --- /dev/null +++ b/internal/token/auth_time_test.go @@ -0,0 +1,45 @@ +package token + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/authorizerdev/authorizer/internal/config" + "github.com/authorizerdev/authorizer/internal/storage/schemas" +) + +// TestCreateSessionToken_PreservesAuthTimeAcrossRollover is a regression test +// for the oidcc-prompt-none-logged-in / oidcc-max-age-10000 conformance +// failures: a session rollover (silent SSO continuation) must NOT reset +// auth_time, even though IssuedAt legitimately refreshes every call. +func TestCreateSessionToken_PreservesAuthTimeAcrossRollover(t *testing.T) { + p := &provider{config: &config.Config{ClientSecret: "test-secret"}} + user := &schemas.User{ID: "user-1"} + + originalLogin := time.Now().Add(-5 * time.Minute).Unix() + + // First mint (real login): no AuthTime supplied, defaults to now. + firstSession, _, _, err := p.CreateSessionToken(&AuthTokenConfig{User: user}) + require.NoError(t, err) + require.NotZero(t, firstSession.AuthTime) + + // Rollover: caller explicitly threads the ORIGINAL auth time forward, + // as authorize.go's rollover call sites now do via EffectiveAuthTime(). + rolledSession, _, _, err := p.CreateSessionToken(&AuthTokenConfig{User: user, AuthTime: originalLogin}) + require.NoError(t, err) + + assert.Equal(t, originalLogin, rolledSession.AuthTime, "AuthTime must survive rollover unchanged") + assert.NotEqual(t, originalLogin, rolledSession.IssuedAt, "IssuedAt must still refresh on rollover") +} + +func TestSessionData_EffectiveAuthTime_FallsBackToIssuedAt(t *testing.T) { + // Pre-fix session cookie: AuthTime never existed, unmarshals to zero value. + old := &SessionData{IssuedAt: 12345} + assert.Equal(t, int64(12345), old.EffectiveAuthTime()) + + fixed := &SessionData{IssuedAt: 12345, AuthTime: 999} + assert.Equal(t, int64(999), fixed.EffectiveAuthTime()) +} diff --git a/internal/token/auth_token.go b/internal/token/auth_token.go index dbf3fb94..2494851d 100644 --- a/internal/token/auth_token.go +++ b/internal/token/auth_token.go @@ -77,6 +77,13 @@ type AuthTokenConfig struct { // token claim. If zero, CreateIDToken falls back to time.Now() so // existing callers continue to work unchanged (backward compat). AuthTime int64 + // ClientID is the OAuth client the refresh token is being minted for — + // the client that authenticated at /oauth/token when the token was + // issued or last rotated. CreateRefreshToken embeds it as the + // "client_id" claim so a later refresh_token redemption can be bound + // to the same client (RFC 6749 §6). Empty is valid (client_credentials + // / machine paths that never mint a refresh token). + ClientID string } // loginMethodToAMR maps an internal LoginMethod value to the OIDC Core §2 @@ -121,16 +128,34 @@ type AuthToken struct { } // SessionData holds the session claims persisted for a user session. +// +// IssuedAt is stamped fresh on every CreateSessionToken call, including +// silent rollovers of an already-authenticated session. AuthTime is the +// timestamp of the End-User's actual last authentication (OIDC Core §2's +// auth_time) and MUST survive rollovers unchanged — conflating the two +// previously caused auth_time and max_age staleness checks to reset on +// every silent /authorize call, defeating both. type SessionData struct { Subject string `json:"sub"` Roles []string `json:"roles"` Scope []string `json:"scope"` Nonce string `json:"nonce"` IssuedAt int64 `json:"iat"` + AuthTime int64 `json:"auth_time"` ExpiresAt int64 `json:"exp"` LoginMethod string `json:"login_method"` } +// EffectiveAuthTime returns AuthTime, falling back to IssuedAt for session +// cookies minted before AuthTime existed (unmarshal leaves it at the zero +// value with no error). +func (sd *SessionData) EffectiveAuthTime() int64 { + if sd.AuthTime != 0 { + return sd.AuthTime + } + return sd.IssuedAt +} + // CreateAuthToken creates a new auth token when userlogs in func (p *provider) CreateAuthToken(gc *gin.Context, cfg *AuthTokenConfig) (*AuthToken, error) { _, fingerPrintHash, sessionTokenExpiresAt, err := p.CreateSessionToken(cfg) @@ -185,6 +210,10 @@ func (p *provider) CreateAuthToken(gc *gin.Context, cfg *AuthTokenConfig) (*Auth // CreateSessionToken creates a new session token func (p *provider) CreateSessionToken(cfg *AuthTokenConfig) (*SessionData, string, int64, error) { expiresAt := time.Now().Add(24 * time.Hour).Unix() + authTime := cfg.AuthTime + if authTime == 0 { + authTime = time.Now().Unix() + } fingerPrintMap := &SessionData{ Nonce: cfg.Nonce, Roles: cfg.Roles, @@ -192,6 +221,7 @@ func (p *provider) CreateSessionToken(cfg *AuthTokenConfig) (*SessionData, strin Scope: cfg.Scope, LoginMethod: cfg.LoginMethod, IssuedAt: time.Now().Unix(), + AuthTime: authTime, ExpiresAt: expiresAt, } fingerPrintBytes, _ := json.Marshal(fingerPrintMap) @@ -203,6 +233,19 @@ func (p *provider) CreateSessionToken(cfg *AuthTokenConfig) (*SessionData, strin return fingerPrintMap, fingerPrintHash, expiresAt, nil } +// audience returns the OIDC "aud" claim value: the OAuth client the token is +// being issued to. cfg.ClientID carries the actual requesting client for the +// /authorize and /oauth/token flows; callers that mint a token for +// Authorizer's own hosted app (GraphQL login/signup, social/SAML/SSO +// callbacks establishing the browser session) leave it unset, and the +// reserved bootstrap client_id remains the audience — unchanged behavior. +func (p *provider) audience(cfg *AuthTokenConfig) string { + if cfg.ClientID != "" { + return cfg.ClientID + } + return p.config.ClientID +} + // CreateRefreshToken util to create JWT token func (p *provider) CreateRefreshToken(cfg *AuthTokenConfig) (string, int64, error) { // Lifetime is configurable via --refresh-token-expires-in (seconds). @@ -213,18 +256,24 @@ func (p *provider) CreateRefreshToken(cfg *AuthTokenConfig) (string, int64, erro } expiryBound := time.Duration(expirySeconds) * time.Second expiresAt := time.Now().Add(expiryBound).Unix() + authTime := cfg.AuthTime + if authTime == 0 { + authTime = time.Now().Unix() + } customClaims := jwt.MapClaims{ "iss": cfg.HostName, - "aud": p.config.ClientID, + "aud": p.audience(cfg), "sub": cfg.User.ID, "exp": expiresAt, "iat": time.Now().Unix(), + "auth_time": authTime, "token_type": constants.TokenTypeRefreshToken, "roles": cfg.Roles, "scope": cfg.Scope, "nonce": cfg.Nonce, "login_method": cfg.LoginMethod, "allowed_roles": strings.Split(cfg.User.Roles, ","), + "client_id": cfg.ClientID, } token, err := p.SignJWTToken(customClaims) @@ -252,7 +301,7 @@ func (p *provider) CreateAccessToken(cfg *AuthTokenConfig) (string, int64, error expiresAt := time.Now().Add(expiryBound).Unix() customClaims := jwt.MapClaims{ "iss": cfg.HostName, - "aud": p.config.ClientID, + "aud": p.audience(cfg), "nonce": cfg.Nonce, "sub": cfg.User.ID, "exp": expiresAt, @@ -383,11 +432,24 @@ func (p *provider) ValidateAccessToken(gc *gin.Context, accessToken string) (map return res, fmt.Errorf(`unauthorized`) } + if p.userIsRevoked(gc, userID) { + p.dependencies.Log.Debug().Str("user_id", userID).Msg("access token rejected: user revoked") + return res, fmt.Errorf(`unauthorized: user revoked`) + } + + // /userinfo and the generic session-or-access-token resolver present no + // client credentials — a bearer access token is accepted from whichever + // client it was issued to, there being no request-time client identity to + // bind it to. Trust the token's own "aud" (set at issuance, protected by + // the JWT signature) as the expected value; the checks above already + // establish the token is a genuine, unexpired, unrevoked Authorizer token. + aud, _ := res["aud"].(string) hostname := parsers.GetHost(gc) if ok, err := p.ValidateJWTClaims(res, &AuthTokenConfig{ HostName: hostname, Nonce: nonce, User: &schemas.User{ID: userID}, + ClientID: aud, }); !ok || err != nil { return res, err } @@ -399,8 +461,10 @@ func (p *provider) ValidateAccessToken(gc *gin.Context, accessToken string) (map return res, nil } -// Function to validate refreshToken -func (p *provider) ValidateRefreshToken(gc *gin.Context, refreshToken string) (map[string]interface{}, error) { +// Function to validate refreshToken. expectedClientID is the OAuth client +// presenting the token at the token endpoint (RFC 6749 §6 client binding) — +// it must match the "aud" claim the token was issued with. +func (p *provider) ValidateRefreshToken(gc *gin.Context, refreshToken string, expectedClientID string) (map[string]interface{}, error) { res := make(map[string]interface{}) if refreshToken == "" { @@ -439,6 +503,7 @@ func (p *provider) ValidateRefreshToken(gc *gin.Context, refreshToken string) (m HostName: hostname, Nonce: nonce, User: &schemas.User{ID: userID}, + ClientID: expectedClientID, }); !ok || err != nil { return res, err } @@ -484,9 +549,35 @@ func (p *provider) ValidateBrowserSession(gc *gin.Context, encryptedSession stri return nil, fmt.Errorf(`unauthorized: token expired`) } + if p.userIsRevoked(gc, res.Subject) { + p.dependencies.Log.Debug().Str("user_id", res.Subject).Msg("browser session rejected: user revoked") + return nil, fmt.Errorf(`unauthorized: user revoked`) + } + return &res, nil } +// userIsRevoked re-checks the DB RevokedTimestamp for a user resolved from an +// already-issued access token or browser session. This is defense-in-depth: +// the session-store deletion SCIM deactivate() (and account deactivation) +// perform is the primary revocation mechanism for these stateful tokens, but +// if that delete was missed or failed on this instance, a held token would +// otherwise keep authenticating requests until its natural exp. Mirrors the +// same demote-only pattern used by introspect.go/token.go/login.go: a lookup +// failure never blocks a request that otherwise validated (fail open on DB +// errors so a transient storage blip can't take down every authenticated +// request), only a confirmed RevokedTimestamp does. +func (p *provider) userIsRevoked(gc *gin.Context, userID string) bool { + if p.dependencies.StorageProvider == nil || userID == "" { + return false + } + user, err := p.dependencies.StorageProvider.GetUserByID(gc, userID) + if err != nil || user == nil { + return false + } + return user.RevokedTimestamp != nil +} + // CreateIDToken util to create the OIDC ID token JWT, based on user // information, roles config and CUSTOM_ACCESS_TOKEN_SCRIPT. // See the in-function block comment for the at_hash / c_hash / nonce @@ -504,7 +595,7 @@ func (p *provider) CreateIDToken(cfg *AuthTokenConfig) (string, int64, error) { customClaims := jwt.MapClaims{ "iss": cfg.HostName, - "aud": p.config.ClientID, + "aud": p.audience(cfg), "sub": cfg.User.ID, "exp": expiresAt, "iat": time.Now().Unix(), diff --git a/internal/token/jwt.go b/internal/token/jwt.go index 9c35eceb..c18232df 100644 --- a/internal/token/jwt.go +++ b/internal/token/jwt.go @@ -151,7 +151,7 @@ func (p *provider) ParseJWTToken(token string) (jwt.MapClaims, error) { // ValidateJWTClaims common util to validate claims func (p *provider) ValidateJWTClaims(claims jwt.MapClaims, authTokenConfig *AuthTokenConfig) (bool, error) { - if claims["aud"] != p.config.ClientID { + if claims["aud"] != p.audience(authTokenConfig) { return false, errors.New("invalid audience") } @@ -172,7 +172,7 @@ func (p *provider) ValidateJWTClaims(claims jwt.MapClaims, authTokenConfig *Auth // ValidateJWTTokenWithoutNonce common util to validate claims without nonce func (p *provider) ValidateJWTTokenWithoutNonce(claims jwt.MapClaims, authTokenConfig *AuthTokenConfig) (bool, error) { - if claims["aud"] != p.config.ClientID { + if claims["aud"] != p.audience(authTokenConfig) { return false, errors.New("invalid audience") } diff --git a/internal/token/provider.go b/internal/token/provider.go index 1064d2ae..21585a50 100644 --- a/internal/token/provider.go +++ b/internal/token/provider.go @@ -10,12 +10,19 @@ import ( "github.com/authorizerdev/authorizer/internal/config" "github.com/authorizerdev/authorizer/internal/memory_store" + "github.com/authorizerdev/authorizer/internal/storage" ) // Dependencies struct for token provider type Dependencies struct { Log *zerolog.Logger MemoryStoreProvider memory_store.Provider + // StorageProvider backs the revocation re-check in ValidateAccessToken / + // ValidateBrowserSession: defense-in-depth so a deprovisioned user + // (RevokedTimestamp set — SCIM active:false, account deactivation) loses + // request-serving access even if the session-store delete that normally + // invalidates the token was missed or failed on this instance. + StorageProvider storage.Provider } type provider struct { @@ -66,8 +73,10 @@ type Provider interface { ValidateJWTClaims(claims jwt.MapClaims, authTokenConfig *AuthTokenConfig) (bool, error) // ValidateJWTTokenWithoutNonce validates jwt token without nonce ValidateJWTTokenWithoutNonce(claims jwt.MapClaims, authTokenConfig *AuthTokenConfig) (bool, error) - // ValidateRefreshToken validates refresh token - ValidateRefreshToken(gc *gin.Context, refreshToken string) (map[string]interface{}, error) + // ValidateRefreshToken validates refresh token. expectedClientID is the + // OAuth client presenting the token at the token endpoint — it MUST match + // the token's "aud" claim (the client it was issued to). + ValidateRefreshToken(gc *gin.Context, refreshToken string, expectedClientID string) (map[string]interface{}, error) // NotifyBackchannelLogout signs and POSTs an OIDC Back-Channel Logout // 1.0 logout_token to the supplied URI. Intended to be invoked from a // goroutine; remote HTTP failures are not surfaced beyond the local error. diff --git a/proto/authorizer/v1/admin.proto b/proto/authorizer/v1/admin.proto index 638121c7..cc829ce7 100644 --- a/proto/authorizer/v1/admin.proto +++ b/proto/authorizer/v1/admin.proto @@ -501,6 +501,11 @@ message UpdateUserRequest { repeated string roles = 13; optional bool is_multi_factor_auth_enabled = 14; AppData app_data = 15; + // Mirrors UpdateUserRequest.reset_mfa in GraphQL — see schema.graphqls' + // doc comment on that field for exact semantics (clears mfa_locked_at, + // is_multi_factor_auth_enabled, has_skipped_mfa_setup_at, deletes all + // authenticators/passkeys). + optional bool reset_mfa = 16; } message UpdateUserResponse { User user = 1; diff --git a/proto/authorizer/v1/authorizer.proto b/proto/authorizer/v1/authorizer.proto index 8ad913b7..bfafe41f 100644 --- a/proto/authorizer/v1/authorizer.proto +++ b/proto/authorizer/v1/authorizer.proto @@ -2,7 +2,8 @@ // public API. Method names match the GraphQL operation names 1:1 // (snake_case in GraphQL → PascalCase in proto): Signup, Login, // MagicLinkLogin, VerifyEmail, ResendVerifyEmail, ForgotPassword, -// ResetPassword, VerifyOtp, ResendOtp, UpdateProfile, DeactivateAccount, +// ResetPassword, VerifyOtp, ResendOtp, SkipMfaSetup, LockMfa, +// EmailOtpMfaSetup, SmsOtpMfaSetup, UpdateProfile, DeactivateAccount, // Revoke, Meta, Session, Profile, ValidateJwtToken, ValidateSession, // CheckPermissions, ListPermissions, Logout. // @@ -107,6 +108,65 @@ service AuthorizerService { option (authorizer.v1.public) = true; } + // SkipMfaSetup completes an in-progress, token-withheld MFA offer by + // recording that the caller explicitly declined it, then issues the + // access token that was withheld. Identified by the MFA session cookie + // (set when the offer screen was returned) plus email/phone_number to + // resolve the pending user — same identification pattern as VerifyOtp. + // Fails with FAILED_PRECONDITION if MFA is organization-enforced + // (enforce-mfa) — enforcement is never skippable. + rpc SkipMfaSetup(SkipMfaSetupRequest) returns (AuthResponse) { + option (google.api.http) = { + post: "/v1/skip_mfa_setup" + body: "*" + }; + option (authorizer.v1.public) = true; + option (authorizer.v1.audit_log) = true; + } + + // LockMfa records that the caller lost access to their only MFA + // factor(s). Only allowed when the caller has NO verified Email/SMS OTP + // fallback enrolled — if one exists, use it instead of locking. Does not + // issue a token; the account requires admin recovery afterward. + rpc LockMfa(LockMfaRequest) returns (LockMfaResponse) { + option (google.api.http) = { + post: "/v1/lock_mfa" + body: "*" + }; + option (authorizer.v1.public) = true; + option (authorizer.v1.audit_log) = true; + } + + // EmailOtpMfaSetup sends a one-time code to the caller's own email and + // creates an unverified email-OTP MFA enrollment. Dual-mode permissions: + // (a) an authenticated caller (bearer token) — the settings-screen action + // for an ALREADY-logged-in user adding a second factor; the request body is + // unused in this mode. (b) a caller in the withheld first-time-offer + // state, with no bearer token yet — identified by the MFA session cookie + // plus email/phone_number, same pattern as SkipMfaSetup. Either mode + // reuses the same underlying Authenticator row once VerifyOtp marks it + // verified. + rpc EmailOtpMfaSetup(EmailOtpMfaSetupRequest) returns (EmailOtpMfaSetupResponse) { + option (google.api.http) = { + post: "/v1/email_otp_mfa_setup" + body: "*" + }; + option (authorizer.v1.public) = true; + option (authorizer.v1.audit_log) = true; + } + + // SmsOtpMfaSetup sends a one-time code to the caller's own phone number + // and creates an unverified SMS-OTP MFA enrollment. Same dual-mode + // permissions and relationship to VerifyOtp as EmailOtpMfaSetup. + rpc SmsOtpMfaSetup(SmsOtpMfaSetupRequest) returns (SmsOtpMfaSetupResponse) { + option (google.api.http) = { + post: "/v1/sms_otp_mfa_setup" + body: "*" + }; + option (authorizer.v1.public) = true; + option (authorizer.v1.audit_log) = true; + } + // === Password lifecycle === rpc ForgotPassword(ForgotPasswordRequest) returns (ForgotPasswordResponse) { @@ -328,6 +388,43 @@ message ResendOtpResponse { string message = 1; } +message SkipMfaSetupRequest { + // Either email or phone_number is required, to resolve which user's MFA + // session cookie this is. + string email = 1 [(buf.validate.field).string.max_len = 320]; + string phone_number = 2 [(buf.validate.field).string.max_len = 32]; + string state = 3; +} +message LockMfaRequest { + // Either email or phone_number is required, to resolve which user's MFA + // session cookie this is — same pattern as SkipMfaSetupRequest. + string email = 1 [(buf.validate.field).string.max_len = 320]; + string phone_number = 2 [(buf.validate.field).string.max_len = 32]; +} +message LockMfaResponse { + string message = 1; +} +message EmailOtpMfaSetupRequest { + // Only used in the MFA-session-cookie mode (a caller in the withheld + // first-time-offer state, with no bearer token yet) to resolve which + // user's MFA session cookie this is — same pattern as SkipMfaSetupRequest + // / LockMfaRequest. Ignored when the caller has a valid bearer + // token/session, which already identifies the user. + string email = 1 [(buf.validate.field).string.max_len = 320]; + string phone_number = 2 [(buf.validate.field).string.max_len = 32]; +} +message EmailOtpMfaSetupResponse { + string message = 1; +} +message SmsOtpMfaSetupRequest { + // Same dual-mode identification semantics as EmailOtpMfaSetupRequest. + string email = 1 [(buf.validate.field).string.max_len = 320]; + string phone_number = 2 [(buf.validate.field).string.max_len = 32]; +} +message SmsOtpMfaSetupResponse { + string message = 1; +} + message ForgotPasswordRequest { string email = 1 [(buf.validate.field).string.max_len = 320]; string phone_number = 2 [(buf.validate.field).string.max_len = 32]; diff --git a/proto/authorizer/v1/types.proto b/proto/authorizer/v1/types.proto index 2af9a995..f4f7fc0d 100644 --- a/proto/authorizer/v1/types.proto +++ b/proto/authorizer/v1/types.proto @@ -32,6 +32,10 @@ message User { bool is_multi_factor_auth_enabled = 19; // Free-form key/value bag — same as GraphQL `app_data: Map`. AppData app_data = 20; + // Mirrors User.has_skipped_mfa_setup_at in GraphQL. 0 means never skipped. + int64 has_skipped_mfa_setup_at = 21; + // Mirrors User.mfa_locked_at in GraphQL. 0 means not locked. + int64 mfa_locked_at = 22; } // AuthResponse mirrors the GraphQL AuthResponse type. Returned (wrapped) by @@ -52,6 +56,14 @@ message AuthResponse { string authenticator_scanner_image = 10; string authenticator_secret = 11; repeated string authenticator_recovery_codes = 12; + // Mirrors AuthResponse.should_offer_webauthn_mfa_verify in GraphQL. + bool should_offer_webauthn_mfa_verify = 13; + // Mirrors AuthResponse.should_offer_webauthn_mfa_setup in GraphQL. + bool should_offer_webauthn_mfa_setup = 14; + // Mirrors AuthResponse.should_offer_email_otp_mfa_setup in GraphQL. + bool should_offer_email_otp_mfa_setup = 15; + // Mirrors AuthResponse.should_offer_sms_otp_mfa_setup in GraphQL. + bool should_offer_sms_otp_mfa_setup = 16; } // Permission is one (object, relation) pair the subject holds: "subject has @@ -123,4 +135,6 @@ message Meta { bool is_email_otp_mfa_enabled = 23; bool is_sms_otp_mfa_enabled = 24; bool is_webauthn_enabled = 25; + // Mirrors Meta.is_mfa_enforced in GraphQL. + bool is_mfa_enforced = 26; } diff --git a/web/app/package.json b/web/app/package.json index 02e11a91..f8f31283 100644 --- a/web/app/package.json +++ b/web/app/package.json @@ -13,7 +13,7 @@ "author": "Lakhan Samani", "license": "Apache-2.0", "dependencies": { - "@authorizerdev/authorizer-react": "2.2.0-rc.0", + "@authorizerdev/authorizer-react": "github:authorizerdev/authorizer-react#main", "react": "^18.3.1", "react-dom": "^18.3.1", "react-is": "^18.3.1", diff --git a/web/app/src/App.tsx b/web/app/src/App.tsx index 4677a8c0..ddefc1b3 100644 --- a/web/app/src/App.tsx +++ b/web/app/src/App.tsx @@ -32,7 +32,7 @@ export default function App() { if (redirectURL) { urlProps.redirectURL = redirectURL; } else { - urlProps.redirectURL = window.location.origin; + urlProps.redirectURL = `${window.location.origin}/app`; } const globalState: Record = { ...window['__authorizer__'], diff --git a/web/app/src/Root.tsx b/web/app/src/Root.tsx index 6f919dfa..889cb011 100644 --- a/web/app/src/Root.tsx +++ b/web/app/src/Root.tsx @@ -1,12 +1,14 @@ import { useEffect, lazy, Suspense } from 'react'; import { Routes, Route, Navigate } from 'react-router-dom'; -import { useAuthorizer } from '@authorizerdev/authorizer-react'; +import { AuthorizerMFASetup, AuthorizerVerifyOtp, useAuthorizer } from '@authorizerdev/authorizer-react'; +import { parseMfaRedirectParams } from '@authorizerdev/authorizer-js'; import SetupPassword from './pages/setup-password'; import { hasWindow, createRandomString } from './utils/common'; const ResetPassword = lazy(() => import('./pages/rest-password')); const Login = lazy(() => import('./pages/login')); const Dashboard = lazy(() => import('./pages/dashboard')); +const Settings = lazy(() => import('./pages/settings')); const SignUp = lazy(() => import('./pages/signup')); /** @@ -38,7 +40,16 @@ export default function Root({ }: { globalState: Record; }) { - const { token, loading, config } = useAuthorizer(); + const { token, loading, config, setAuthData } = useAuthorizer(); + + // The server redirects here with these params, instead of issuing a + // token, when its MFA gate withholds one - not just for the OAuth + // /authorize flow this originated for, but also the magic-link-login and + // signup-email-verification click-through URLs (GET /verify_email), + // which redirect to the same place with the same params. + const mfaRedirect = hasWindow() + ? parseMfaRedirectParams(window.location.href) + : null; const combinedParams = getCombinedParams(); const getParam = (key: string): string => combinedParams.get(key) || ''; @@ -58,7 +69,7 @@ export default function Root({ const rawRedirectURL = getParam('redirect_uri') || getParam('redirectURL'); urlProps.redirectURL = - rawRedirectURL || (hasWindow() ? window.location.origin : '/app'); + rawRedirectURL || (hasWindow() ? `${window.location.origin}/app` : '/app'); urlProps.redirect_uri = urlProps.redirectURL; @@ -106,14 +117,71 @@ export default function Root({ window.location.replace(`/authorize?${params.toString()}`); }, [token, isAuthorizeContext, state]); + // Both MFA gates below are reached via a server redirect carrying the + // gate state in the URL, not client-side navigation - there's no prior + // SPA screen to pop back to. "Back" here means abandoning the pending + // MFA session and returning to a clean /app (fresh login screen). + const backToLogin = () => window.location.replace('/app'); + if (loading) { return

Loading...

; } + if (mfaRedirect && mfaRedirect.mfaGate === 'verify') { + // An already-configured factor must be challenged, not offered setup + // again - no email/phone_number in hand (OAuth/magic-link return), but + // verify_otp resolves the pending user from the MFA session cookie + // alone, same as the passkey-primary-login continuation. + return ( + { + setAuthData({ + user: data?.user || null, + token: data, + config, + loading: false, + }); + }} + /> + ); + } + if (mfaRedirect && mfaRedirect.mfaGate === 'offer') { + return ( + { + setAuthData({ + user: data?.user || null, + token: data, + config, + loading: false, + }); + }, + }} + /> + ); + } if (token) { return ( }> } /> + } /> } /> diff --git a/web/app/src/pages/dashboard.tsx b/web/app/src/pages/dashboard.tsx index 42b7645e..aa683314 100644 --- a/web/app/src/pages/dashboard.tsx +++ b/web/app/src/pages/dashboard.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { useAuthorizer } from '@authorizerdev/authorizer-react'; +import { Link } from 'react-router-dom'; export default function Dashboard() { const [loading, setLoading] = React.useState(false); @@ -23,6 +24,12 @@ export default function Dashboard() {

+

+ + Manage MFA + +

+
{loading ? (

Processing....

diff --git a/web/app/src/pages/login.tsx b/web/app/src/pages/login.tsx index d309d3e8..2d688847 100644 --- a/web/app/src/pages/login.tsx +++ b/web/app/src/pages/login.tsx @@ -3,11 +3,12 @@ import { AuthorizerBasicAuthLogin, AuthorizerForgotPassword, AuthorizerMagicLinkLogin, + AuthorizerPasskeyLogin, AuthorizerSocialLogin, useAuthorizer, } from '@authorizerdev/authorizer-react'; import styled from 'styled-components'; -import { Link } from 'react-router-dom'; +import { Link, useLocation } from 'react-router-dom'; const enum VIEW_TYPES { LOGIN = 'login', @@ -82,6 +83,11 @@ async function homeRealmDiscovery( export default function Login({ urlProps }: { urlProps: Record }) { const { config } = useAuthorizer(); + // Preserved on the Sign Up link below: dropping the query string here + // (state/client_id/redirect_uri/...) strands a user who signs up + // mid-OAuth-flow on the dashboard with no way back to /authorize, since + // Root.tsx's resumption effect reads these from the current URL only. + const location = useLocation(); const [view, setView] = useState(VIEW_TYPES.LOGIN); // Email-first Home Realm Discovery: show an email field first. On an SSO // match we redirect to the org's SP-initiated login; otherwise we reveal the @@ -89,6 +95,22 @@ export default function Login({ urlProps }: { urlProps: Record }) { const [ssoResolved, setSsoResolved] = useState(false); const [hrdEmail, setHrdEmail] = useState(''); const [hrdChecking, setHrdChecking] = useState(false); + // AuthorizerPasskeyLogin and AuthorizerBasicAuthLogin each take over the + // whole login surface once their own sign-in needs a second factor (their + // own MFA setup/verify/locked screens) - every other login option, and + // the login attempt not currently in flight, don't belong stacked on top + // of those screens. + const [passkeyStep, setPasskeyStep] = useState< + 'button' | 'mfa-setup' | 'mfa-verify' | 'locked' + >('button'); + const [basicAuthStep, setBasicAuthStep] = useState< + 'form' | 'mfa-setup' | 'otp-verify' | 'locked' + >('form'); + const passkeyIdle = passkeyStep === 'button'; + const basicAuthIdle = basicAuthStep === 'form'; + // Social login, magic link, and the forgot-password/sign-up footers only + // make sense while both login attempts are idle. + const showChrome = passkeyIdle && basicAuthIdle; const handleHRDSubmit = async (e: React.FormEvent) => { e.preventDefault(); @@ -158,29 +180,40 @@ export default function Login({ urlProps }: { urlProps: Record }) { {view === VIEW_TYPES.LOGIN && (

Login

- -
- {(config.is_basic_authentication_enabled || - config.is_mobile_basic_authentication_enabled) && - !config.is_magic_link_login_enabled && ( - - )} - {config.is_magic_link_login_enabled && ( - + {showChrome && } + {basicAuthIdle && ( + + )} + {passkeyIdle && ( + +
+ {(config.is_basic_authentication_enabled || + config.is_mobile_basic_authentication_enabled) && + !config.is_magic_link_login_enabled && ( + + )} + {showChrome && config.is_magic_link_login_enabled && ( + + )} + {showChrome && + (config.is_basic_authentication_enabled || + config.is_mobile_basic_authentication_enabled) && + !config.is_magic_link_login_enabled && ( +
+ setView(VIEW_TYPES.FORGOT_PASSWORD)} + style={{ marginBottom: 10 }} + > + Forgot Password? + +
+ )} +
)} - {(config.is_basic_authentication_enabled || - config.is_mobile_basic_authentication_enabled) && - !config.is_magic_link_login_enabled && ( -
- setView(VIEW_TYPES.FORGOT_PASSWORD)} - style={{ marginBottom: 10 }} - > - Forgot Password? - -
- )}
)} {view === VIEW_TYPES.FORGOT_PASSWORD && ( @@ -206,11 +239,12 @@ export default function Login({ urlProps }: { urlProps: Record }) { )} - {config.is_basic_authentication_enabled && + {showChrome && + config.is_basic_authentication_enabled && !config.is_magic_link_login_enabled && config.is_sign_up_enabled && ( - Don't have an account?   Sign Up + Don't have an account?   Sign Up )} diff --git a/web/app/src/pages/settings.tsx b/web/app/src/pages/settings.tsx new file mode 100644 index 00000000..e5fd42e9 --- /dev/null +++ b/web/app/src/pages/settings.tsx @@ -0,0 +1,52 @@ +import React, { useEffect, useState } from 'react'; +import { + AuthorizerMFASetup, + useAuthorizer, +} from '@authorizerdev/authorizer-react'; +import { Link } from 'react-router-dom'; + +export default function Settings() { + const { user, config, authorizerRef } = useAuthorizer(); + // AuthorizerMFASetup only knows what the caller tells it - there's no + // per-user enrolment signal for TOTP/email-OTP/SMS-OTP, but passkeys can + // be checked directly so the Passkey row can highlight as already set up. + const [passkeyRegistered, setPasskeyRegistered] = useState(false); + + useEffect(() => { + authorizerRef.webauthnCredentials().then(({ data, errors }) => { + if (!errors?.length && data) { + setPasskeyRegistered(data.length > 0); + } + }); + }, [authorizerRef]); + + return ( +
+

Multi-factor authentication

+

+ Signed in as{' '} + + {user?.email} + + . Set up an additional sign-in method to secure your account. +

+
+ +
+
+ + Back to dashboard + +
+
+ ); +} diff --git a/web/app/src/pages/signup.tsx b/web/app/src/pages/signup.tsx index b7a0b037..12916f86 100644 --- a/web/app/src/pages/signup.tsx +++ b/web/app/src/pages/signup.tsx @@ -1,10 +1,7 @@ -import React, { Fragment } from 'react'; -import { - AuthorizerSignup, - AuthorizerSocialLogin, -} from '@authorizerdev/authorizer-react'; +import React, { Fragment, useState } from 'react'; +import { AuthorizerSignup } from '@authorizerdev/authorizer-react'; import styled from 'styled-components'; -import { Link } from 'react-router-dom'; +import { Link, useLocation } from 'react-router-dom'; const FooterContent = styled.div` display: flex; @@ -18,15 +15,29 @@ export default function SignUp({ }: { urlProps: Record; }) { + // Preserved on the Login link below - same reasoning as login.tsx's + // Sign Up link: dropping the OAuth query string strands the user. + const location = useLocation(); + // AuthorizerSignup shows its own screens internally (MFA setup, OTP + // verify, locked-out) once the account is created - "Already have an + // account? Login" only makes sense above the initial form, not stacked on + // top of those follow-up screens (a user mid-MFA-setup already has an + // account and isn't signing up again). + const [isBaseForm, setIsBaseForm] = useState(true); return (

Sign Up


- - - - Already have an account? Login - + setIsBaseForm(step === 'form')} + /> + {isBaseForm && ( + + Already have an account?   + Login + + )}
); } diff --git a/web/dashboard/src/components/MfaStatus.tsx b/web/dashboard/src/components/MfaStatus.tsx new file mode 100644 index 00000000..8bab5619 --- /dev/null +++ b/web/dashboard/src/components/MfaStatus.tsx @@ -0,0 +1,75 @@ +import React from 'react'; +import { Badge } from './ui/badge'; + +// Human-readable labels for the raw enrolled_mfa_methods identifiers the API +// returns. Anything unmapped (a future method) falls back to its raw id rather +// than being dropped. +const METHOD_LABELS: Record = { + totp: 'TOTP', + webauthn: 'Passkey', + email_otp: 'Email OTP', + sms_otp: 'SMS OTP', +}; + +interface MfaStatusProps { + // mfaServiceEnabled is the org-wide signal (_admin_meta + // .is_multi_factor_auth_service_enabled): whether ANY MFA method is usable + // on this server at all. + mfaServiceEnabled: boolean; + // enrolledMethods are the factors this user has actually verified. + enrolledMethods?: string[]; + // isMfaEnabled is the per-user required-at-login flag + // (is_multi_factor_auth_enabled). + isMfaEnabled?: boolean; +} + +// MfaStatus renders the three mutually-exclusive MFA states the dashboard shows +// per user. Shared by the Users table and the user-detail modal so both stay in +// lockstep. +// +// 1. Server has no MFA method available → "Disabled" (org-wide, muted). +// 2. Available, user enrolled nothing → "Not enrolled" (neutral). +// 3. Available, user has >=1 verified factor → "Enrolled" + which factors, +// plus whether MFA is actually required at login. +// +// State 2 also surfaces the required-at-login flag, since the reachable combo +// "is_multi_factor_auth_enabled=true but nothing enrolled yet" (e.g. mid +// offer/enroll flow) means the user will be forced to enroll at next login — +// worth showing rather than hiding behind a bare "Not enrolled". +const MfaStatus = ({ + mfaServiceEnabled, + enrolledMethods, + isMfaEnabled, +}: MfaStatusProps) => { + if (!mfaServiceEnabled) { + return Disabled; + } + + const methods = enrolledMethods ?? []; + const requiredLabel = isMfaEnabled ? 'Required at login' : 'Not required'; + + if (methods.length === 0) { + return ( +
+ Not enrolled + {requiredLabel} +
+ ); + } + + return ( +
+ Enrolled +
+ {methods.map((m) => ( + + {METHOD_LABELS[m] ?? m} + + ))} +
+ {requiredLabel} +
+ ); +}; + +export default MfaStatus; diff --git a/web/dashboard/src/components/ViewUserModal.tsx b/web/dashboard/src/components/ViewUserModal.tsx index a5a0bba8..6ba34086 100644 --- a/web/dashboard/src/components/ViewUserModal.tsx +++ b/web/dashboard/src/components/ViewUserModal.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { useClient } from 'urql'; import dayjs from 'dayjs'; import { Badge } from './ui/badge'; +import MfaStatus from './MfaStatus'; import { Dialog, DialogContent, DialogHeader, DialogTitle } from './ui/dialog'; import { UserOrganizationsQuery } from '../graphql/queries'; import type { @@ -13,10 +14,20 @@ import type { interface ViewUserModalProps { user: User | null; open: boolean; + // mfaServiceEnabled is the org-wide _admin_meta + // .is_multi_factor_auth_service_enabled signal, passed down from the Users + // page so the MFA status can distinguish "server has no MFA" from "user + // hasn't enrolled". + mfaServiceEnabled: boolean; onClose: () => void; } -const ViewUserModal = ({ user, open, onClose }: ViewUserModalProps) => { +const ViewUserModal = ({ + user, + open, + mfaServiceEnabled, + onClose, +}: ViewUserModalProps) => { const client = useClient(); const [orgs, setOrgs] = React.useState([]); const [orgLoading, setOrgLoading] = React.useState(false); @@ -103,13 +114,11 @@ const ViewUserModal = ({ user, open, onClose }: ViewUserModalProps) => { { label: 'MFA', value: ( - - {user.is_multi_factor_auth_enabled ? 'Enabled' : 'Disabled'} - + ), }, { diff --git a/web/dashboard/src/graphql/queries/index.ts b/web/dashboard/src/graphql/queries/index.ts index 6025b4a6..2e0a388a 100644 --- a/web/dashboard/src/graphql/queries/index.ts +++ b/web/dashboard/src/graphql/queries/index.ts @@ -42,6 +42,7 @@ export const UserDetailsQuery = ` created_at revoked_timestamp is_multi_factor_auth_enabled + enrolled_mfa_methods } } } diff --git a/web/dashboard/src/pages/Users.race.test.tsx b/web/dashboard/src/pages/Users.race.test.tsx new file mode 100644 index 00000000..0ec7eab8 --- /dev/null +++ b/web/dashboard/src/pages/Users.race.test.tsx @@ -0,0 +1,119 @@ +// @vitest-environment jsdom +import React from 'react'; +import { describe, expect, it, vi, afterEach } from 'vitest'; +import { + render, + screen, + fireEvent, + waitFor, + cleanup, +} from '@testing-library/react'; +import Users from './Users'; +import { TooltipProvider } from '../components/ui/tooltip'; +import type { User } from '../types'; + +// This project's vitest.config.ts does not set `test.globals: true`, so +// @testing-library/react's automatic afterEach(cleanup) never registers. +afterEach(cleanup); + +// Deferred promise so the test controls exactly when each mocked GraphQL +// response resolves, independent of when the request was fired. +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((res) => { + resolve = res; + }); + return { promise, resolve }; +} + +const makeUser = (id: string, email: string): User => ({ + id, + email, + email_verified: true, + signup_methods: 'basic_auth', + roles: ['user'], + created_at: 0, +}); + +const usersResponse = (users: User[], total: number) => ({ + data: { + _users: { + users, + pagination: { limit: 10, page: 1, offset: 0, total }, + }, + }, +}); + +const adminMetaResponse = { + data: { _admin_meta: { is_multi_factor_auth_service_enabled: true } }, +}; + +// urql's useClient is the only external dependency Users.tsx needs — mock it +// so query() responses are controlled per-call by the test. +vi.mock('urql', () => ({ + useClient: () => mockClient, +})); + +// eslint-disable-next-line prefer-const +let mockClient: { query: ReturnType }; + +describe('Users list — stale-response race guard', () => { + it('keeps the result of the most recently fired request, not the one that resolves last', async () => { + // Request A: fired on mount, resolves LAST (slow). + const requestA = deferred>(); + // Request B: fired by a search change before A resolves, resolves + // FIRST (fast) — the out-of-order case the guard must handle. + const requestB = deferred>(); + + let userQueryCall = 0; + mockClient = { + query: vi.fn(() => { + userQueryCall += 1; + // Call 1 is always AdminRolesQuery (fired once on mount). + if (userQueryCall === 1) { + return { toPromise: () => Promise.resolve(adminMetaResponse) }; + } + // Call 2 is the initial UserDetailsQuery (request A); call 3 is + // the one triggered by the search change (request B). + const which = userQueryCall === 2 ? requestA : requestB; + return { toPromise: () => which.promise }; + }), + }; + + render( + + + , + ); + + // Request A has fired (mount effect). + await waitFor(() => expect(mockClient.query).toHaveBeenCalledTimes(2)); + + // Trigger request B via the search box, before A resolves. The 300ms + // debounce means the second query call lands ~300ms after typing. + fireEvent.change( + screen.getByPlaceholderText('Search by email, name, or ID...'), + { target: { value: 'b' } }, + ); + await waitFor(() => expect(mockClient.query).toHaveBeenCalledTimes(3), { + timeout: 1000, + }); + + // B (the later-fired request) resolves first. + requestB.resolve(usersResponse([makeUser('b', 'b@example.com')], 1)); + await waitFor(() => + expect(screen.queryByText('b@example.com')).toBeTruthy(), + ); + + // A (the earlier-fired request) resolves after — its data must NOT + // overwrite the list, since a newer request (B) has since started. + requestA.resolve(usersResponse([makeUser('a', 'a@example.com')], 1)); + + // Give the resolved microtask a tick to flush into state if the guard + // were absent — confirm it stays absent even after settling. + await new Promise((r) => setTimeout(r, 50)); + + expect(screen.queryByText('a@example.com')).toBeFalsy(); + expect(screen.getByText('b@example.com')).toBeTruthy(); + }); +}); diff --git a/web/dashboard/src/pages/Users.tsx b/web/dashboard/src/pages/Users.tsx index 41f98930..d039a713 100644 --- a/web/dashboard/src/pages/Users.tsx +++ b/web/dashboard/src/pages/Users.tsx @@ -19,6 +19,7 @@ import EditUserModal from '../components/EditUserModal'; import DeleteUserModal from '../components/DeleteUserModal'; import InviteMembersModal from '../components/InviteMembersModal'; import ViewUserModal from '../components/ViewUserModal'; +import MfaStatus from '../components/MfaStatus'; import UserPermissionsModal from '../components/UserPermissionsModal'; import { Button } from '../components/ui/button'; import { Badge } from '../components/ui/badge'; @@ -112,7 +113,15 @@ export default function Users() { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + // Guards against out-of-order responses: if a page/search change fires a + // new request before an earlier one resolves, the earlier response must + // not overwrite the list with stale results. Each call captures the + // request id current at its start and checks it's still current before + // touching state. + const requestIdRef = React.useRef(0); + const updateUserList = async () => { + const requestId = ++requestIdRef.current; setLoading(true); const { data } = await client .query(UserDetailsQuery, { @@ -125,6 +134,10 @@ export default function Users() { }, }) .toPromise(); + if (requestId !== requestIdRef.current) { + // A newer request has since started; discard this stale response. + return; + } if (data?._users) { const { pagination, users } = data._users; const maxPages = getMaxPages(pagination as unknown as PaginationProps); @@ -230,12 +243,16 @@ export default function Users() { }; const multiFactorAuthUpdateHandler = async (user: User) => { + // Disabling must actually clear the user's enrolled authenticators + // (reset_mfa), not just flip the flag - otherwise their old TOTP + // secret/passkey/OTP enrollment sits in storage untouched and silently + // becomes live again the moment MFA is re-enabled for them, with no + // re-enrollment step and no visibility into that happening. const res = await client .mutation(UpdateUser, { - params: { - id: user.id, - is_multi_factor_auth_enabled: !user.is_multi_factor_auth_enabled, - }, + params: user.is_multi_factor_auth_enabled + ? { id: user.id, reset_mfa: true } + : { id: user.id, is_multi_factor_auth_enabled: true }, }) .toPromise(); if (res.data?._update_user?.id) { @@ -383,17 +400,11 @@ export default function Users() { - - {user.is_multi_factor_auth_enabled - ? 'Enabled' - : 'Disabled'} - + e.stopPropagation()}> @@ -499,6 +510,7 @@ export default function Users() { + + + + + + {/* Confirm before revoking. */} + !open && setPendingDelete(null)} + > + + + Revoke access? + + This removes the tuple immediately. + + + {pendingDelete && ( +
+ {pendingDelete.user} · {pendingDelete.relation} ·{' '} + {pendingDelete.object} +
+ )} + + + + +
+
+
{/* Add tuple form */}
@@ -499,7 +608,8 @@ const Tuples = () => { diff --git a/web/dashboard/src/types.ts b/web/dashboard/src/types.ts index 37db1618..eeb273f2 100644 --- a/web/dashboard/src/types.ts +++ b/web/dashboard/src/types.ts @@ -17,6 +17,10 @@ export interface User { updated_at?: number; revoked_timestamp?: number; is_multi_factor_auth_enabled?: boolean; + // enrolled_mfa_methods lists the factors this user has actually verified: + // any of "totp", "webauthn", "email_otp", "sms_otp". Distinct from + // is_multi_factor_auth_enabled (a required-at-login flag). + enrolled_mfa_methods?: string[]; preferred_username?: string; }