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/root.go b/cmd/root.go index 998df2d3..ece2412f 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 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/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/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..ec5eac7b 100644 --- a/internal/graph/generated/generated.go +++ b/internal/graph/generated/generated.go @@ -81,7 +81,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 +316,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 +325,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,7 +341,8 @@ 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 UpdateClient func(childComplexity int, params model.UpdateClientRequest) int UpdateEmailTemplate func(childComplexity int, params model.UpdateEmailTemplateRequest) int @@ -546,6 +552,7 @@ type ComplexityRoot struct { 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,7 +671,10 @@ 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) + 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) WebauthnRegistrationOptions(ctx context.Context, email *string) (*model.WebauthnRegistrationOptionsResponse, error) WebauthnRegistrationVerify(ctx context.Context, params model.WebauthnRegistrationVerifyRequest) (*model.Response, error) WebauthnLoginOptions(ctx context.Context, email *string) (*model.WebauthnLoginOptionsResponse, error) @@ -950,6 +960,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 +974,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 +2295,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 +2398,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 +2590,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 { @@ -3836,6 +3908,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 +4366,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 +4375,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 +4387,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 +4599,10 @@ 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 app_data: Map } @@ -4559,12 +4645,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 @@ -5249,6 +5346,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 +5754,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,10 +5896,33 @@ 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! # WebAuthn / passkey self-service ceremonies (no admin ` + "`" + `_` + "`" + ` prefix). webauthn_registration_options( email: String @@ -7135,6 +7287,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 +7343,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 +7623,62 @@ 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_update_profile_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { var err error args := map[string]any{} @@ -9572,6 +9836,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 +10199,8 @@ 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 "app_data": return ec.fieldContext_User_app_data(ctx, field) } @@ -14859,6 +15248,8 @@ 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 "app_data": return ec.fieldContext_User_app_data(ctx, field) } @@ -16201,6 +16592,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 +16683,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 +16774,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 +16865,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 +17122,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 +17451,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 +17564,98 @@ 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) @@ -17154,7 +17672,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_lock_mfa(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { fc = &graphql.FieldContext{ Object: "Mutation", Field: field, @@ -17168,6 +17686,135 @@ 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_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) + 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_sms_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_sms_otp_mfa_setup_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } return fc, nil } @@ -17399,6 +18046,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 +18331,8 @@ 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 "app_data": return ec.fieldContext_User_app_data(ctx, field) } @@ -23348,6 +24003,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 +24122,8 @@ 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 "app_data": return ec.fieldContext_User_app_data(ctx, field) } @@ -23790,6 +24453,8 @@ 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 "app_data": return ec.fieldContext_User_app_data(ctx, field) } @@ -27775,6 +28440,47 @@ 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_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 +28861,8 @@ 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 "app_data": return ec.fieldContext_User_app_data(ctx, field) } @@ -28372,6 +29080,8 @@ 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 "app_data": return ec.fieldContext_User_app_data(ctx, field) } @@ -33481,6 +34191,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 +34658,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 +35213,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 +36342,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 +36447,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) @@ -36235,6 +37061,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 +38394,27 @@ 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 "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) @@ -39755,6 +40608,8 @@ 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 "app_data": out.Values[i] = ec._User_app_data(ctx, field, obj) default: @@ -41429,6 +42284,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 +42797,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 +43926,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..126b25b9 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,7 @@ 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"` AppData map[string]any `json:"app_data,omitempty"` } diff --git a/internal/graph/schema.graphqls b/internal/graph/schema.graphqls index 8c7f3d42..0cf8ce49 100644 --- a/internal/graph/schema.graphqls +++ b/internal/graph/schema.graphqls @@ -90,6 +90,10 @@ 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 app_data: Map } @@ -132,12 +136,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 @@ -822,6 +837,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 +1245,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,10 +1387,33 @@ 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! # WebAuthn / passkey self-service ceremonies (no admin `_` prefix). webauthn_registration_options( email: String diff --git a/internal/graph/schema.resolvers.go b/internal/graph/schema.resolvers.go index fba97056..4fb74542 100644 --- a/internal/graph/schema.resolvers.go +++ b/internal/graph/schema.resolvers.go @@ -83,8 +83,23 @@ 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) } // WebauthnRegistrationOptions is the resolver for the webauthn_registration_options field. 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..8fb2199f --- /dev/null +++ b/internal/graphql/otp_mfa_setup.go @@ -0,0 +1,45 @@ +// 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 +} diff --git a/internal/graphql/provider.go b/internal/graphql/provider.go index 11d7ea71..7a92e3cf 100644 --- a/internal/graphql/provider.go +++ b/internal/graphql/provider.go @@ -96,9 +96,26 @@ 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) // DeleteEmailTemplate is the method to delete email template. // Permissions: authorizer:admin DeleteEmailTemplate(ctx context.Context, params *model.DeleteEmailTemplateRequest) (*model.Response, error) diff --git a/internal/graphql/skip_mfa_setup.go b/internal/graphql/skip_mfa_setup.go 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/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/oauth_authorize_state_test.go b/internal/http_handlers/oauth_authorize_state_test.go index ed5adcf2..ad5e7940 100644 --- a/internal/http_handlers/oauth_authorize_state_test.go +++ b/internal/http_handlers/oauth_authorize_state_test.go @@ -213,16 +213,21 @@ type fakeMemoryStore struct { 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 { 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/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/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/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_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..ba4ec217 --- /dev/null +++ b/internal/integration_tests/oauth_mfa_gate_test.go @@ -0,0 +1,226 @@ +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.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") + }) + + 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") + + 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/otp_mfa_setup_test.go b/internal/integration_tests/otp_mfa_setup_test.go new file mode 100644 index 00000000..5fac9fd7 --- /dev/null +++ b/internal/integration_tests/otp_mfa_setup_test.go @@ -0,0 +1,361 @@ +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" +) + +// 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, enroll a second factor. + 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, not the bearer + // token -- arm a fresh session directly (same approach as + // TestVerifyOTPNoRecord) rather than threading the earlier one through. + verifySession := uuid.NewString() + require.NoError(t, ts.MemoryStoreProvider.SetMfaSession(user.ID, verifySession, constants.MFASessionPurposeVerified, time.Now().Add(5*time.Minute).Unix())) + 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") + + // 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/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..7f8c42c0 100644 --- a/internal/integration_tests/test_helper.go +++ b/internal/integration_tests/test_helper.go @@ -424,3 +424,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/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..4691bacf 100644 --- a/internal/integration_tests/webauthn_enforce_mfa_test.go +++ b/internal/integration_tests/webauthn_enforce_mfa_test.go @@ -64,8 +64,15 @@ func assertPasskeyLogin(t *testing.T, ts *testSetup, rp virtualwebauthn.RelyingP } func TestWebauthnLoginVerifyEnforceMFA(t *testing.T) { - t.Run("EnforceMFA=false — unchanged, issues token", func(t *testing.T) { + t.Run("EnforceMFA=false, user opted into optional MFA, unenrolled — withholds token and offers setup", func(t *testing.T) { + // This is the exact bypass Task 3 closes: previously WebauthnLoginVerify + // only gated on EnforceMFA, so a passkey-primary login for a user with + // optional (not enforced) MFA enabled but never enrolled/skipped got a + // token unconditionally, skipping the first-time offer entirely. Now it + // goes through the same resolveMFAGate gate password login uses, and + // mfaGateOfferAll withholds the token same as login.go's TOTP branch. cfg := getTestConfig() + cfg.EnableWebauthnMFA = true ts := initTestSetup(t, cfg) user, rp, authenticator, credential := registerPasskeyForNewUser(t, ts) user.IsMultiFactorAuthEnabled = refs.NewBoolRef(true) @@ -74,26 +81,34 @@ func TestWebauthnLoginVerifyEnforceMFA(t *testing.T) { 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) + assert.Nil(t, authRes.AccessToken, "a first-time optional-MFA offer must withhold the token even for passkey-primary login") + assert.True(t, refs.BoolValue(authRes.ShouldOfferWebauthnMfaSetup)) + // This test config never sets EnableTOTPLogin, so no TOTP enrollment + // payload is offered alongside the WebAuthn offer. + assert.False(t, refs.BoolValue(authRes.ShouldShowTotpScreen)) + assert.Nil(t, authRes.AuthenticatorSecret) }) - t.Run("EnforceMFA=true, user MFA not individually enabled — unaffected", func(t *testing.T) { + t.Run("EnforceMFA=true overrides an individual opt-out — token withheld", 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. + // Turn the user's individual flag off, as an admin could have. Before + // the EnforceMFA-is-absolute fix this issued a token unconditionally + // (the persisted false short-circuited the gate to mfaGateNone). Now the + // org-wide mandate wins: the gate still applies and withholds the token. user.IsMultiFactorAuthEnabled = refs.NewBoolRef(false) _, err := ts.StorageProvider.UpdateUser(t.Context(), user) require.NoError(t, err) authRes, err := assertPasskeyLogin(t, ts, rp, authenticator, credential) require.NoError(t, err) - require.NotNil(t, authRes.AccessToken) + require.NotNil(t, authRes) + assert.Nil(t, authRes.AccessToken, "EnforceMFA must override an individual opt-out and withhold the token") + assert.True(t, refs.BoolValue(authRes.ShouldShowTotpScreen), "enforced enrollment must offer the TOTP setup screen") }) t.Run("EnforceMFA=true, TOTP verified — blocks token, offers totp screen", func(t *testing.T) { diff --git a/internal/integration_tests/webauthn_test.go b/internal/integration_tests/webauthn_test.go index b95132f2..54b66d85 100644 --- a/internal/integration_tests/webauthn_test.go +++ b/internal/integration_tests/webauthn_test.go @@ -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)) @@ -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/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/forgot_password.go b/internal/service/forgot_password.go index 55065df1..cb026c77 100644 --- a/internal/service/forgot_password.go +++ b/internal/service/forgot_password.go @@ -160,7 +160,9 @@ 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 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..1a0f8e8b 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,7 +37,10 @@ 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) { @@ -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,26 @@ 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") + } + + // 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 +329,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, @@ -348,13 +340,18 @@ func (p *provider) Login(ctx context.Context, meta RequestMetadata, params *mode }() return &model.AuthResponse{ Message: "Please check email inbox for the OTP", - ShouldShowEmailOtpScreen: refs.NewBoolRef(isEmailLogin), + ShouldShowEmailOtpScreen: refs.NewBoolRef(true), }, 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,17 +366,23 @@ 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{ Message: "Please check text message for the OTP", - ShouldShowMobileOtpScreen: refs.NewBoolRef(isMobileLogin), + ShouldShowMobileOtpScreen: refs.NewBoolRef(true), }, 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 @@ -391,7 +394,7 @@ func (p *provider) Login(ctx context.Context, meta RequestMetadata, params *mode hasWebauthnCredential := len(webauthnCreds) > 0 authenticatorVerified := totpVerified || hasWebauthnCredential gate := resolveMFAGate( - refs.BoolValue(user.IsMultiFactorAuthEnabled), + effectiveMFAEnabled(p.Config, user), p.Config.EnforceMFA, authenticatorVerified, user.HasSkippedMFASetupAt != nil, @@ -404,7 +407,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 +424,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 +545,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..414e5d14 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,39 @@ 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 +} + +// 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..04047e80 --- /dev/null +++ b/internal/service/oauth_mfa_gate.go @@ -0,0 +1,92 @@ +// 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. +// +// 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, ",")) + // 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..3ab8d17b --- /dev/null +++ b/internal/service/otp_mfa_setup.go @@ -0,0 +1,238 @@ +// 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 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. +// +// Returns Unauthenticated if neither mode resolves a caller. +func (p *provider) resolveOTPSetupCaller(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() + + user, err := p.resolveOTPSetupCaller(ctx, meta, 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"}, nil, 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() + + user, err := p.resolveOTPSetupCaller(ctx, meta, 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"}, nil, 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..05b001e7 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" ) @@ -100,9 +101,27 @@ 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) // ResendVerifyEmail re-issues a pending email-verification link. Public — // response is generic to avoid account enumeration. @@ -160,6 +179,15 @@ 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. 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..23b06a4b 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,31 +137,14 @@ 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 @@ -140,7 +155,7 @@ func (p *provider) ResendOTP(ctx context.Context, meta RequestMetadata, params * 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/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..13011900 100644 --- a/internal/service/signup.go +++ b/internal/service/signup.go @@ -269,7 +269,9 @@ 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 @@ -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/verify_email.go b/internal/service/verify_email.go index 1c1f1914..abcf5441 100644 --- a/internal/service/verify_email.go +++ b/internal/service/verify_email.go @@ -69,7 +69,9 @@ func (p *provider) VerifyEmail(ctx context.Context, meta RequestMetadata, params setOTPMFaSession := func(expiresAt int64) error { mfaSession := uuid.NewString() - err = p.MemoryStoreProvider.SetMfaSession(user.ID, mfaSession, expiresAt) + // Reached only via a signed verification token mailed to the user's + // inbox — a possession proof of this exact account, so Verified. + err = p.MemoryStoreProvider.SetMfaSession(user.ID, mfaSession, constants.MFASessionPurposeVerified, expiresAt) if err != nil { log.Debug().Err(err).Msg("Failed to set mfa session") return err 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..17eb2753 100644 --- a/internal/service/webauthn.go +++ b/internal/service/webauthn.go @@ -155,28 +155,52 @@ 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 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 passkey used for PRIMARY login is only one factor (something you + // have) — it does not itself satisfy an MFA requirement, so it goes + // through the exact same 5-way gate password login does. A WebAuthn + // credential registered for MFA purposes on this same account (there is + // no `purpose` field distinguishing "primary" vs "MFA" registrations) + // counts as a verified second factor here too, same as login.go's TOTP + // branch treats it — but the credential the user just authenticated + // PRIMARY with cannot also be counted as its own second factor, so + // authenticatorVerified below is TOTP-only for a passkey-primary login. + authenticator, authErr := p.StorageProvider.GetAuthenticatorDetailsByUserId(ctx, user.ID, constants.EnvKeyTOTPAuthenticator) + totpVerified := authErr == nil && authenticator != nil && authenticator.VerifiedAt != nil + gate := resolveMFAGate( + effectiveMFAEnabled(p.Config, user), + p.Config.EnforceMFA, + totpVerified, + user.HasSkippedMFASetupAt != nil, + ) + switch gate { + case mfaGateBlockVerify: 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 + return &model.AuthResponse{ + Message: `Proceed to mfa verification`, + ShouldShowTotpScreen: refs.NewBoolRef(true), + }, side, nil + case mfaGateBlockEnroll: + 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") + } + expiresAt := time.Now().Add(3 * time.Minute).Unix() + if err := p.setMFASession(meta, side, user.ID, expiresAt); err != nil { + log.Debug().Msg("Failed to set mfa session") + return nil, nil, err } enrollment, err := p.generateTOTPEnrollment(ctx, user.ID) if err != nil { @@ -190,6 +214,39 @@ func (p *provider) WebauthnLoginVerify(ctx context.Context, meta RequestMetadata AuthenticatorSecret: refs.NewStringRef(enrollment.Secret), AuthenticatorRecoveryCodes: enrollment.RecoveryCodes, }, 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 + } + 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), + } + // Unlike login.go's TOTP branch (only reachable when EnableTOTPLogin is + // already true), passkey-primary login reaches this gate regardless of + // TOTP availability — only offer/generate a TOTP enrollment when TOTP + // login is actually enabled server-wide, or p.AuthenticatorProvider is + // nil and generateTOTPEnrollment panics. The token is withheld either + // way via setMFASession above; WebAuthn-only offer is still meaningful + // when TOTP isn't configured. + if p.Config.EnableTOTPLogin { + 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, mfaGateNone: + // Both fall through to normal token issuance below. } p.AuditProvider.LogEvent(audit.Event{ 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/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/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/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/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; }