diff --git a/.github/workflows/storage-matrix.yml b/.github/workflows/storage-matrix.yml new file mode 100644 index 00000000..a63c16e7 --- /dev/null +++ b/.github/workflows/storage-matrix.yml @@ -0,0 +1,44 @@ +name: Storage backend matrix + +on: + # CI's `test` job only exercises SQLite (make test). This job runs the + # same provider_test.go suite against all 6 other backends via Docker + # containers (make test-all-db) - real coverage that otherwise never runs + # anywhere. Kept off the PR path since it's slow (multiple DB containers, + # Couchbase alone takes minutes to become healthy); PRs touching storage + # code get fast feedback via the path filter below instead of waiting on + # every unrelated PR. + pull_request: + paths: + - "internal/storage/**" + - "Makefile" + - "scripts/couchbase-test.sh" + - ".github/workflows/storage-matrix.yml" + schedule: + - cron: "0 3 * * *" # daily, catches regressions within a day either way + workflow_dispatch: {} + +permissions: + contents: read + +concurrency: + group: storage-matrix-${{ github.ref }} + cancel-in-progress: true + +jobs: + test-all-db: + name: Go tests (all storage backends) + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - uses: actions/checkout@v5 + with: + persist-credentials: false + + - uses: actions/setup-go@v6 + with: + go-version-file: go.mod + cache: true + + - name: Run tests across all storage backends + run: make test-all-db diff --git a/Dockerfile b/Dockerfile index 2e95ce44..1f1097db 100644 --- a/Dockerfile +++ b/Dockerfile @@ -58,7 +58,8 @@ RUN cd web/app && npm run build && cd ../dashboard && npm run build FROM alpine:3.23.3 ARG ALPINE_EDGE_MAIN=https://dl-cdn.alpinelinux.org/alpine/edge/main -RUN apk add --no-cache -X "${ALPINE_EDGE_MAIN}" "busybox>=1.37.0-r31" +RUN apk add --no-cache -X "${ALPINE_EDGE_MAIN}" "busybox>=1.37.0-r31" && \ + apk upgrade --no-cache ARG TARGETARCH=amd64 diff --git a/Makefile b/Makefile index 310798fb..928d9eae 100644 --- a/Makefile +++ b/Makefile @@ -88,7 +88,7 @@ dev: --admin-secret=admin \ --client-id=kbyuFDidLLm280LIwVFiazOqjO3ty8KH \ --client-secret=60Op4HFM0I8ajz0WdiStAbziZ-VFQttXuxixHHs2R7r7-CW8GR79l-mmLqMhc-Sa \ - --allowed-origins=localhost:8080,localhost:8090,localhost:9091,localhost:5173 + --allowed-origins=localhost:8080,localhost:8090,localhost:9091,localhost:5173,localhost:5174 test: go clean --testcache && TEST_DBS="sqlite" $(GO_TEST_ALL) diff --git a/cmd/mcp.go b/cmd/mcp.go index e10a2adf..4a841ee7 100644 --- a/cmd/mcp.go +++ b/cmd/mcp.go @@ -100,6 +100,7 @@ func runMCP(_ *cobra.Command, _ []string) { tokenProvider, err := token.New(&rootArgs.config, &token.Dependencies{ Log: &log, MemoryStoreProvider: memoryStoreProvider, + StorageProvider: storageProvider, }) if err != nil { log.Fatal().Err(err).Msg("failed to create token provider") diff --git a/cmd/root.go b/cmd/root.go index 998df2d3..442a2fe9 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 @@ -535,6 +536,7 @@ func runRoot(c *cobra.Command, args []string) { tokenProvider, err := token.New(&rootArgs.config, &token.Dependencies{ Log: &log, MemoryStoreProvider: memoryStoreProvider, + StorageProvider: storageProvider, }) if err != nil { log.Fatal().Err(err).Msg("failed to create token provider") diff --git a/gen/go/authorizer/v1/admin.pb.go b/gen/go/authorizer/v1/admin.pb.go index 52e44640..858888e6 100644 --- a/gen/go/authorizer/v1/admin.pb.go +++ b/gen/go/authorizer/v1/admin.pb.go @@ -664,6 +664,11 @@ type UpdateUserRequest struct { Roles []string `protobuf:"bytes,13,rep,name=roles,proto3" json:"roles,omitempty"` IsMultiFactorAuthEnabled *bool `protobuf:"varint,14,opt,name=is_multi_factor_auth_enabled,json=isMultiFactorAuthEnabled,proto3,oneof" json:"is_multi_factor_auth_enabled,omitempty"` AppData *AppData `protobuf:"bytes,15,opt,name=app_data,json=appData,proto3" json:"app_data,omitempty"` + // Mirrors UpdateUserRequest.reset_mfa in GraphQL — see schema.graphqls' + // doc comment on that field for exact semantics (clears mfa_locked_at, + // is_multi_factor_auth_enabled, has_skipped_mfa_setup_at, deletes all + // authenticators/passkeys). + ResetMfa *bool `protobuf:"varint,16,opt,name=reset_mfa,json=resetMfa,proto3,oneof" json:"reset_mfa,omitempty"` } func (x *UpdateUserRequest) Reset() { @@ -801,6 +806,13 @@ func (x *UpdateUserRequest) GetAppData() *AppData { return nil } +func (x *UpdateUserRequest) GetResetMfa() bool { + if x != nil && x.ResetMfa != nil { + return *x.ResetMfa + } + return false +} + type UpdateUserResponse struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache @@ -5671,7 +5683,7 @@ var file_authorizer_v1_admin_proto_rawDesc = []byte{ 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x22, 0x37, 0x0a, 0x0c, 0x55, 0x73, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x27, 0x0a, 0x04, 0x75, 0x73, 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x13, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, - 0x2e, 0x76, 0x31, 0x2e, 0x55, 0x73, 0x65, 0x72, 0x52, 0x04, 0x75, 0x73, 0x65, 0x72, 0x22, 0x9c, + 0x2e, 0x76, 0x31, 0x2e, 0x55, 0x73, 0x65, 0x72, 0x52, 0x04, 0x75, 0x73, 0x65, 0x72, 0x22, 0xcc, 0x06, 0x0a, 0x11, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x55, 0x73, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x17, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x42, 0x07, 0xba, 0x48, 0x04, 0x72, 0x02, 0x10, 0x01, 0x52, 0x02, 0x69, 0x64, 0x12, 0x19, 0x0a, @@ -5709,19 +5721,22 @@ var file_authorizer_v1_admin_proto_rawDesc = []byte{ 0x12, 0x31, 0x0a, 0x08, 0x61, 0x70, 0x70, 0x5f, 0x64, 0x61, 0x74, 0x61, 0x18, 0x0f, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x41, 0x70, 0x70, 0x44, 0x61, 0x74, 0x61, 0x52, 0x07, 0x61, 0x70, 0x70, 0x44, - 0x61, 0x74, 0x61, 0x42, 0x08, 0x0a, 0x06, 0x5f, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x42, 0x11, 0x0a, - 0x0f, 0x5f, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x5f, 0x76, 0x65, 0x72, 0x69, 0x66, 0x69, 0x65, 0x64, - 0x42, 0x0d, 0x0a, 0x0b, 0x5f, 0x67, 0x69, 0x76, 0x65, 0x6e, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x42, - 0x0e, 0x0a, 0x0c, 0x5f, 0x66, 0x61, 0x6d, 0x69, 0x6c, 0x79, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x42, - 0x0e, 0x0a, 0x0c, 0x5f, 0x6d, 0x69, 0x64, 0x64, 0x6c, 0x65, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x42, - 0x0b, 0x0a, 0x09, 0x5f, 0x6e, 0x69, 0x63, 0x6b, 0x6e, 0x61, 0x6d, 0x65, 0x42, 0x09, 0x0a, 0x07, - 0x5f, 0x67, 0x65, 0x6e, 0x64, 0x65, 0x72, 0x42, 0x0c, 0x0a, 0x0a, 0x5f, 0x62, 0x69, 0x72, 0x74, - 0x68, 0x64, 0x61, 0x74, 0x65, 0x42, 0x0f, 0x0a, 0x0d, 0x5f, 0x70, 0x68, 0x6f, 0x6e, 0x65, 0x5f, - 0x6e, 0x75, 0x6d, 0x62, 0x65, 0x72, 0x42, 0x18, 0x0a, 0x16, 0x5f, 0x70, 0x68, 0x6f, 0x6e, 0x65, - 0x5f, 0x6e, 0x75, 0x6d, 0x62, 0x65, 0x72, 0x5f, 0x76, 0x65, 0x72, 0x69, 0x66, 0x69, 0x65, 0x64, - 0x42, 0x0a, 0x0a, 0x08, 0x5f, 0x70, 0x69, 0x63, 0x74, 0x75, 0x72, 0x65, 0x42, 0x1f, 0x0a, 0x1d, - 0x5f, 0x69, 0x73, 0x5f, 0x6d, 0x75, 0x6c, 0x74, 0x69, 0x5f, 0x66, 0x61, 0x63, 0x74, 0x6f, 0x72, - 0x5f, 0x61, 0x75, 0x74, 0x68, 0x5f, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x22, 0x3d, 0x0a, + 0x61, 0x74, 0x61, 0x12, 0x20, 0x0a, 0x09, 0x72, 0x65, 0x73, 0x65, 0x74, 0x5f, 0x6d, 0x66, 0x61, + 0x18, 0x10, 0x20, 0x01, 0x28, 0x08, 0x48, 0x0c, 0x52, 0x08, 0x72, 0x65, 0x73, 0x65, 0x74, 0x4d, + 0x66, 0x61, 0x88, 0x01, 0x01, 0x42, 0x08, 0x0a, 0x06, 0x5f, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x42, + 0x11, 0x0a, 0x0f, 0x5f, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x5f, 0x76, 0x65, 0x72, 0x69, 0x66, 0x69, + 0x65, 0x64, 0x42, 0x0d, 0x0a, 0x0b, 0x5f, 0x67, 0x69, 0x76, 0x65, 0x6e, 0x5f, 0x6e, 0x61, 0x6d, + 0x65, 0x42, 0x0e, 0x0a, 0x0c, 0x5f, 0x66, 0x61, 0x6d, 0x69, 0x6c, 0x79, 0x5f, 0x6e, 0x61, 0x6d, + 0x65, 0x42, 0x0e, 0x0a, 0x0c, 0x5f, 0x6d, 0x69, 0x64, 0x64, 0x6c, 0x65, 0x5f, 0x6e, 0x61, 0x6d, + 0x65, 0x42, 0x0b, 0x0a, 0x09, 0x5f, 0x6e, 0x69, 0x63, 0x6b, 0x6e, 0x61, 0x6d, 0x65, 0x42, 0x09, + 0x0a, 0x07, 0x5f, 0x67, 0x65, 0x6e, 0x64, 0x65, 0x72, 0x42, 0x0c, 0x0a, 0x0a, 0x5f, 0x62, 0x69, + 0x72, 0x74, 0x68, 0x64, 0x61, 0x74, 0x65, 0x42, 0x0f, 0x0a, 0x0d, 0x5f, 0x70, 0x68, 0x6f, 0x6e, + 0x65, 0x5f, 0x6e, 0x75, 0x6d, 0x62, 0x65, 0x72, 0x42, 0x18, 0x0a, 0x16, 0x5f, 0x70, 0x68, 0x6f, + 0x6e, 0x65, 0x5f, 0x6e, 0x75, 0x6d, 0x62, 0x65, 0x72, 0x5f, 0x76, 0x65, 0x72, 0x69, 0x66, 0x69, + 0x65, 0x64, 0x42, 0x0a, 0x0a, 0x08, 0x5f, 0x70, 0x69, 0x63, 0x74, 0x75, 0x72, 0x65, 0x42, 0x1f, + 0x0a, 0x1d, 0x5f, 0x69, 0x73, 0x5f, 0x6d, 0x75, 0x6c, 0x74, 0x69, 0x5f, 0x66, 0x61, 0x63, 0x74, + 0x6f, 0x72, 0x5f, 0x61, 0x75, 0x74, 0x68, 0x5f, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x42, + 0x0c, 0x0a, 0x0a, 0x5f, 0x72, 0x65, 0x73, 0x65, 0x74, 0x5f, 0x6d, 0x66, 0x61, 0x22, 0x3d, 0x0a, 0x12, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x55, 0x73, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x27, 0x0a, 0x04, 0x75, 0x73, 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x13, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, diff --git a/gen/go/authorizer/v1/authorizer.pb.go b/gen/go/authorizer/v1/authorizer.pb.go index 1f7e5881..cb6d6d57 100644 --- a/gen/go/authorizer/v1/authorizer.pb.go +++ b/gen/go/authorizer/v1/authorizer.pb.go @@ -2,7 +2,8 @@ // public API. Method names match the GraphQL operation names 1:1 // (snake_case in GraphQL → PascalCase in proto): Signup, Login, // MagicLinkLogin, VerifyEmail, ResendVerifyEmail, ForgotPassword, -// ResetPassword, VerifyOtp, ResendOtp, UpdateProfile, DeactivateAccount, +// ResetPassword, VerifyOtp, ResendOtp, SkipMfaSetup, LockMfa, +// EmailOtpMfaSetup, SmsOtpMfaSetup, UpdateProfile, DeactivateAccount, // Revoke, Meta, Session, Profile, ValidateJwtToken, ValidateSession, // CheckPermissions, ListPermissions, Logout. // @@ -845,6 +846,371 @@ func (x *ResendOtpResponse) GetMessage() string { return "" } +type SkipMfaSetupRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // Either email or phone_number is required, to resolve which user's MFA + // session cookie this is. + Email string `protobuf:"bytes,1,opt,name=email,proto3" json:"email,omitempty"` + PhoneNumber string `protobuf:"bytes,2,opt,name=phone_number,json=phoneNumber,proto3" json:"phone_number,omitempty"` + State string `protobuf:"bytes,3,opt,name=state,proto3" json:"state,omitempty"` +} + +func (x *SkipMfaSetupRequest) Reset() { + *x = SkipMfaSetupRequest{} + mi := &file_authorizer_v1_authorizer_proto_msgTypes[12] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SkipMfaSetupRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SkipMfaSetupRequest) ProtoMessage() {} + +func (x *SkipMfaSetupRequest) ProtoReflect() protoreflect.Message { + mi := &file_authorizer_v1_authorizer_proto_msgTypes[12] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SkipMfaSetupRequest.ProtoReflect.Descriptor instead. +func (*SkipMfaSetupRequest) Descriptor() ([]byte, []int) { + return file_authorizer_v1_authorizer_proto_rawDescGZIP(), []int{12} +} + +func (x *SkipMfaSetupRequest) GetEmail() string { + if x != nil { + return x.Email + } + return "" +} + +func (x *SkipMfaSetupRequest) GetPhoneNumber() string { + if x != nil { + return x.PhoneNumber + } + return "" +} + +func (x *SkipMfaSetupRequest) GetState() string { + if x != nil { + return x.State + } + return "" +} + +type LockMfaRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // Either email or phone_number is required, to resolve which user's MFA + // session cookie this is — same pattern as SkipMfaSetupRequest. + Email string `protobuf:"bytes,1,opt,name=email,proto3" json:"email,omitempty"` + PhoneNumber string `protobuf:"bytes,2,opt,name=phone_number,json=phoneNumber,proto3" json:"phone_number,omitempty"` +} + +func (x *LockMfaRequest) Reset() { + *x = LockMfaRequest{} + mi := &file_authorizer_v1_authorizer_proto_msgTypes[13] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *LockMfaRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*LockMfaRequest) ProtoMessage() {} + +func (x *LockMfaRequest) ProtoReflect() protoreflect.Message { + mi := &file_authorizer_v1_authorizer_proto_msgTypes[13] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use LockMfaRequest.ProtoReflect.Descriptor instead. +func (*LockMfaRequest) Descriptor() ([]byte, []int) { + return file_authorizer_v1_authorizer_proto_rawDescGZIP(), []int{13} +} + +func (x *LockMfaRequest) GetEmail() string { + if x != nil { + return x.Email + } + return "" +} + +func (x *LockMfaRequest) GetPhoneNumber() string { + if x != nil { + return x.PhoneNumber + } + return "" +} + +type LockMfaResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Message string `protobuf:"bytes,1,opt,name=message,proto3" json:"message,omitempty"` +} + +func (x *LockMfaResponse) Reset() { + *x = LockMfaResponse{} + mi := &file_authorizer_v1_authorizer_proto_msgTypes[14] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *LockMfaResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*LockMfaResponse) ProtoMessage() {} + +func (x *LockMfaResponse) ProtoReflect() protoreflect.Message { + mi := &file_authorizer_v1_authorizer_proto_msgTypes[14] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use LockMfaResponse.ProtoReflect.Descriptor instead. +func (*LockMfaResponse) Descriptor() ([]byte, []int) { + return file_authorizer_v1_authorizer_proto_rawDescGZIP(), []int{14} +} + +func (x *LockMfaResponse) GetMessage() string { + if x != nil { + return x.Message + } + return "" +} + +type EmailOtpMfaSetupRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // Only used in the MFA-session-cookie mode (a caller in the withheld + // first-time-offer state, with no bearer token yet) to resolve which + // user's MFA session cookie this is — same pattern as SkipMfaSetupRequest + // / LockMfaRequest. Ignored when the caller has a valid bearer + // token/session, which already identifies the user. + Email string `protobuf:"bytes,1,opt,name=email,proto3" json:"email,omitempty"` + PhoneNumber string `protobuf:"bytes,2,opt,name=phone_number,json=phoneNumber,proto3" json:"phone_number,omitempty"` +} + +func (x *EmailOtpMfaSetupRequest) Reset() { + *x = EmailOtpMfaSetupRequest{} + mi := &file_authorizer_v1_authorizer_proto_msgTypes[15] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *EmailOtpMfaSetupRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*EmailOtpMfaSetupRequest) ProtoMessage() {} + +func (x *EmailOtpMfaSetupRequest) ProtoReflect() protoreflect.Message { + mi := &file_authorizer_v1_authorizer_proto_msgTypes[15] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use EmailOtpMfaSetupRequest.ProtoReflect.Descriptor instead. +func (*EmailOtpMfaSetupRequest) Descriptor() ([]byte, []int) { + return file_authorizer_v1_authorizer_proto_rawDescGZIP(), []int{15} +} + +func (x *EmailOtpMfaSetupRequest) GetEmail() string { + if x != nil { + return x.Email + } + return "" +} + +func (x *EmailOtpMfaSetupRequest) GetPhoneNumber() string { + if x != nil { + return x.PhoneNumber + } + return "" +} + +type EmailOtpMfaSetupResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Message string `protobuf:"bytes,1,opt,name=message,proto3" json:"message,omitempty"` +} + +func (x *EmailOtpMfaSetupResponse) Reset() { + *x = EmailOtpMfaSetupResponse{} + mi := &file_authorizer_v1_authorizer_proto_msgTypes[16] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *EmailOtpMfaSetupResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*EmailOtpMfaSetupResponse) ProtoMessage() {} + +func (x *EmailOtpMfaSetupResponse) ProtoReflect() protoreflect.Message { + mi := &file_authorizer_v1_authorizer_proto_msgTypes[16] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use EmailOtpMfaSetupResponse.ProtoReflect.Descriptor instead. +func (*EmailOtpMfaSetupResponse) Descriptor() ([]byte, []int) { + return file_authorizer_v1_authorizer_proto_rawDescGZIP(), []int{16} +} + +func (x *EmailOtpMfaSetupResponse) GetMessage() string { + if x != nil { + return x.Message + } + return "" +} + +type SmsOtpMfaSetupRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // Same dual-mode identification semantics as EmailOtpMfaSetupRequest. + Email string `protobuf:"bytes,1,opt,name=email,proto3" json:"email,omitempty"` + PhoneNumber string `protobuf:"bytes,2,opt,name=phone_number,json=phoneNumber,proto3" json:"phone_number,omitempty"` +} + +func (x *SmsOtpMfaSetupRequest) Reset() { + *x = SmsOtpMfaSetupRequest{} + mi := &file_authorizer_v1_authorizer_proto_msgTypes[17] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SmsOtpMfaSetupRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SmsOtpMfaSetupRequest) ProtoMessage() {} + +func (x *SmsOtpMfaSetupRequest) ProtoReflect() protoreflect.Message { + mi := &file_authorizer_v1_authorizer_proto_msgTypes[17] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SmsOtpMfaSetupRequest.ProtoReflect.Descriptor instead. +func (*SmsOtpMfaSetupRequest) Descriptor() ([]byte, []int) { + return file_authorizer_v1_authorizer_proto_rawDescGZIP(), []int{17} +} + +func (x *SmsOtpMfaSetupRequest) GetEmail() string { + if x != nil { + return x.Email + } + return "" +} + +func (x *SmsOtpMfaSetupRequest) GetPhoneNumber() string { + if x != nil { + return x.PhoneNumber + } + return "" +} + +type SmsOtpMfaSetupResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Message string `protobuf:"bytes,1,opt,name=message,proto3" json:"message,omitempty"` +} + +func (x *SmsOtpMfaSetupResponse) Reset() { + *x = SmsOtpMfaSetupResponse{} + mi := &file_authorizer_v1_authorizer_proto_msgTypes[18] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SmsOtpMfaSetupResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SmsOtpMfaSetupResponse) ProtoMessage() {} + +func (x *SmsOtpMfaSetupResponse) ProtoReflect() protoreflect.Message { + mi := &file_authorizer_v1_authorizer_proto_msgTypes[18] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SmsOtpMfaSetupResponse.ProtoReflect.Descriptor instead. +func (*SmsOtpMfaSetupResponse) Descriptor() ([]byte, []int) { + return file_authorizer_v1_authorizer_proto_rawDescGZIP(), []int{18} +} + +func (x *SmsOtpMfaSetupResponse) GetMessage() string { + if x != nil { + return x.Message + } + return "" +} + type ForgotPasswordRequest struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache @@ -858,7 +1224,7 @@ type ForgotPasswordRequest struct { func (x *ForgotPasswordRequest) Reset() { *x = ForgotPasswordRequest{} - mi := &file_authorizer_v1_authorizer_proto_msgTypes[12] + mi := &file_authorizer_v1_authorizer_proto_msgTypes[19] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -870,7 +1236,7 @@ func (x *ForgotPasswordRequest) String() string { func (*ForgotPasswordRequest) ProtoMessage() {} func (x *ForgotPasswordRequest) ProtoReflect() protoreflect.Message { - mi := &file_authorizer_v1_authorizer_proto_msgTypes[12] + mi := &file_authorizer_v1_authorizer_proto_msgTypes[19] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -883,7 +1249,7 @@ func (x *ForgotPasswordRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ForgotPasswordRequest.ProtoReflect.Descriptor instead. func (*ForgotPasswordRequest) Descriptor() ([]byte, []int) { - return file_authorizer_v1_authorizer_proto_rawDescGZIP(), []int{12} + return file_authorizer_v1_authorizer_proto_rawDescGZIP(), []int{19} } func (x *ForgotPasswordRequest) GetEmail() string { @@ -926,7 +1292,7 @@ type ForgotPasswordResponse struct { func (x *ForgotPasswordResponse) Reset() { *x = ForgotPasswordResponse{} - mi := &file_authorizer_v1_authorizer_proto_msgTypes[13] + mi := &file_authorizer_v1_authorizer_proto_msgTypes[20] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -938,7 +1304,7 @@ func (x *ForgotPasswordResponse) String() string { func (*ForgotPasswordResponse) ProtoMessage() {} func (x *ForgotPasswordResponse) ProtoReflect() protoreflect.Message { - mi := &file_authorizer_v1_authorizer_proto_msgTypes[13] + mi := &file_authorizer_v1_authorizer_proto_msgTypes[20] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -951,7 +1317,7 @@ func (x *ForgotPasswordResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ForgotPasswordResponse.ProtoReflect.Descriptor instead. func (*ForgotPasswordResponse) Descriptor() ([]byte, []int) { - return file_authorizer_v1_authorizer_proto_rawDescGZIP(), []int{13} + return file_authorizer_v1_authorizer_proto_rawDescGZIP(), []int{20} } func (x *ForgotPasswordResponse) GetMessage() string { @@ -983,7 +1349,7 @@ type ResetPasswordRequest struct { func (x *ResetPasswordRequest) Reset() { *x = ResetPasswordRequest{} - mi := &file_authorizer_v1_authorizer_proto_msgTypes[14] + mi := &file_authorizer_v1_authorizer_proto_msgTypes[21] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -995,7 +1361,7 @@ func (x *ResetPasswordRequest) String() string { func (*ResetPasswordRequest) ProtoMessage() {} func (x *ResetPasswordRequest) ProtoReflect() protoreflect.Message { - mi := &file_authorizer_v1_authorizer_proto_msgTypes[14] + mi := &file_authorizer_v1_authorizer_proto_msgTypes[21] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1008,7 +1374,7 @@ func (x *ResetPasswordRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ResetPasswordRequest.ProtoReflect.Descriptor instead. func (*ResetPasswordRequest) Descriptor() ([]byte, []int) { - return file_authorizer_v1_authorizer_proto_rawDescGZIP(), []int{14} + return file_authorizer_v1_authorizer_proto_rawDescGZIP(), []int{21} } func (x *ResetPasswordRequest) GetToken() string { @@ -1056,7 +1422,7 @@ type ResetPasswordResponse struct { func (x *ResetPasswordResponse) Reset() { *x = ResetPasswordResponse{} - mi := &file_authorizer_v1_authorizer_proto_msgTypes[15] + mi := &file_authorizer_v1_authorizer_proto_msgTypes[22] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1068,7 +1434,7 @@ func (x *ResetPasswordResponse) String() string { func (*ResetPasswordResponse) ProtoMessage() {} func (x *ResetPasswordResponse) ProtoReflect() protoreflect.Message { - mi := &file_authorizer_v1_authorizer_proto_msgTypes[15] + mi := &file_authorizer_v1_authorizer_proto_msgTypes[22] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1081,7 +1447,7 @@ func (x *ResetPasswordResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ResetPasswordResponse.ProtoReflect.Descriptor instead. func (*ResetPasswordResponse) Descriptor() ([]byte, []int) { - return file_authorizer_v1_authorizer_proto_rawDescGZIP(), []int{15} + return file_authorizer_v1_authorizer_proto_rawDescGZIP(), []int{22} } func (x *ResetPasswordResponse) GetMessage() string { @@ -1099,7 +1465,7 @@ type ProfileRequest struct { func (x *ProfileRequest) Reset() { *x = ProfileRequest{} - mi := &file_authorizer_v1_authorizer_proto_msgTypes[16] + mi := &file_authorizer_v1_authorizer_proto_msgTypes[23] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1111,7 +1477,7 @@ func (x *ProfileRequest) String() string { func (*ProfileRequest) ProtoMessage() {} func (x *ProfileRequest) ProtoReflect() protoreflect.Message { - mi := &file_authorizer_v1_authorizer_proto_msgTypes[16] + mi := &file_authorizer_v1_authorizer_proto_msgTypes[23] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1124,7 +1490,7 @@ func (x *ProfileRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ProfileRequest.ProtoReflect.Descriptor instead. func (*ProfileRequest) Descriptor() ([]byte, []int) { - return file_authorizer_v1_authorizer_proto_rawDescGZIP(), []int{16} + return file_authorizer_v1_authorizer_proto_rawDescGZIP(), []int{23} } type UpdateProfileRequest struct { @@ -1153,7 +1519,7 @@ type UpdateProfileRequest struct { func (x *UpdateProfileRequest) Reset() { *x = UpdateProfileRequest{} - mi := &file_authorizer_v1_authorizer_proto_msgTypes[17] + mi := &file_authorizer_v1_authorizer_proto_msgTypes[24] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1165,7 +1531,7 @@ func (x *UpdateProfileRequest) String() string { func (*UpdateProfileRequest) ProtoMessage() {} func (x *UpdateProfileRequest) ProtoReflect() protoreflect.Message { - mi := &file_authorizer_v1_authorizer_proto_msgTypes[17] + mi := &file_authorizer_v1_authorizer_proto_msgTypes[24] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1178,7 +1544,7 @@ func (x *UpdateProfileRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use UpdateProfileRequest.ProtoReflect.Descriptor instead. func (*UpdateProfileRequest) Descriptor() ([]byte, []int) { - return file_authorizer_v1_authorizer_proto_rawDescGZIP(), []int{17} + return file_authorizer_v1_authorizer_proto_rawDescGZIP(), []int{24} } func (x *UpdateProfileRequest) GetOldPassword() string { @@ -1289,7 +1655,7 @@ type UpdateProfileResponse struct { func (x *UpdateProfileResponse) Reset() { *x = UpdateProfileResponse{} - mi := &file_authorizer_v1_authorizer_proto_msgTypes[18] + mi := &file_authorizer_v1_authorizer_proto_msgTypes[25] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1301,7 +1667,7 @@ func (x *UpdateProfileResponse) String() string { func (*UpdateProfileResponse) ProtoMessage() {} func (x *UpdateProfileResponse) ProtoReflect() protoreflect.Message { - mi := &file_authorizer_v1_authorizer_proto_msgTypes[18] + mi := &file_authorizer_v1_authorizer_proto_msgTypes[25] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1314,7 +1680,7 @@ func (x *UpdateProfileResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use UpdateProfileResponse.ProtoReflect.Descriptor instead. func (*UpdateProfileResponse) Descriptor() ([]byte, []int) { - return file_authorizer_v1_authorizer_proto_rawDescGZIP(), []int{18} + return file_authorizer_v1_authorizer_proto_rawDescGZIP(), []int{25} } func (x *UpdateProfileResponse) GetMessage() string { @@ -1332,7 +1698,7 @@ type DeactivateAccountRequest struct { func (x *DeactivateAccountRequest) Reset() { *x = DeactivateAccountRequest{} - mi := &file_authorizer_v1_authorizer_proto_msgTypes[19] + mi := &file_authorizer_v1_authorizer_proto_msgTypes[26] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1344,7 +1710,7 @@ func (x *DeactivateAccountRequest) String() string { func (*DeactivateAccountRequest) ProtoMessage() {} func (x *DeactivateAccountRequest) ProtoReflect() protoreflect.Message { - mi := &file_authorizer_v1_authorizer_proto_msgTypes[19] + mi := &file_authorizer_v1_authorizer_proto_msgTypes[26] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1357,7 +1723,7 @@ func (x *DeactivateAccountRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use DeactivateAccountRequest.ProtoReflect.Descriptor instead. func (*DeactivateAccountRequest) Descriptor() ([]byte, []int) { - return file_authorizer_v1_authorizer_proto_rawDescGZIP(), []int{19} + return file_authorizer_v1_authorizer_proto_rawDescGZIP(), []int{26} } type DeactivateAccountResponse struct { @@ -1370,7 +1736,7 @@ type DeactivateAccountResponse struct { func (x *DeactivateAccountResponse) Reset() { *x = DeactivateAccountResponse{} - mi := &file_authorizer_v1_authorizer_proto_msgTypes[20] + mi := &file_authorizer_v1_authorizer_proto_msgTypes[27] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1382,7 +1748,7 @@ func (x *DeactivateAccountResponse) String() string { func (*DeactivateAccountResponse) ProtoMessage() {} func (x *DeactivateAccountResponse) ProtoReflect() protoreflect.Message { - mi := &file_authorizer_v1_authorizer_proto_msgTypes[20] + mi := &file_authorizer_v1_authorizer_proto_msgTypes[27] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1395,7 +1761,7 @@ func (x *DeactivateAccountResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use DeactivateAccountResponse.ProtoReflect.Descriptor instead. func (*DeactivateAccountResponse) Descriptor() ([]byte, []int) { - return file_authorizer_v1_authorizer_proto_rawDescGZIP(), []int{20} + return file_authorizer_v1_authorizer_proto_rawDescGZIP(), []int{27} } func (x *DeactivateAccountResponse) GetMessage() string { @@ -1415,7 +1781,7 @@ type RevokeRequest struct { func (x *RevokeRequest) Reset() { *x = RevokeRequest{} - mi := &file_authorizer_v1_authorizer_proto_msgTypes[21] + mi := &file_authorizer_v1_authorizer_proto_msgTypes[28] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1427,7 +1793,7 @@ func (x *RevokeRequest) String() string { func (*RevokeRequest) ProtoMessage() {} func (x *RevokeRequest) ProtoReflect() protoreflect.Message { - mi := &file_authorizer_v1_authorizer_proto_msgTypes[21] + mi := &file_authorizer_v1_authorizer_proto_msgTypes[28] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1440,7 +1806,7 @@ func (x *RevokeRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use RevokeRequest.ProtoReflect.Descriptor instead. func (*RevokeRequest) Descriptor() ([]byte, []int) { - return file_authorizer_v1_authorizer_proto_rawDescGZIP(), []int{21} + return file_authorizer_v1_authorizer_proto_rawDescGZIP(), []int{28} } func (x *RevokeRequest) GetRefreshToken() string { @@ -1460,7 +1826,7 @@ type RevokeResponse struct { func (x *RevokeResponse) Reset() { *x = RevokeResponse{} - mi := &file_authorizer_v1_authorizer_proto_msgTypes[22] + mi := &file_authorizer_v1_authorizer_proto_msgTypes[29] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1472,7 +1838,7 @@ func (x *RevokeResponse) String() string { func (*RevokeResponse) ProtoMessage() {} func (x *RevokeResponse) ProtoReflect() protoreflect.Message { - mi := &file_authorizer_v1_authorizer_proto_msgTypes[22] + mi := &file_authorizer_v1_authorizer_proto_msgTypes[29] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1485,7 +1851,7 @@ func (x *RevokeResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use RevokeResponse.ProtoReflect.Descriptor instead. func (*RevokeResponse) Descriptor() ([]byte, []int) { - return file_authorizer_v1_authorizer_proto_rawDescGZIP(), []int{22} + return file_authorizer_v1_authorizer_proto_rawDescGZIP(), []int{29} } func (x *RevokeResponse) GetMessage() string { @@ -1511,7 +1877,7 @@ type SessionRequest struct { func (x *SessionRequest) Reset() { *x = SessionRequest{} - mi := &file_authorizer_v1_authorizer_proto_msgTypes[23] + mi := &file_authorizer_v1_authorizer_proto_msgTypes[30] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1523,7 +1889,7 @@ func (x *SessionRequest) String() string { func (*SessionRequest) ProtoMessage() {} func (x *SessionRequest) ProtoReflect() protoreflect.Message { - mi := &file_authorizer_v1_authorizer_proto_msgTypes[23] + mi := &file_authorizer_v1_authorizer_proto_msgTypes[30] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1536,7 +1902,7 @@ func (x *SessionRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SessionRequest.ProtoReflect.Descriptor instead. func (*SessionRequest) Descriptor() ([]byte, []int) { - return file_authorizer_v1_authorizer_proto_rawDescGZIP(), []int{23} + return file_authorizer_v1_authorizer_proto_rawDescGZIP(), []int{30} } func (x *SessionRequest) GetRoles() []string { @@ -1581,7 +1947,7 @@ type ValidateJwtTokenRequest struct { func (x *ValidateJwtTokenRequest) Reset() { *x = ValidateJwtTokenRequest{} - mi := &file_authorizer_v1_authorizer_proto_msgTypes[24] + mi := &file_authorizer_v1_authorizer_proto_msgTypes[31] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1593,7 +1959,7 @@ func (x *ValidateJwtTokenRequest) String() string { func (*ValidateJwtTokenRequest) ProtoMessage() {} func (x *ValidateJwtTokenRequest) ProtoReflect() protoreflect.Message { - mi := &file_authorizer_v1_authorizer_proto_msgTypes[24] + mi := &file_authorizer_v1_authorizer_proto_msgTypes[31] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1606,7 +1972,7 @@ func (x *ValidateJwtTokenRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ValidateJwtTokenRequest.ProtoReflect.Descriptor instead. func (*ValidateJwtTokenRequest) Descriptor() ([]byte, []int) { - return file_authorizer_v1_authorizer_proto_rawDescGZIP(), []int{24} + return file_authorizer_v1_authorizer_proto_rawDescGZIP(), []int{31} } func (x *ValidateJwtTokenRequest) GetTokenType() string { @@ -1649,7 +2015,7 @@ type ValidateJwtTokenResponse struct { func (x *ValidateJwtTokenResponse) Reset() { *x = ValidateJwtTokenResponse{} - mi := &file_authorizer_v1_authorizer_proto_msgTypes[25] + mi := &file_authorizer_v1_authorizer_proto_msgTypes[32] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1661,7 +2027,7 @@ func (x *ValidateJwtTokenResponse) String() string { func (*ValidateJwtTokenResponse) ProtoMessage() {} func (x *ValidateJwtTokenResponse) ProtoReflect() protoreflect.Message { - mi := &file_authorizer_v1_authorizer_proto_msgTypes[25] + mi := &file_authorizer_v1_authorizer_proto_msgTypes[32] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1674,7 +2040,7 @@ func (x *ValidateJwtTokenResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ValidateJwtTokenResponse.ProtoReflect.Descriptor instead. func (*ValidateJwtTokenResponse) Descriptor() ([]byte, []int) { - return file_authorizer_v1_authorizer_proto_rawDescGZIP(), []int{25} + return file_authorizer_v1_authorizer_proto_rawDescGZIP(), []int{32} } func (x *ValidateJwtTokenResponse) GetIsValid() bool { @@ -1704,7 +2070,7 @@ type ValidateSessionRequest struct { func (x *ValidateSessionRequest) Reset() { *x = ValidateSessionRequest{} - mi := &file_authorizer_v1_authorizer_proto_msgTypes[26] + mi := &file_authorizer_v1_authorizer_proto_msgTypes[33] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1716,7 +2082,7 @@ func (x *ValidateSessionRequest) String() string { func (*ValidateSessionRequest) ProtoMessage() {} func (x *ValidateSessionRequest) ProtoReflect() protoreflect.Message { - mi := &file_authorizer_v1_authorizer_proto_msgTypes[26] + mi := &file_authorizer_v1_authorizer_proto_msgTypes[33] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1729,7 +2095,7 @@ func (x *ValidateSessionRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ValidateSessionRequest.ProtoReflect.Descriptor instead. func (*ValidateSessionRequest) Descriptor() ([]byte, []int) { - return file_authorizer_v1_authorizer_proto_rawDescGZIP(), []int{26} + return file_authorizer_v1_authorizer_proto_rawDescGZIP(), []int{33} } func (x *ValidateSessionRequest) GetCookie() string { @@ -1764,7 +2130,7 @@ type ValidateSessionResponse struct { func (x *ValidateSessionResponse) Reset() { *x = ValidateSessionResponse{} - mi := &file_authorizer_v1_authorizer_proto_msgTypes[27] + mi := &file_authorizer_v1_authorizer_proto_msgTypes[34] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1776,7 +2142,7 @@ func (x *ValidateSessionResponse) String() string { func (*ValidateSessionResponse) ProtoMessage() {} func (x *ValidateSessionResponse) ProtoReflect() protoreflect.Message { - mi := &file_authorizer_v1_authorizer_proto_msgTypes[27] + mi := &file_authorizer_v1_authorizer_proto_msgTypes[34] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1789,7 +2155,7 @@ func (x *ValidateSessionResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ValidateSessionResponse.ProtoReflect.Descriptor instead. func (*ValidateSessionResponse) Descriptor() ([]byte, []int) { - return file_authorizer_v1_authorizer_proto_rawDescGZIP(), []int{27} + return file_authorizer_v1_authorizer_proto_rawDescGZIP(), []int{34} } func (x *ValidateSessionResponse) GetIsValid() bool { @@ -1814,7 +2180,7 @@ type MetaRequest struct { func (x *MetaRequest) Reset() { *x = MetaRequest{} - mi := &file_authorizer_v1_authorizer_proto_msgTypes[28] + mi := &file_authorizer_v1_authorizer_proto_msgTypes[35] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1826,7 +2192,7 @@ func (x *MetaRequest) String() string { func (*MetaRequest) ProtoMessage() {} func (x *MetaRequest) ProtoReflect() protoreflect.Message { - mi := &file_authorizer_v1_authorizer_proto_msgTypes[28] + mi := &file_authorizer_v1_authorizer_proto_msgTypes[35] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1839,7 +2205,7 @@ func (x *MetaRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use MetaRequest.ProtoReflect.Descriptor instead. func (*MetaRequest) Descriptor() ([]byte, []int) { - return file_authorizer_v1_authorizer_proto_rawDescGZIP(), []int{28} + return file_authorizer_v1_authorizer_proto_rawDescGZIP(), []int{35} } type CheckPermissionsRequest struct { @@ -1857,7 +2223,7 @@ type CheckPermissionsRequest struct { func (x *CheckPermissionsRequest) Reset() { *x = CheckPermissionsRequest{} - mi := &file_authorizer_v1_authorizer_proto_msgTypes[29] + mi := &file_authorizer_v1_authorizer_proto_msgTypes[36] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1869,7 +2235,7 @@ func (x *CheckPermissionsRequest) String() string { func (*CheckPermissionsRequest) ProtoMessage() {} func (x *CheckPermissionsRequest) ProtoReflect() protoreflect.Message { - mi := &file_authorizer_v1_authorizer_proto_msgTypes[29] + mi := &file_authorizer_v1_authorizer_proto_msgTypes[36] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1882,7 +2248,7 @@ func (x *CheckPermissionsRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use CheckPermissionsRequest.ProtoReflect.Descriptor instead. func (*CheckPermissionsRequest) Descriptor() ([]byte, []int) { - return file_authorizer_v1_authorizer_proto_rawDescGZIP(), []int{29} + return file_authorizer_v1_authorizer_proto_rawDescGZIP(), []int{36} } func (x *CheckPermissionsRequest) GetChecks() []*PermissionCheckInput { @@ -1910,7 +2276,7 @@ type CheckPermissionsResponse struct { func (x *CheckPermissionsResponse) Reset() { *x = CheckPermissionsResponse{} - mi := &file_authorizer_v1_authorizer_proto_msgTypes[30] + mi := &file_authorizer_v1_authorizer_proto_msgTypes[37] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1922,7 +2288,7 @@ func (x *CheckPermissionsResponse) String() string { func (*CheckPermissionsResponse) ProtoMessage() {} func (x *CheckPermissionsResponse) ProtoReflect() protoreflect.Message { - mi := &file_authorizer_v1_authorizer_proto_msgTypes[30] + mi := &file_authorizer_v1_authorizer_proto_msgTypes[37] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1935,7 +2301,7 @@ func (x *CheckPermissionsResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use CheckPermissionsResponse.ProtoReflect.Descriptor instead. func (*CheckPermissionsResponse) Descriptor() ([]byte, []int) { - return file_authorizer_v1_authorizer_proto_rawDescGZIP(), []int{30} + return file_authorizer_v1_authorizer_proto_rawDescGZIP(), []int{37} } func (x *CheckPermissionsResponse) GetResults() []*PermissionCheckResult { @@ -1960,7 +2326,7 @@ type ListPermissionsRequest struct { func (x *ListPermissionsRequest) Reset() { *x = ListPermissionsRequest{} - mi := &file_authorizer_v1_authorizer_proto_msgTypes[31] + mi := &file_authorizer_v1_authorizer_proto_msgTypes[38] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1972,7 +2338,7 @@ func (x *ListPermissionsRequest) String() string { func (*ListPermissionsRequest) ProtoMessage() {} func (x *ListPermissionsRequest) ProtoReflect() protoreflect.Message { - mi := &file_authorizer_v1_authorizer_proto_msgTypes[31] + mi := &file_authorizer_v1_authorizer_proto_msgTypes[38] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1985,7 +2351,7 @@ func (x *ListPermissionsRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListPermissionsRequest.ProtoReflect.Descriptor instead. func (*ListPermissionsRequest) Descriptor() ([]byte, []int) { - return file_authorizer_v1_authorizer_proto_rawDescGZIP(), []int{31} + return file_authorizer_v1_authorizer_proto_rawDescGZIP(), []int{38} } func (x *ListPermissionsRequest) GetRelation() string { @@ -2026,7 +2392,7 @@ type ListPermissionsResponse struct { func (x *ListPermissionsResponse) Reset() { *x = ListPermissionsResponse{} - mi := &file_authorizer_v1_authorizer_proto_msgTypes[32] + mi := &file_authorizer_v1_authorizer_proto_msgTypes[39] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2038,7 +2404,7 @@ func (x *ListPermissionsResponse) String() string { func (*ListPermissionsResponse) ProtoMessage() {} func (x *ListPermissionsResponse) ProtoReflect() protoreflect.Message { - mi := &file_authorizer_v1_authorizer_proto_msgTypes[32] + mi := &file_authorizer_v1_authorizer_proto_msgTypes[39] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2051,7 +2417,7 @@ func (x *ListPermissionsResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListPermissionsResponse.ProtoReflect.Descriptor instead. func (*ListPermissionsResponse) Descriptor() ([]byte, []int) { - return file_authorizer_v1_authorizer_proto_rawDescGZIP(), []int{32} + return file_authorizer_v1_authorizer_proto_rawDescGZIP(), []int{39} } func (x *ListPermissionsResponse) GetObjects() []string { @@ -2194,333 +2560,400 @@ var file_authorizer_v1_authorizer_proto_rawDesc = []byte{ 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x22, 0x2d, 0x0a, 0x11, 0x52, 0x65, 0x73, 0x65, 0x6e, 0x64, 0x4f, 0x74, 0x70, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x9c, 0x01, 0x0a, 0x15, 0x46, - 0x6f, 0x72, 0x67, 0x6f, 0x74, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x52, 0x65, 0x71, + 0x09, 0x52, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x77, 0x0a, 0x13, 0x53, 0x6b, + 0x69, 0x70, 0x4d, 0x66, 0x61, 0x53, 0x65, 0x74, 0x75, 0x70, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, + 0x74, 0x12, 0x1e, 0x0a, 0x05, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, + 0x42, 0x08, 0xba, 0x48, 0x05, 0x72, 0x03, 0x18, 0xc0, 0x02, 0x52, 0x05, 0x65, 0x6d, 0x61, 0x69, + 0x6c, 0x12, 0x2a, 0x0a, 0x0c, 0x70, 0x68, 0x6f, 0x6e, 0x65, 0x5f, 0x6e, 0x75, 0x6d, 0x62, 0x65, + 0x72, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x42, 0x07, 0xba, 0x48, 0x04, 0x72, 0x02, 0x18, 0x20, + 0x52, 0x0b, 0x70, 0x68, 0x6f, 0x6e, 0x65, 0x4e, 0x75, 0x6d, 0x62, 0x65, 0x72, 0x12, 0x14, 0x0a, + 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x73, 0x74, + 0x61, 0x74, 0x65, 0x22, 0x5c, 0x0a, 0x0e, 0x4c, 0x6f, 0x63, 0x6b, 0x4d, 0x66, 0x61, 0x52, 0x65, + 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1e, 0x0a, 0x05, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x18, 0x01, + 0x20, 0x01, 0x28, 0x09, 0x42, 0x08, 0xba, 0x48, 0x05, 0x72, 0x03, 0x18, 0xc0, 0x02, 0x52, 0x05, + 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x12, 0x2a, 0x0a, 0x0c, 0x70, 0x68, 0x6f, 0x6e, 0x65, 0x5f, 0x6e, + 0x75, 0x6d, 0x62, 0x65, 0x72, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x42, 0x07, 0xba, 0x48, 0x04, + 0x72, 0x02, 0x18, 0x20, 0x52, 0x0b, 0x70, 0x68, 0x6f, 0x6e, 0x65, 0x4e, 0x75, 0x6d, 0x62, 0x65, + 0x72, 0x22, 0x2b, 0x0a, 0x0f, 0x4c, 0x6f, 0x63, 0x6b, 0x4d, 0x66, 0x61, 0x52, 0x65, 0x73, 0x70, + 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, + 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x65, + 0x0a, 0x17, 0x45, 0x6d, 0x61, 0x69, 0x6c, 0x4f, 0x74, 0x70, 0x4d, 0x66, 0x61, 0x53, 0x65, 0x74, + 0x75, 0x70, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1e, 0x0a, 0x05, 0x65, 0x6d, 0x61, + 0x69, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x42, 0x08, 0xba, 0x48, 0x05, 0x72, 0x03, 0x18, + 0xc0, 0x02, 0x52, 0x05, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x12, 0x2a, 0x0a, 0x0c, 0x70, 0x68, 0x6f, + 0x6e, 0x65, 0x5f, 0x6e, 0x75, 0x6d, 0x62, 0x65, 0x72, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x42, + 0x07, 0xba, 0x48, 0x04, 0x72, 0x02, 0x18, 0x20, 0x52, 0x0b, 0x70, 0x68, 0x6f, 0x6e, 0x65, 0x4e, + 0x75, 0x6d, 0x62, 0x65, 0x72, 0x22, 0x34, 0x0a, 0x18, 0x45, 0x6d, 0x61, 0x69, 0x6c, 0x4f, 0x74, + 0x70, 0x4d, 0x66, 0x61, 0x53, 0x65, 0x74, 0x75, 0x70, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, + 0x65, 0x12, 0x18, 0x0a, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x01, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x63, 0x0a, 0x15, 0x53, + 0x6d, 0x73, 0x4f, 0x74, 0x70, 0x4d, 0x66, 0x61, 0x53, 0x65, 0x74, 0x75, 0x70, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1e, 0x0a, 0x05, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x42, 0x08, 0xba, 0x48, 0x05, 0x72, 0x03, 0x18, 0xc0, 0x02, 0x52, 0x05, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x12, 0x2a, 0x0a, 0x0c, 0x70, 0x68, 0x6f, 0x6e, 0x65, 0x5f, 0x6e, 0x75, 0x6d, 0x62, 0x65, 0x72, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x42, 0x07, 0xba, 0x48, 0x04, 0x72, 0x02, 0x18, 0x20, 0x52, 0x0b, 0x70, 0x68, 0x6f, 0x6e, 0x65, 0x4e, 0x75, 0x6d, 0x62, 0x65, 0x72, - 0x12, 0x14, 0x0a, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x12, 0x21, 0x0a, 0x0c, 0x72, 0x65, 0x64, 0x69, 0x72, 0x65, - 0x63, 0x74, 0x5f, 0x75, 0x72, 0x69, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x72, 0x65, - 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x55, 0x72, 0x69, 0x22, 0x74, 0x0a, 0x16, 0x46, 0x6f, 0x72, - 0x67, 0x6f, 0x74, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x52, 0x65, 0x73, 0x70, 0x6f, - 0x6e, 0x73, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x01, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x12, 0x40, 0x0a, - 0x1d, 0x73, 0x68, 0x6f, 0x75, 0x6c, 0x64, 0x5f, 0x73, 0x68, 0x6f, 0x77, 0x5f, 0x6d, 0x6f, 0x62, - 0x69, 0x6c, 0x65, 0x5f, 0x6f, 0x74, 0x70, 0x5f, 0x73, 0x63, 0x72, 0x65, 0x65, 0x6e, 0x18, 0x02, - 0x20, 0x01, 0x28, 0x08, 0x52, 0x19, 0x73, 0x68, 0x6f, 0x75, 0x6c, 0x64, 0x53, 0x68, 0x6f, 0x77, - 0x4d, 0x6f, 0x62, 0x69, 0x6c, 0x65, 0x4f, 0x74, 0x70, 0x53, 0x63, 0x72, 0x65, 0x65, 0x6e, 0x22, - 0xc9, 0x01, 0x0a, 0x14, 0x52, 0x65, 0x73, 0x65, 0x74, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, - 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x14, 0x0a, 0x05, 0x74, 0x6f, 0x6b, 0x65, - 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x10, - 0x0a, 0x03, 0x6f, 0x74, 0x70, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6f, 0x74, 0x70, - 0x12, 0x2a, 0x0a, 0x0c, 0x70, 0x68, 0x6f, 0x6e, 0x65, 0x5f, 0x6e, 0x75, 0x6d, 0x62, 0x65, 0x72, - 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x42, 0x07, 0xba, 0x48, 0x04, 0x72, 0x02, 0x18, 0x20, 0x52, - 0x0b, 0x70, 0x68, 0x6f, 0x6e, 0x65, 0x4e, 0x75, 0x6d, 0x62, 0x65, 0x72, 0x12, 0x26, 0x0a, 0x08, - 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x42, 0x0a, - 0xba, 0x48, 0x07, 0x72, 0x05, 0x10, 0x01, 0x18, 0x80, 0x01, 0x52, 0x08, 0x70, 0x61, 0x73, 0x73, - 0x77, 0x6f, 0x72, 0x64, 0x12, 0x35, 0x0a, 0x10, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x72, 0x6d, 0x5f, - 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x42, 0x0a, - 0xba, 0x48, 0x07, 0x72, 0x05, 0x10, 0x01, 0x18, 0x80, 0x01, 0x52, 0x0f, 0x63, 0x6f, 0x6e, 0x66, - 0x69, 0x72, 0x6d, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x22, 0x31, 0x0a, 0x15, 0x52, - 0x65, 0x73, 0x65, 0x74, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x52, 0x65, 0x73, 0x70, - 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, - 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x10, - 0x0a, 0x0e, 0x50, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, - 0x22, 0xd4, 0x04, 0x0a, 0x14, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x50, 0x72, 0x6f, 0x66, 0x69, - 0x6c, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x21, 0x0a, 0x0c, 0x6f, 0x6c, 0x64, - 0x5f, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x0b, 0x6f, 0x6c, 0x64, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x12, 0x2b, 0x0a, 0x0c, - 0x6e, 0x65, 0x77, 0x5f, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x18, 0x02, 0x20, 0x01, - 0x28, 0x09, 0x42, 0x08, 0xba, 0x48, 0x05, 0x72, 0x03, 0x18, 0x80, 0x01, 0x52, 0x0b, 0x6e, 0x65, - 0x77, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x12, 0x3a, 0x0a, 0x14, 0x63, 0x6f, 0x6e, - 0x66, 0x69, 0x72, 0x6d, 0x5f, 0x6e, 0x65, 0x77, 0x5f, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, - 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x42, 0x08, 0xba, 0x48, 0x05, 0x72, 0x03, 0x18, 0x80, - 0x01, 0x52, 0x12, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x72, 0x6d, 0x4e, 0x65, 0x77, 0x50, 0x61, 0x73, - 0x73, 0x77, 0x6f, 0x72, 0x64, 0x12, 0x1e, 0x0a, 0x05, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x18, 0x04, - 0x20, 0x01, 0x28, 0x09, 0x42, 0x08, 0xba, 0x48, 0x05, 0x72, 0x03, 0x18, 0xc0, 0x02, 0x52, 0x05, - 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x12, 0x1d, 0x0a, 0x0a, 0x67, 0x69, 0x76, 0x65, 0x6e, 0x5f, 0x6e, - 0x61, 0x6d, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x67, 0x69, 0x76, 0x65, 0x6e, - 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x1f, 0x0a, 0x0b, 0x66, 0x61, 0x6d, 0x69, 0x6c, 0x79, 0x5f, 0x6e, - 0x61, 0x6d, 0x65, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x66, 0x61, 0x6d, 0x69, 0x6c, - 0x79, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x1f, 0x0a, 0x0b, 0x6d, 0x69, 0x64, 0x64, 0x6c, 0x65, 0x5f, - 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x6d, 0x69, 0x64, 0x64, - 0x6c, 0x65, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x1a, 0x0a, 0x08, 0x6e, 0x69, 0x63, 0x6b, 0x6e, 0x61, - 0x6d, 0x65, 0x18, 0x08, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x6e, 0x69, 0x63, 0x6b, 0x6e, 0x61, - 0x6d, 0x65, 0x12, 0x16, 0x0a, 0x06, 0x67, 0x65, 0x6e, 0x64, 0x65, 0x72, 0x18, 0x09, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x06, 0x67, 0x65, 0x6e, 0x64, 0x65, 0x72, 0x12, 0x1c, 0x0a, 0x09, 0x62, 0x69, - 0x72, 0x74, 0x68, 0x64, 0x61, 0x74, 0x65, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x62, - 0x69, 0x72, 0x74, 0x68, 0x64, 0x61, 0x74, 0x65, 0x12, 0x2a, 0x0a, 0x0c, 0x70, 0x68, 0x6f, 0x6e, - 0x65, 0x5f, 0x6e, 0x75, 0x6d, 0x62, 0x65, 0x72, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x09, 0x42, 0x07, - 0xba, 0x48, 0x04, 0x72, 0x02, 0x18, 0x20, 0x52, 0x0b, 0x70, 0x68, 0x6f, 0x6e, 0x65, 0x4e, 0x75, - 0x6d, 0x62, 0x65, 0x72, 0x12, 0x18, 0x0a, 0x07, 0x70, 0x69, 0x63, 0x74, 0x75, 0x72, 0x65, 0x18, - 0x0c, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x70, 0x69, 0x63, 0x74, 0x75, 0x72, 0x65, 0x12, 0x43, - 0x0a, 0x1c, 0x69, 0x73, 0x5f, 0x6d, 0x75, 0x6c, 0x74, 0x69, 0x5f, 0x66, 0x61, 0x63, 0x74, 0x6f, - 0x72, 0x5f, 0x61, 0x75, 0x74, 0x68, 0x5f, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x0d, - 0x20, 0x01, 0x28, 0x08, 0x48, 0x00, 0x52, 0x18, 0x69, 0x73, 0x4d, 0x75, 0x6c, 0x74, 0x69, 0x46, - 0x61, 0x63, 0x74, 0x6f, 0x72, 0x41, 0x75, 0x74, 0x68, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, - 0x88, 0x01, 0x01, 0x12, 0x31, 0x0a, 0x08, 0x61, 0x70, 0x70, 0x5f, 0x64, 0x61, 0x74, 0x61, 0x18, - 0x0e, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, - 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x41, 0x70, 0x70, 0x44, 0x61, 0x74, 0x61, 0x52, 0x07, 0x61, - 0x70, 0x70, 0x44, 0x61, 0x74, 0x61, 0x42, 0x1f, 0x0a, 0x1d, 0x5f, 0x69, 0x73, 0x5f, 0x6d, 0x75, - 0x6c, 0x74, 0x69, 0x5f, 0x66, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x5f, 0x61, 0x75, 0x74, 0x68, 0x5f, - 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x22, 0x31, 0x0a, 0x15, 0x55, 0x70, 0x64, 0x61, 0x74, - 0x65, 0x50, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, - 0x12, 0x18, 0x0a, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x1a, 0x0a, 0x18, 0x44, 0x65, - 0x61, 0x63, 0x74, 0x69, 0x76, 0x61, 0x74, 0x65, 0x41, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x52, - 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0x35, 0x0a, 0x19, 0x44, 0x65, 0x61, 0x63, 0x74, 0x69, - 0x76, 0x61, 0x74, 0x65, 0x41, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, - 0x6e, 0x73, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x01, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x3d, 0x0a, - 0x0d, 0x52, 0x65, 0x76, 0x6f, 0x6b, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x2c, - 0x0a, 0x0d, 0x72, 0x65, 0x66, 0x72, 0x65, 0x73, 0x68, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, - 0x01, 0x20, 0x01, 0x28, 0x09, 0x42, 0x07, 0xba, 0x48, 0x04, 0x72, 0x02, 0x10, 0x01, 0x52, 0x0c, - 0x72, 0x65, 0x66, 0x72, 0x65, 0x73, 0x68, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x22, 0x2a, 0x0a, 0x0e, - 0x52, 0x65, 0x76, 0x6f, 0x6b, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x18, + 0x22, 0x32, 0x0a, 0x16, 0x53, 0x6d, 0x73, 0x4f, 0x74, 0x70, 0x4d, 0x66, 0x61, 0x53, 0x65, 0x74, + 0x75, 0x70, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x6d, 0x65, + 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x6d, 0x65, 0x73, + 0x73, 0x61, 0x67, 0x65, 0x22, 0x9c, 0x01, 0x0a, 0x15, 0x46, 0x6f, 0x72, 0x67, 0x6f, 0x74, 0x50, + 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1e, + 0x0a, 0x05, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x42, 0x08, 0xba, + 0x48, 0x05, 0x72, 0x03, 0x18, 0xc0, 0x02, 0x52, 0x05, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x12, 0x2a, + 0x0a, 0x0c, 0x70, 0x68, 0x6f, 0x6e, 0x65, 0x5f, 0x6e, 0x75, 0x6d, 0x62, 0x65, 0x72, 0x18, 0x02, + 0x20, 0x01, 0x28, 0x09, 0x42, 0x07, 0xba, 0x48, 0x04, 0x72, 0x02, 0x18, 0x20, 0x52, 0x0b, 0x70, + 0x68, 0x6f, 0x6e, 0x65, 0x4e, 0x75, 0x6d, 0x62, 0x65, 0x72, 0x12, 0x14, 0x0a, 0x05, 0x73, 0x74, + 0x61, 0x74, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, + 0x12, 0x21, 0x0a, 0x0c, 0x72, 0x65, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x5f, 0x75, 0x72, 0x69, + 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x72, 0x65, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, + 0x55, 0x72, 0x69, 0x22, 0x74, 0x0a, 0x16, 0x46, 0x6f, 0x72, 0x67, 0x6f, 0x74, 0x50, 0x61, 0x73, + 0x73, 0x77, 0x6f, 0x72, 0x64, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x18, 0x0a, + 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, + 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x12, 0x40, 0x0a, 0x1d, 0x73, 0x68, 0x6f, 0x75, 0x6c, + 0x64, 0x5f, 0x73, 0x68, 0x6f, 0x77, 0x5f, 0x6d, 0x6f, 0x62, 0x69, 0x6c, 0x65, 0x5f, 0x6f, 0x74, + 0x70, 0x5f, 0x73, 0x63, 0x72, 0x65, 0x65, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x19, + 0x73, 0x68, 0x6f, 0x75, 0x6c, 0x64, 0x53, 0x68, 0x6f, 0x77, 0x4d, 0x6f, 0x62, 0x69, 0x6c, 0x65, + 0x4f, 0x74, 0x70, 0x53, 0x63, 0x72, 0x65, 0x65, 0x6e, 0x22, 0xc9, 0x01, 0x0a, 0x14, 0x52, 0x65, + 0x73, 0x65, 0x74, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x12, 0x14, 0x0a, 0x05, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x05, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x10, 0x0a, 0x03, 0x6f, 0x74, 0x70, 0x18, + 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6f, 0x74, 0x70, 0x12, 0x2a, 0x0a, 0x0c, 0x70, 0x68, + 0x6f, 0x6e, 0x65, 0x5f, 0x6e, 0x75, 0x6d, 0x62, 0x65, 0x72, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, + 0x42, 0x07, 0xba, 0x48, 0x04, 0x72, 0x02, 0x18, 0x20, 0x52, 0x0b, 0x70, 0x68, 0x6f, 0x6e, 0x65, + 0x4e, 0x75, 0x6d, 0x62, 0x65, 0x72, 0x12, 0x26, 0x0a, 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, + 0x72, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x42, 0x0a, 0xba, 0x48, 0x07, 0x72, 0x05, 0x10, + 0x01, 0x18, 0x80, 0x01, 0x52, 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x12, 0x35, + 0x0a, 0x10, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x72, 0x6d, 0x5f, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, + 0x72, 0x64, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x42, 0x0a, 0xba, 0x48, 0x07, 0x72, 0x05, 0x10, + 0x01, 0x18, 0x80, 0x01, 0x52, 0x0f, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x72, 0x6d, 0x50, 0x61, 0x73, + 0x73, 0x77, 0x6f, 0x72, 0x64, 0x22, 0x31, 0x0a, 0x15, 0x52, 0x65, 0x73, 0x65, 0x74, 0x50, 0x61, + 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0xa2, 0x01, 0x0a, 0x0e, 0x53, 0x65, 0x73, - 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x14, 0x0a, 0x05, 0x72, - 0x6f, 0x6c, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, 0x52, 0x05, 0x72, 0x6f, 0x6c, 0x65, - 0x73, 0x12, 0x14, 0x0a, 0x05, 0x73, 0x63, 0x6f, 0x70, 0x65, 0x18, 0x02, 0x20, 0x03, 0x28, 0x09, - 0x52, 0x05, 0x73, 0x63, 0x6f, 0x70, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, - 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x12, 0x4e, 0x0a, - 0x12, 0x72, 0x65, 0x71, 0x75, 0x69, 0x72, 0x65, 0x64, 0x5f, 0x72, 0x65, 0x6c, 0x61, 0x74, 0x69, - 0x6f, 0x6e, 0x73, 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x61, 0x75, 0x74, 0x68, - 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x46, 0x67, 0x61, 0x52, 0x65, 0x6c, - 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x49, 0x6e, 0x70, 0x75, 0x74, 0x52, 0x11, 0x72, 0x65, 0x71, 0x75, - 0x69, 0x72, 0x65, 0x64, 0x52, 0x65, 0x6c, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x22, 0xc6, 0x01, - 0x0a, 0x17, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x4a, 0x77, 0x74, 0x54, 0x6f, 0x6b, - 0x65, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x26, 0x0a, 0x0a, 0x74, 0x6f, 0x6b, - 0x65, 0x6e, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x42, 0x07, 0xba, - 0x48, 0x04, 0x72, 0x02, 0x10, 0x01, 0x52, 0x09, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x54, 0x79, 0x70, - 0x65, 0x12, 0x1d, 0x0a, 0x05, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, - 0x42, 0x07, 0xba, 0x48, 0x04, 0x72, 0x02, 0x10, 0x01, 0x52, 0x05, 0x74, 0x6f, 0x6b, 0x65, 0x6e, - 0x12, 0x14, 0x0a, 0x05, 0x72, 0x6f, 0x6c, 0x65, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x09, 0x52, - 0x05, 0x72, 0x6f, 0x6c, 0x65, 0x73, 0x12, 0x4e, 0x0a, 0x12, 0x72, 0x65, 0x71, 0x75, 0x69, 0x72, - 0x65, 0x64, 0x5f, 0x72, 0x65, 0x6c, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x04, 0x20, 0x03, - 0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, - 0x76, 0x31, 0x2e, 0x46, 0x67, 0x61, 0x52, 0x65, 0x6c, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x49, 0x6e, - 0x70, 0x75, 0x74, 0x52, 0x11, 0x72, 0x65, 0x71, 0x75, 0x69, 0x72, 0x65, 0x64, 0x52, 0x65, 0x6c, - 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x22, 0x65, 0x0a, 0x18, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, - 0x74, 0x65, 0x4a, 0x77, 0x74, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, - 0x73, 0x65, 0x12, 0x19, 0x0a, 0x08, 0x69, 0x73, 0x5f, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x18, 0x01, - 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x69, 0x73, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x12, 0x2e, 0x0a, - 0x06, 0x63, 0x6c, 0x61, 0x69, 0x6d, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e, - 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x41, 0x70, - 0x70, 0x44, 0x61, 0x74, 0x61, 0x52, 0x06, 0x63, 0x6c, 0x61, 0x69, 0x6d, 0x73, 0x22, 0x9f, 0x01, - 0x0a, 0x16, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, - 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1f, 0x0a, 0x06, 0x63, 0x6f, 0x6f, 0x6b, - 0x69, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x42, 0x07, 0xba, 0x48, 0x04, 0x72, 0x02, 0x10, - 0x01, 0x52, 0x06, 0x63, 0x6f, 0x6f, 0x6b, 0x69, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x72, 0x6f, 0x6c, - 0x65, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x09, 0x52, 0x05, 0x72, 0x6f, 0x6c, 0x65, 0x73, 0x12, - 0x4e, 0x0a, 0x12, 0x72, 0x65, 0x71, 0x75, 0x69, 0x72, 0x65, 0x64, 0x5f, 0x72, 0x65, 0x6c, 0x61, - 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x61, 0x75, - 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x46, 0x67, 0x61, 0x52, - 0x65, 0x6c, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x49, 0x6e, 0x70, 0x75, 0x74, 0x52, 0x11, 0x72, 0x65, - 0x71, 0x75, 0x69, 0x72, 0x65, 0x64, 0x52, 0x65, 0x6c, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x22, - 0x5d, 0x0a, 0x17, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x53, 0x65, 0x73, 0x73, 0x69, - 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x19, 0x0a, 0x08, 0x69, 0x73, - 0x5f, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x69, 0x73, - 0x56, 0x61, 0x6c, 0x69, 0x64, 0x12, 0x27, 0x0a, 0x04, 0x75, 0x73, 0x65, 0x72, 0x18, 0x02, 0x20, - 0x01, 0x28, 0x0b, 0x32, 0x13, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, - 0x2e, 0x76, 0x31, 0x2e, 0x55, 0x73, 0x65, 0x72, 0x52, 0x04, 0x75, 0x73, 0x65, 0x72, 0x22, 0x0d, - 0x0a, 0x0b, 0x4d, 0x65, 0x74, 0x61, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0x76, 0x0a, - 0x17, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x50, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, - 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x47, 0x0a, 0x06, 0x63, 0x68, 0x65, 0x63, - 0x6b, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x23, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, - 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x50, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, - 0x69, 0x6f, 0x6e, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x49, 0x6e, 0x70, 0x75, 0x74, 0x42, 0x0a, 0xba, - 0x48, 0x07, 0x92, 0x01, 0x04, 0x08, 0x01, 0x10, 0x64, 0x52, 0x06, 0x63, 0x68, 0x65, 0x63, 0x6b, - 0x73, 0x12, 0x12, 0x0a, 0x04, 0x75, 0x73, 0x65, 0x72, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x04, 0x75, 0x73, 0x65, 0x72, 0x22, 0x5a, 0x0a, 0x18, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x50, 0x65, - 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, - 0x65, 0x12, 0x3e, 0x0a, 0x07, 0x72, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x73, 0x18, 0x01, 0x20, 0x03, - 0x28, 0x0b, 0x32, 0x24, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, + 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x10, 0x0a, 0x0e, 0x50, 0x72, 0x6f, 0x66, + 0x69, 0x6c, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0xd4, 0x04, 0x0a, 0x14, 0x55, + 0x70, 0x64, 0x61, 0x74, 0x65, 0x50, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x52, 0x65, 0x71, 0x75, + 0x65, 0x73, 0x74, 0x12, 0x21, 0x0a, 0x0c, 0x6f, 0x6c, 0x64, 0x5f, 0x70, 0x61, 0x73, 0x73, 0x77, + 0x6f, 0x72, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x6f, 0x6c, 0x64, 0x50, 0x61, + 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x12, 0x2b, 0x0a, 0x0c, 0x6e, 0x65, 0x77, 0x5f, 0x70, 0x61, + 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x42, 0x08, 0xba, 0x48, + 0x05, 0x72, 0x03, 0x18, 0x80, 0x01, 0x52, 0x0b, 0x6e, 0x65, 0x77, 0x50, 0x61, 0x73, 0x73, 0x77, + 0x6f, 0x72, 0x64, 0x12, 0x3a, 0x0a, 0x14, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x72, 0x6d, 0x5f, 0x6e, + 0x65, 0x77, 0x5f, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, + 0x09, 0x42, 0x08, 0xba, 0x48, 0x05, 0x72, 0x03, 0x18, 0x80, 0x01, 0x52, 0x12, 0x63, 0x6f, 0x6e, + 0x66, 0x69, 0x72, 0x6d, 0x4e, 0x65, 0x77, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x12, + 0x1e, 0x0a, 0x05, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x42, 0x08, + 0xba, 0x48, 0x05, 0x72, 0x03, 0x18, 0xc0, 0x02, 0x52, 0x05, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x12, + 0x1d, 0x0a, 0x0a, 0x67, 0x69, 0x76, 0x65, 0x6e, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x05, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x09, 0x67, 0x69, 0x76, 0x65, 0x6e, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x1f, + 0x0a, 0x0b, 0x66, 0x61, 0x6d, 0x69, 0x6c, 0x79, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x06, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x0a, 0x66, 0x61, 0x6d, 0x69, 0x6c, 0x79, 0x4e, 0x61, 0x6d, 0x65, 0x12, + 0x1f, 0x0a, 0x0b, 0x6d, 0x69, 0x64, 0x64, 0x6c, 0x65, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x07, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x6d, 0x69, 0x64, 0x64, 0x6c, 0x65, 0x4e, 0x61, 0x6d, 0x65, + 0x12, 0x1a, 0x0a, 0x08, 0x6e, 0x69, 0x63, 0x6b, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x08, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x08, 0x6e, 0x69, 0x63, 0x6b, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x16, 0x0a, 0x06, + 0x67, 0x65, 0x6e, 0x64, 0x65, 0x72, 0x18, 0x09, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x67, 0x65, + 0x6e, 0x64, 0x65, 0x72, 0x12, 0x1c, 0x0a, 0x09, 0x62, 0x69, 0x72, 0x74, 0x68, 0x64, 0x61, 0x74, + 0x65, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x62, 0x69, 0x72, 0x74, 0x68, 0x64, 0x61, + 0x74, 0x65, 0x12, 0x2a, 0x0a, 0x0c, 0x70, 0x68, 0x6f, 0x6e, 0x65, 0x5f, 0x6e, 0x75, 0x6d, 0x62, + 0x65, 0x72, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x09, 0x42, 0x07, 0xba, 0x48, 0x04, 0x72, 0x02, 0x18, + 0x20, 0x52, 0x0b, 0x70, 0x68, 0x6f, 0x6e, 0x65, 0x4e, 0x75, 0x6d, 0x62, 0x65, 0x72, 0x12, 0x18, + 0x0a, 0x07, 0x70, 0x69, 0x63, 0x74, 0x75, 0x72, 0x65, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x07, 0x70, 0x69, 0x63, 0x74, 0x75, 0x72, 0x65, 0x12, 0x43, 0x0a, 0x1c, 0x69, 0x73, 0x5f, 0x6d, + 0x75, 0x6c, 0x74, 0x69, 0x5f, 0x66, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x5f, 0x61, 0x75, 0x74, 0x68, + 0x5f, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x0d, 0x20, 0x01, 0x28, 0x08, 0x48, 0x00, + 0x52, 0x18, 0x69, 0x73, 0x4d, 0x75, 0x6c, 0x74, 0x69, 0x46, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x41, + 0x75, 0x74, 0x68, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x88, 0x01, 0x01, 0x12, 0x31, 0x0a, + 0x08, 0x61, 0x70, 0x70, 0x5f, 0x64, 0x61, 0x74, 0x61, 0x18, 0x0e, 0x20, 0x01, 0x28, 0x0b, 0x32, + 0x16, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, + 0x41, 0x70, 0x70, 0x44, 0x61, 0x74, 0x61, 0x52, 0x07, 0x61, 0x70, 0x70, 0x44, 0x61, 0x74, 0x61, + 0x42, 0x1f, 0x0a, 0x1d, 0x5f, 0x69, 0x73, 0x5f, 0x6d, 0x75, 0x6c, 0x74, 0x69, 0x5f, 0x66, 0x61, + 0x63, 0x74, 0x6f, 0x72, 0x5f, 0x61, 0x75, 0x74, 0x68, 0x5f, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, + 0x64, 0x22, 0x31, 0x0a, 0x15, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x50, 0x72, 0x6f, 0x66, 0x69, + 0x6c, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x6d, 0x65, + 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x6d, 0x65, 0x73, + 0x73, 0x61, 0x67, 0x65, 0x22, 0x1a, 0x0a, 0x18, 0x44, 0x65, 0x61, 0x63, 0x74, 0x69, 0x76, 0x61, + 0x74, 0x65, 0x41, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, + 0x22, 0x35, 0x0a, 0x19, 0x44, 0x65, 0x61, 0x63, 0x74, 0x69, 0x76, 0x61, 0x74, 0x65, 0x41, 0x63, + 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x18, 0x0a, + 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, + 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x3d, 0x0a, 0x0d, 0x52, 0x65, 0x76, 0x6f, 0x6b, + 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x2c, 0x0a, 0x0d, 0x72, 0x65, 0x66, 0x72, + 0x65, 0x73, 0x68, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x42, + 0x07, 0xba, 0x48, 0x04, 0x72, 0x02, 0x10, 0x01, 0x52, 0x0c, 0x72, 0x65, 0x66, 0x72, 0x65, 0x73, + 0x68, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x22, 0x2a, 0x0a, 0x0e, 0x52, 0x65, 0x76, 0x6f, 0x6b, 0x65, + 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x6d, 0x65, 0x73, 0x73, + 0x61, 0x67, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, + 0x67, 0x65, 0x22, 0xa2, 0x01, 0x0a, 0x0e, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, + 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x14, 0x0a, 0x05, 0x72, 0x6f, 0x6c, 0x65, 0x73, 0x18, 0x01, + 0x20, 0x03, 0x28, 0x09, 0x52, 0x05, 0x72, 0x6f, 0x6c, 0x65, 0x73, 0x12, 0x14, 0x0a, 0x05, 0x73, + 0x63, 0x6f, 0x70, 0x65, 0x18, 0x02, 0x20, 0x03, 0x28, 0x09, 0x52, 0x05, 0x73, 0x63, 0x6f, 0x70, + 0x65, 0x12, 0x14, 0x0a, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x12, 0x4e, 0x0a, 0x12, 0x72, 0x65, 0x71, 0x75, 0x69, + 0x72, 0x65, 0x64, 0x5f, 0x72, 0x65, 0x6c, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x04, 0x20, + 0x03, 0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, + 0x2e, 0x76, 0x31, 0x2e, 0x46, 0x67, 0x61, 0x52, 0x65, 0x6c, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x49, + 0x6e, 0x70, 0x75, 0x74, 0x52, 0x11, 0x72, 0x65, 0x71, 0x75, 0x69, 0x72, 0x65, 0x64, 0x52, 0x65, + 0x6c, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x22, 0xc6, 0x01, 0x0a, 0x17, 0x56, 0x61, 0x6c, 0x69, + 0x64, 0x61, 0x74, 0x65, 0x4a, 0x77, 0x74, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x52, 0x65, 0x71, 0x75, + 0x65, 0x73, 0x74, 0x12, 0x26, 0x0a, 0x0a, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x5f, 0x74, 0x79, 0x70, + 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x42, 0x07, 0xba, 0x48, 0x04, 0x72, 0x02, 0x10, 0x01, + 0x52, 0x09, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x54, 0x79, 0x70, 0x65, 0x12, 0x1d, 0x0a, 0x05, 0x74, + 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x42, 0x07, 0xba, 0x48, 0x04, 0x72, + 0x02, 0x10, 0x01, 0x52, 0x05, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x14, 0x0a, 0x05, 0x72, 0x6f, + 0x6c, 0x65, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x09, 0x52, 0x05, 0x72, 0x6f, 0x6c, 0x65, 0x73, + 0x12, 0x4e, 0x0a, 0x12, 0x72, 0x65, 0x71, 0x75, 0x69, 0x72, 0x65, 0x64, 0x5f, 0x72, 0x65, 0x6c, + 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x61, + 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x46, 0x67, 0x61, + 0x52, 0x65, 0x6c, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x49, 0x6e, 0x70, 0x75, 0x74, 0x52, 0x11, 0x72, + 0x65, 0x71, 0x75, 0x69, 0x72, 0x65, 0x64, 0x52, 0x65, 0x6c, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, + 0x22, 0x65, 0x0a, 0x18, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x4a, 0x77, 0x74, 0x54, + 0x6f, 0x6b, 0x65, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x19, 0x0a, 0x08, + 0x69, 0x73, 0x5f, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, + 0x69, 0x73, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x12, 0x2e, 0x0a, 0x06, 0x63, 0x6c, 0x61, 0x69, 0x6d, + 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, + 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x41, 0x70, 0x70, 0x44, 0x61, 0x74, 0x61, 0x52, + 0x06, 0x63, 0x6c, 0x61, 0x69, 0x6d, 0x73, 0x22, 0x9f, 0x01, 0x0a, 0x16, 0x56, 0x61, 0x6c, 0x69, + 0x64, 0x61, 0x74, 0x65, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x12, 0x1f, 0x0a, 0x06, 0x63, 0x6f, 0x6f, 0x6b, 0x69, 0x65, 0x18, 0x01, 0x20, 0x01, + 0x28, 0x09, 0x42, 0x07, 0xba, 0x48, 0x04, 0x72, 0x02, 0x10, 0x01, 0x52, 0x06, 0x63, 0x6f, 0x6f, + 0x6b, 0x69, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x72, 0x6f, 0x6c, 0x65, 0x73, 0x18, 0x02, 0x20, 0x03, + 0x28, 0x09, 0x52, 0x05, 0x72, 0x6f, 0x6c, 0x65, 0x73, 0x12, 0x4e, 0x0a, 0x12, 0x72, 0x65, 0x71, + 0x75, 0x69, 0x72, 0x65, 0x64, 0x5f, 0x72, 0x65, 0x6c, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x18, + 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, + 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x46, 0x67, 0x61, 0x52, 0x65, 0x6c, 0x61, 0x74, 0x69, 0x6f, + 0x6e, 0x49, 0x6e, 0x70, 0x75, 0x74, 0x52, 0x11, 0x72, 0x65, 0x71, 0x75, 0x69, 0x72, 0x65, 0x64, + 0x52, 0x65, 0x6c, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x22, 0x5d, 0x0a, 0x17, 0x56, 0x61, 0x6c, + 0x69, 0x64, 0x61, 0x74, 0x65, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x70, + 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x19, 0x0a, 0x08, 0x69, 0x73, 0x5f, 0x76, 0x61, 0x6c, 0x69, 0x64, + 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x69, 0x73, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x12, + 0x27, 0x0a, 0x04, 0x75, 0x73, 0x65, 0x72, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x13, 0x2e, + 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x55, 0x73, + 0x65, 0x72, 0x52, 0x04, 0x75, 0x73, 0x65, 0x72, 0x22, 0x0d, 0x0a, 0x0b, 0x4d, 0x65, 0x74, 0x61, + 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0x76, 0x0a, 0x17, 0x43, 0x68, 0x65, 0x63, 0x6b, + 0x50, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x12, 0x47, 0x0a, 0x06, 0x63, 0x68, 0x65, 0x63, 0x6b, 0x73, 0x18, 0x01, 0x20, 0x03, + 0x28, 0x0b, 0x32, 0x23, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x50, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x43, 0x68, 0x65, - 0x63, 0x6b, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x52, 0x07, 0x72, 0x65, 0x73, 0x75, 0x6c, 0x74, - 0x73, 0x22, 0x69, 0x0a, 0x16, 0x4c, 0x69, 0x73, 0x74, 0x50, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, - 0x69, 0x6f, 0x6e, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1a, 0x0a, 0x08, 0x72, - 0x65, 0x6c, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x72, - 0x65, 0x6c, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x1f, 0x0a, 0x0b, 0x6f, 0x62, 0x6a, 0x65, 0x63, - 0x74, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x6f, 0x62, - 0x6a, 0x65, 0x63, 0x74, 0x54, 0x79, 0x70, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x75, 0x73, 0x65, 0x72, - 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x75, 0x73, 0x65, 0x72, 0x22, 0x8e, 0x01, 0x0a, - 0x17, 0x4c, 0x69, 0x73, 0x74, 0x50, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x73, - 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x6f, 0x62, 0x6a, 0x65, - 0x63, 0x74, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, 0x52, 0x07, 0x6f, 0x62, 0x6a, 0x65, 0x63, - 0x74, 0x73, 0x12, 0x3b, 0x0a, 0x0b, 0x70, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, - 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, - 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x50, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, - 0x6f, 0x6e, 0x52, 0x0b, 0x70, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x12, - 0x1c, 0x0a, 0x09, 0x74, 0x72, 0x75, 0x6e, 0x63, 0x61, 0x74, 0x65, 0x64, 0x18, 0x03, 0x20, 0x01, - 0x28, 0x08, 0x52, 0x09, 0x74, 0x72, 0x75, 0x6e, 0x63, 0x61, 0x74, 0x65, 0x64, 0x32, 0xe8, 0x12, - 0x0a, 0x11, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x53, 0x65, 0x72, 0x76, - 0x69, 0x63, 0x65, 0x12, 0x66, 0x0a, 0x06, 0x53, 0x69, 0x67, 0x6e, 0x75, 0x70, 0x12, 0x1c, 0x2e, - 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x69, - 0x67, 0x6e, 0x75, 0x70, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1b, 0x2e, 0x61, 0x75, + 0x63, 0x6b, 0x49, 0x6e, 0x70, 0x75, 0x74, 0x42, 0x0a, 0xba, 0x48, 0x07, 0x92, 0x01, 0x04, 0x08, + 0x01, 0x10, 0x64, 0x52, 0x06, 0x63, 0x68, 0x65, 0x63, 0x6b, 0x73, 0x12, 0x12, 0x0a, 0x04, 0x75, + 0x73, 0x65, 0x72, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x75, 0x73, 0x65, 0x72, 0x22, + 0x5a, 0x0a, 0x18, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x50, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, + 0x6f, 0x6e, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x3e, 0x0a, 0x07, 0x72, + 0x65, 0x73, 0x75, 0x6c, 0x74, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x24, 0x2e, 0x61, + 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x50, 0x65, 0x72, + 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x52, 0x65, 0x73, 0x75, + 0x6c, 0x74, 0x52, 0x07, 0x72, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x73, 0x22, 0x69, 0x0a, 0x16, 0x4c, + 0x69, 0x73, 0x74, 0x50, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x52, 0x65, + 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1a, 0x0a, 0x08, 0x72, 0x65, 0x6c, 0x61, 0x74, 0x69, 0x6f, + 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x72, 0x65, 0x6c, 0x61, 0x74, 0x69, 0x6f, + 0x6e, 0x12, 0x1f, 0x0a, 0x0b, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x5f, 0x74, 0x79, 0x70, 0x65, + 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x54, 0x79, + 0x70, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x75, 0x73, 0x65, 0x72, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x04, 0x75, 0x73, 0x65, 0x72, 0x22, 0x8e, 0x01, 0x0a, 0x17, 0x4c, 0x69, 0x73, 0x74, 0x50, + 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, + 0x73, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x73, 0x18, 0x01, 0x20, + 0x03, 0x28, 0x09, 0x52, 0x07, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x73, 0x12, 0x3b, 0x0a, 0x0b, + 0x70, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, + 0x0b, 0x32, 0x19, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, + 0x31, 0x2e, 0x50, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x0b, 0x70, 0x65, + 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x1c, 0x0a, 0x09, 0x74, 0x72, 0x75, + 0x6e, 0x63, 0x61, 0x74, 0x65, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x09, 0x74, 0x72, + 0x75, 0x6e, 0x63, 0x61, 0x74, 0x65, 0x64, 0x32, 0xe7, 0x16, 0x0a, 0x11, 0x41, 0x75, 0x74, 0x68, + 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x66, 0x0a, + 0x06, 0x53, 0x69, 0x67, 0x6e, 0x75, 0x70, 0x12, 0x1c, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, + 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x69, 0x67, 0x6e, 0x75, 0x70, 0x52, 0x65, + 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1b, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, + 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x41, 0x75, 0x74, 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, + 0x73, 0x65, 0x22, 0x21, 0x92, 0xb5, 0x18, 0x00, 0x98, 0xb5, 0x18, 0x01, 0xa0, 0xb5, 0x18, 0x01, + 0x82, 0xd3, 0xe4, 0x93, 0x02, 0x0f, 0x3a, 0x01, 0x2a, 0x22, 0x0a, 0x2f, 0x76, 0x31, 0x2f, 0x73, + 0x69, 0x67, 0x6e, 0x75, 0x70, 0x12, 0x63, 0x0a, 0x05, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x12, 0x1b, + 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x4c, + 0x6f, 0x67, 0x69, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1b, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x41, 0x75, 0x74, 0x68, - 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x21, 0x92, 0xb5, 0x18, 0x00, 0x98, 0xb5, - 0x18, 0x01, 0xa0, 0xb5, 0x18, 0x01, 0x82, 0xd3, 0xe4, 0x93, 0x02, 0x0f, 0x3a, 0x01, 0x2a, 0x22, - 0x0a, 0x2f, 0x76, 0x31, 0x2f, 0x73, 0x69, 0x67, 0x6e, 0x75, 0x70, 0x12, 0x63, 0x0a, 0x05, 0x4c, - 0x6f, 0x67, 0x69, 0x6e, 0x12, 0x1b, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, - 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, - 0x74, 0x1a, 0x1b, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, - 0x31, 0x2e, 0x41, 0x75, 0x74, 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x20, - 0x92, 0xb5, 0x18, 0x00, 0x98, 0xb5, 0x18, 0x01, 0xa0, 0xb5, 0x18, 0x01, 0x82, 0xd3, 0xe4, 0x93, - 0x02, 0x0e, 0x3a, 0x01, 0x2a, 0x22, 0x09, 0x2f, 0x76, 0x31, 0x2f, 0x6c, 0x6f, 0x67, 0x69, 0x6e, - 0x12, 0x5d, 0x0a, 0x06, 0x4c, 0x6f, 0x67, 0x6f, 0x75, 0x74, 0x12, 0x1c, 0x2e, 0x61, 0x75, 0x74, - 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x6f, 0x67, 0x6f, 0x75, - 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1d, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, - 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x6f, 0x67, 0x6f, 0x75, 0x74, 0x52, - 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x16, 0x98, 0xb5, 0x18, 0x01, 0x82, 0xd3, 0xe4, - 0x93, 0x02, 0x0c, 0x22, 0x0a, 0x2f, 0x76, 0x31, 0x2f, 0x6c, 0x6f, 0x67, 0x6f, 0x75, 0x74, 0x12, - 0x86, 0x01, 0x0a, 0x0e, 0x4d, 0x61, 0x67, 0x69, 0x63, 0x4c, 0x69, 0x6e, 0x6b, 0x4c, 0x6f, 0x67, - 0x69, 0x6e, 0x12, 0x24, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, + 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x20, 0x92, 0xb5, 0x18, 0x00, 0x98, 0xb5, + 0x18, 0x01, 0xa0, 0xb5, 0x18, 0x01, 0x82, 0xd3, 0xe4, 0x93, 0x02, 0x0e, 0x3a, 0x01, 0x2a, 0x22, + 0x09, 0x2f, 0x76, 0x31, 0x2f, 0x6c, 0x6f, 0x67, 0x69, 0x6e, 0x12, 0x5d, 0x0a, 0x06, 0x4c, 0x6f, + 0x67, 0x6f, 0x75, 0x74, 0x12, 0x1c, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, + 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x6f, 0x67, 0x6f, 0x75, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x1a, 0x1d, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, + 0x76, 0x31, 0x2e, 0x4c, 0x6f, 0x67, 0x6f, 0x75, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, + 0x65, 0x22, 0x16, 0x98, 0xb5, 0x18, 0x01, 0x82, 0xd3, 0xe4, 0x93, 0x02, 0x0c, 0x22, 0x0a, 0x2f, + 0x76, 0x31, 0x2f, 0x6c, 0x6f, 0x67, 0x6f, 0x75, 0x74, 0x12, 0x86, 0x01, 0x0a, 0x0e, 0x4d, 0x61, + 0x67, 0x69, 0x63, 0x4c, 0x69, 0x6e, 0x6b, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x12, 0x24, 0x2e, 0x61, + 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x4d, 0x61, 0x67, + 0x69, 0x63, 0x4c, 0x69, 0x6e, 0x6b, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x1a, 0x25, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x4d, 0x61, 0x67, 0x69, 0x63, 0x4c, 0x69, 0x6e, 0x6b, 0x4c, 0x6f, 0x67, 0x69, - 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x25, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, - 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x4d, 0x61, 0x67, 0x69, 0x63, 0x4c, 0x69, - 0x6e, 0x6b, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, - 0x27, 0x98, 0xb5, 0x18, 0x01, 0xa0, 0xb5, 0x18, 0x01, 0x82, 0xd3, 0xe4, 0x93, 0x02, 0x19, 0x3a, - 0x01, 0x2a, 0x22, 0x14, 0x2f, 0x76, 0x31, 0x2f, 0x6d, 0x61, 0x67, 0x69, 0x63, 0x5f, 0x6c, 0x69, - 0x6e, 0x6b, 0x5f, 0x6c, 0x6f, 0x67, 0x69, 0x6e, 0x12, 0x72, 0x0a, 0x0b, 0x56, 0x65, 0x72, 0x69, - 0x66, 0x79, 0x45, 0x6d, 0x61, 0x69, 0x6c, 0x12, 0x21, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, - 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x56, 0x65, 0x72, 0x69, 0x66, 0x79, 0x45, 0x6d, - 0x61, 0x69, 0x6c, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1b, 0x2e, 0x61, 0x75, 0x74, - 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x41, 0x75, 0x74, 0x68, 0x52, - 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x23, 0x98, 0xb5, 0x18, 0x01, 0xa0, 0xb5, 0x18, - 0x01, 0x82, 0xd3, 0xe4, 0x93, 0x02, 0x15, 0x3a, 0x01, 0x2a, 0x22, 0x10, 0x2f, 0x76, 0x31, 0x2f, - 0x76, 0x65, 0x72, 0x69, 0x66, 0x79, 0x5f, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x12, 0x8e, 0x01, 0x0a, - 0x11, 0x52, 0x65, 0x73, 0x65, 0x6e, 0x64, 0x56, 0x65, 0x72, 0x69, 0x66, 0x79, 0x45, 0x6d, 0x61, - 0x69, 0x6c, 0x12, 0x27, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, - 0x76, 0x31, 0x2e, 0x52, 0x65, 0x73, 0x65, 0x6e, 0x64, 0x56, 0x65, 0x72, 0x69, 0x66, 0x79, 0x45, - 0x6d, 0x61, 0x69, 0x6c, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x28, 0x2e, 0x61, 0x75, - 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x73, 0x65, - 0x6e, 0x64, 0x56, 0x65, 0x72, 0x69, 0x66, 0x79, 0x45, 0x6d, 0x61, 0x69, 0x6c, 0x52, 0x65, 0x73, - 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x26, 0xa0, 0xb5, 0x18, 0x01, 0x82, 0xd3, 0xe4, 0x93, 0x02, - 0x1c, 0x3a, 0x01, 0x2a, 0x22, 0x17, 0x2f, 0x76, 0x31, 0x2f, 0x72, 0x65, 0x73, 0x65, 0x6e, 0x64, - 0x5f, 0x76, 0x65, 0x72, 0x69, 0x66, 0x79, 0x5f, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x12, 0x6c, 0x0a, - 0x09, 0x56, 0x65, 0x72, 0x69, 0x66, 0x79, 0x4f, 0x74, 0x70, 0x12, 0x1f, 0x2e, 0x61, 0x75, 0x74, - 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x56, 0x65, 0x72, 0x69, 0x66, - 0x79, 0x4f, 0x74, 0x70, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1b, 0x2e, 0x61, 0x75, - 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x41, 0x75, 0x74, 0x68, - 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x21, 0x98, 0xb5, 0x18, 0x01, 0xa0, 0xb5, - 0x18, 0x01, 0x82, 0xd3, 0xe4, 0x93, 0x02, 0x13, 0x3a, 0x01, 0x2a, 0x22, 0x0e, 0x2f, 0x76, 0x31, - 0x2f, 0x76, 0x65, 0x72, 0x69, 0x66, 0x79, 0x5f, 0x6f, 0x74, 0x70, 0x12, 0x6d, 0x0a, 0x09, 0x52, - 0x65, 0x73, 0x65, 0x6e, 0x64, 0x4f, 0x74, 0x70, 0x12, 0x1f, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, - 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x73, 0x65, 0x6e, 0x64, 0x4f, - 0x74, 0x70, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x20, 0x2e, 0x61, 0x75, 0x74, 0x68, - 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x73, 0x65, 0x6e, 0x64, - 0x4f, 0x74, 0x70, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x1d, 0xa0, 0xb5, 0x18, - 0x01, 0x82, 0xd3, 0xe4, 0x93, 0x02, 0x13, 0x3a, 0x01, 0x2a, 0x22, 0x0e, 0x2f, 0x76, 0x31, 0x2f, - 0x72, 0x65, 0x73, 0x65, 0x6e, 0x64, 0x5f, 0x6f, 0x74, 0x70, 0x12, 0x81, 0x01, 0x0a, 0x0e, 0x46, - 0x6f, 0x72, 0x67, 0x6f, 0x74, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x12, 0x24, 0x2e, - 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x46, 0x6f, - 0x72, 0x67, 0x6f, 0x74, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x52, 0x65, 0x71, 0x75, - 0x65, 0x73, 0x74, 0x1a, 0x25, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, - 0x2e, 0x76, 0x31, 0x2e, 0x46, 0x6f, 0x72, 0x67, 0x6f, 0x74, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, - 0x72, 0x64, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x22, 0xa0, 0xb5, 0x18, 0x01, - 0x82, 0xd3, 0xe4, 0x93, 0x02, 0x18, 0x3a, 0x01, 0x2a, 0x22, 0x13, 0x2f, 0x76, 0x31, 0x2f, 0x66, - 0x6f, 0x72, 0x67, 0x6f, 0x74, 0x5f, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x12, 0x81, - 0x01, 0x0a, 0x0d, 0x52, 0x65, 0x73, 0x65, 0x74, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, - 0x12, 0x23, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, - 0x2e, 0x52, 0x65, 0x73, 0x65, 0x74, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x52, 0x65, - 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x24, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, - 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x73, 0x65, 0x74, 0x50, 0x61, 0x73, 0x73, 0x77, - 0x6f, 0x72, 0x64, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x25, 0x98, 0xb5, 0x18, - 0x01, 0xa0, 0xb5, 0x18, 0x01, 0x82, 0xd3, 0xe4, 0x93, 0x02, 0x17, 0x3a, 0x01, 0x2a, 0x22, 0x12, - 0x2f, 0x76, 0x31, 0x2f, 0x72, 0x65, 0x73, 0x65, 0x74, 0x5f, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, - 0x72, 0x64, 0x12, 0x58, 0x0a, 0x07, 0x50, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x12, 0x1d, 0x2e, - 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x50, 0x72, - 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x13, 0x2e, 0x61, - 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x55, 0x73, 0x65, - 0x72, 0x22, 0x19, 0x92, 0xb5, 0x18, 0x02, 0x08, 0x01, 0x82, 0xd3, 0xe4, 0x93, 0x02, 0x0d, 0x12, - 0x0b, 0x2f, 0x76, 0x31, 0x2f, 0x70, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x12, 0x7d, 0x0a, 0x0d, - 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x50, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x12, 0x23, 0x2e, - 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x55, 0x70, - 0x64, 0x61, 0x74, 0x65, 0x50, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, - 0x73, 0x74, 0x1a, 0x24, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, - 0x76, 0x31, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x50, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, - 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x21, 0x98, 0xb5, 0x18, 0x01, 0x82, 0xd3, - 0xe4, 0x93, 0x02, 0x17, 0x3a, 0x01, 0x2a, 0x22, 0x12, 0x2f, 0x76, 0x31, 0x2f, 0x75, 0x70, 0x64, - 0x61, 0x74, 0x65, 0x5f, 0x70, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x12, 0x93, 0x01, 0x0a, 0x11, - 0x44, 0x65, 0x61, 0x63, 0x74, 0x69, 0x76, 0x61, 0x74, 0x65, 0x41, 0x63, 0x63, 0x6f, 0x75, 0x6e, - 0x74, 0x12, 0x27, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, - 0x31, 0x2e, 0x44, 0x65, 0x61, 0x63, 0x74, 0x69, 0x76, 0x61, 0x74, 0x65, 0x41, 0x63, 0x63, 0x6f, - 0x75, 0x6e, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x28, 0x2e, 0x61, 0x75, 0x74, + 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x27, 0x98, 0xb5, 0x18, 0x01, 0xa0, + 0xb5, 0x18, 0x01, 0x82, 0xd3, 0xe4, 0x93, 0x02, 0x19, 0x3a, 0x01, 0x2a, 0x22, 0x14, 0x2f, 0x76, + 0x31, 0x2f, 0x6d, 0x61, 0x67, 0x69, 0x63, 0x5f, 0x6c, 0x69, 0x6e, 0x6b, 0x5f, 0x6c, 0x6f, 0x67, + 0x69, 0x6e, 0x12, 0x72, 0x0a, 0x0b, 0x56, 0x65, 0x72, 0x69, 0x66, 0x79, 0x45, 0x6d, 0x61, 0x69, + 0x6c, 0x12, 0x21, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, + 0x31, 0x2e, 0x56, 0x65, 0x72, 0x69, 0x66, 0x79, 0x45, 0x6d, 0x61, 0x69, 0x6c, 0x52, 0x65, 0x71, + 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1b, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, + 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x41, 0x75, 0x74, 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, + 0x65, 0x22, 0x23, 0x98, 0xb5, 0x18, 0x01, 0xa0, 0xb5, 0x18, 0x01, 0x82, 0xd3, 0xe4, 0x93, 0x02, + 0x15, 0x3a, 0x01, 0x2a, 0x22, 0x10, 0x2f, 0x76, 0x31, 0x2f, 0x76, 0x65, 0x72, 0x69, 0x66, 0x79, + 0x5f, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x12, 0x8e, 0x01, 0x0a, 0x11, 0x52, 0x65, 0x73, 0x65, 0x6e, + 0x64, 0x56, 0x65, 0x72, 0x69, 0x66, 0x79, 0x45, 0x6d, 0x61, 0x69, 0x6c, 0x12, 0x27, 0x2e, 0x61, + 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x73, + 0x65, 0x6e, 0x64, 0x56, 0x65, 0x72, 0x69, 0x66, 0x79, 0x45, 0x6d, 0x61, 0x69, 0x6c, 0x52, 0x65, + 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x28, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, + 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x73, 0x65, 0x6e, 0x64, 0x56, 0x65, 0x72, 0x69, + 0x66, 0x79, 0x45, 0x6d, 0x61, 0x69, 0x6c, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, + 0x26, 0xa0, 0xb5, 0x18, 0x01, 0x82, 0xd3, 0xe4, 0x93, 0x02, 0x1c, 0x3a, 0x01, 0x2a, 0x22, 0x17, + 0x2f, 0x76, 0x31, 0x2f, 0x72, 0x65, 0x73, 0x65, 0x6e, 0x64, 0x5f, 0x76, 0x65, 0x72, 0x69, 0x66, + 0x79, 0x5f, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x12, 0x6c, 0x0a, 0x09, 0x56, 0x65, 0x72, 0x69, 0x66, + 0x79, 0x4f, 0x74, 0x70, 0x12, 0x1f, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, + 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x56, 0x65, 0x72, 0x69, 0x66, 0x79, 0x4f, 0x74, 0x70, 0x52, 0x65, + 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1b, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, + 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x41, 0x75, 0x74, 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, + 0x73, 0x65, 0x22, 0x21, 0x98, 0xb5, 0x18, 0x01, 0xa0, 0xb5, 0x18, 0x01, 0x82, 0xd3, 0xe4, 0x93, + 0x02, 0x13, 0x3a, 0x01, 0x2a, 0x22, 0x0e, 0x2f, 0x76, 0x31, 0x2f, 0x76, 0x65, 0x72, 0x69, 0x66, + 0x79, 0x5f, 0x6f, 0x74, 0x70, 0x12, 0x6d, 0x0a, 0x09, 0x52, 0x65, 0x73, 0x65, 0x6e, 0x64, 0x4f, + 0x74, 0x70, 0x12, 0x1f, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, + 0x76, 0x31, 0x2e, 0x52, 0x65, 0x73, 0x65, 0x6e, 0x64, 0x4f, 0x74, 0x70, 0x52, 0x65, 0x71, 0x75, + 0x65, 0x73, 0x74, 0x1a, 0x20, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, + 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x73, 0x65, 0x6e, 0x64, 0x4f, 0x74, 0x70, 0x52, 0x65, 0x73, + 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x1d, 0xa0, 0xb5, 0x18, 0x01, 0x82, 0xd3, 0xe4, 0x93, 0x02, + 0x13, 0x3a, 0x01, 0x2a, 0x22, 0x0e, 0x2f, 0x76, 0x31, 0x2f, 0x72, 0x65, 0x73, 0x65, 0x6e, 0x64, + 0x5f, 0x6f, 0x74, 0x70, 0x12, 0x76, 0x0a, 0x0c, 0x53, 0x6b, 0x69, 0x70, 0x4d, 0x66, 0x61, 0x53, + 0x65, 0x74, 0x75, 0x70, 0x12, 0x22, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, + 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x6b, 0x69, 0x70, 0x4d, 0x66, 0x61, 0x53, 0x65, 0x74, 0x75, + 0x70, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1b, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, + 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x41, 0x75, 0x74, 0x68, 0x52, 0x65, 0x73, + 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x25, 0x98, 0xb5, 0x18, 0x01, 0xa0, 0xb5, 0x18, 0x01, 0x82, + 0xd3, 0xe4, 0x93, 0x02, 0x17, 0x3a, 0x01, 0x2a, 0x22, 0x12, 0x2f, 0x76, 0x31, 0x2f, 0x73, 0x6b, + 0x69, 0x70, 0x5f, 0x6d, 0x66, 0x61, 0x5f, 0x73, 0x65, 0x74, 0x75, 0x70, 0x12, 0x69, 0x0a, 0x07, + 0x4c, 0x6f, 0x63, 0x6b, 0x4d, 0x66, 0x61, 0x12, 0x1d, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, + 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x6f, 0x63, 0x6b, 0x4d, 0x66, 0x61, 0x52, + 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1e, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, + 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x6f, 0x63, 0x6b, 0x4d, 0x66, 0x61, 0x52, 0x65, + 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x1f, 0x98, 0xb5, 0x18, 0x01, 0xa0, 0xb5, 0x18, 0x01, + 0x82, 0xd3, 0xe4, 0x93, 0x02, 0x11, 0x3a, 0x01, 0x2a, 0x22, 0x0c, 0x2f, 0x76, 0x31, 0x2f, 0x6c, + 0x6f, 0x63, 0x6b, 0x5f, 0x6d, 0x66, 0x61, 0x12, 0x8f, 0x01, 0x0a, 0x10, 0x45, 0x6d, 0x61, 0x69, + 0x6c, 0x4f, 0x74, 0x70, 0x4d, 0x66, 0x61, 0x53, 0x65, 0x74, 0x75, 0x70, 0x12, 0x26, 0x2e, 0x61, + 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x45, 0x6d, 0x61, + 0x69, 0x6c, 0x4f, 0x74, 0x70, 0x4d, 0x66, 0x61, 0x53, 0x65, 0x74, 0x75, 0x70, 0x52, 0x65, 0x71, + 0x75, 0x65, 0x73, 0x74, 0x1a, 0x27, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, + 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x45, 0x6d, 0x61, 0x69, 0x6c, 0x4f, 0x74, 0x70, 0x4d, 0x66, 0x61, + 0x53, 0x65, 0x74, 0x75, 0x70, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x2a, 0x98, + 0xb5, 0x18, 0x01, 0xa0, 0xb5, 0x18, 0x01, 0x82, 0xd3, 0xe4, 0x93, 0x02, 0x1c, 0x3a, 0x01, 0x2a, + 0x22, 0x17, 0x2f, 0x76, 0x31, 0x2f, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x5f, 0x6f, 0x74, 0x70, 0x5f, + 0x6d, 0x66, 0x61, 0x5f, 0x73, 0x65, 0x74, 0x75, 0x70, 0x12, 0x87, 0x01, 0x0a, 0x0e, 0x53, 0x6d, + 0x73, 0x4f, 0x74, 0x70, 0x4d, 0x66, 0x61, 0x53, 0x65, 0x74, 0x75, 0x70, 0x12, 0x24, 0x2e, 0x61, + 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x6d, 0x73, + 0x4f, 0x74, 0x70, 0x4d, 0x66, 0x61, 0x53, 0x65, 0x74, 0x75, 0x70, 0x52, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x1a, 0x25, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, + 0x76, 0x31, 0x2e, 0x53, 0x6d, 0x73, 0x4f, 0x74, 0x70, 0x4d, 0x66, 0x61, 0x53, 0x65, 0x74, 0x75, + 0x70, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x28, 0x98, 0xb5, 0x18, 0x01, 0xa0, + 0xb5, 0x18, 0x01, 0x82, 0xd3, 0xe4, 0x93, 0x02, 0x1a, 0x3a, 0x01, 0x2a, 0x22, 0x15, 0x2f, 0x76, + 0x31, 0x2f, 0x73, 0x6d, 0x73, 0x5f, 0x6f, 0x74, 0x70, 0x5f, 0x6d, 0x66, 0x61, 0x5f, 0x73, 0x65, + 0x74, 0x75, 0x70, 0x12, 0x81, 0x01, 0x0a, 0x0e, 0x46, 0x6f, 0x72, 0x67, 0x6f, 0x74, 0x50, 0x61, + 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x12, 0x24, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, + 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x46, 0x6f, 0x72, 0x67, 0x6f, 0x74, 0x50, 0x61, 0x73, + 0x73, 0x77, 0x6f, 0x72, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x25, 0x2e, 0x61, + 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x46, 0x6f, 0x72, + 0x67, 0x6f, 0x74, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x52, 0x65, 0x73, 0x70, 0x6f, + 0x6e, 0x73, 0x65, 0x22, 0x22, 0xa0, 0xb5, 0x18, 0x01, 0x82, 0xd3, 0xe4, 0x93, 0x02, 0x18, 0x3a, + 0x01, 0x2a, 0x22, 0x13, 0x2f, 0x76, 0x31, 0x2f, 0x66, 0x6f, 0x72, 0x67, 0x6f, 0x74, 0x5f, 0x70, + 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x12, 0x81, 0x01, 0x0a, 0x0d, 0x52, 0x65, 0x73, 0x65, + 0x74, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x12, 0x23, 0x2e, 0x61, 0x75, 0x74, 0x68, + 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x73, 0x65, 0x74, 0x50, + 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x24, + 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x52, + 0x65, 0x73, 0x65, 0x74, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x52, 0x65, 0x73, 0x70, + 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x25, 0x98, 0xb5, 0x18, 0x01, 0xa0, 0xb5, 0x18, 0x01, 0x82, 0xd3, + 0xe4, 0x93, 0x02, 0x17, 0x3a, 0x01, 0x2a, 0x22, 0x12, 0x2f, 0x76, 0x31, 0x2f, 0x72, 0x65, 0x73, + 0x65, 0x74, 0x5f, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x12, 0x58, 0x0a, 0x07, 0x50, + 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x12, 0x1d, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, + 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x50, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x52, 0x65, + 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x13, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, + 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x55, 0x73, 0x65, 0x72, 0x22, 0x19, 0x92, 0xb5, 0x18, 0x02, + 0x08, 0x01, 0x82, 0xd3, 0xe4, 0x93, 0x02, 0x0d, 0x12, 0x0b, 0x2f, 0x76, 0x31, 0x2f, 0x70, 0x72, + 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x12, 0x7d, 0x0a, 0x0d, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x50, + 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x12, 0x23, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, + 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x50, 0x72, 0x6f, + 0x66, 0x69, 0x6c, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x24, 0x2e, 0x61, 0x75, + 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x55, 0x70, 0x64, 0x61, + 0x74, 0x65, 0x50, 0x72, 0x6f, 0x66, 0x69, 0x6c, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, + 0x65, 0x22, 0x21, 0x98, 0xb5, 0x18, 0x01, 0x82, 0xd3, 0xe4, 0x93, 0x02, 0x17, 0x3a, 0x01, 0x2a, + 0x22, 0x12, 0x2f, 0x76, 0x31, 0x2f, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x5f, 0x70, 0x72, 0x6f, + 0x66, 0x69, 0x6c, 0x65, 0x12, 0x93, 0x01, 0x0a, 0x11, 0x44, 0x65, 0x61, 0x63, 0x74, 0x69, 0x76, + 0x61, 0x74, 0x65, 0x41, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x27, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x44, 0x65, 0x61, 0x63, 0x74, - 0x69, 0x76, 0x61, 0x74, 0x65, 0x41, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x52, 0x65, 0x73, 0x70, - 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x2b, 0x92, 0xb5, 0x18, 0x02, 0x18, 0x01, 0x98, 0xb5, 0x18, 0x01, - 0x82, 0xd3, 0xe4, 0x93, 0x02, 0x1b, 0x3a, 0x01, 0x2a, 0x22, 0x16, 0x2f, 0x76, 0x31, 0x2f, 0x64, - 0x65, 0x61, 0x63, 0x74, 0x69, 0x76, 0x61, 0x74, 0x65, 0x5f, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, - 0x74, 0x12, 0x64, 0x0a, 0x06, 0x52, 0x65, 0x76, 0x6f, 0x6b, 0x65, 0x12, 0x1c, 0x2e, 0x61, 0x75, - 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x76, 0x6f, - 0x6b, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1d, 0x2e, 0x61, 0x75, 0x74, 0x68, - 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x76, 0x6f, 0x6b, 0x65, - 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x1d, 0x98, 0xb5, 0x18, 0x01, 0xa0, 0xb5, - 0x18, 0x01, 0x82, 0xd3, 0xe4, 0x93, 0x02, 0x0f, 0x3a, 0x01, 0x2a, 0x22, 0x0a, 0x2f, 0x76, 0x31, - 0x2f, 0x72, 0x65, 0x76, 0x6f, 0x6b, 0x65, 0x12, 0x5d, 0x0a, 0x07, 0x53, 0x65, 0x73, 0x73, 0x69, - 0x6f, 0x6e, 0x12, 0x1d, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, - 0x76, 0x31, 0x2e, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, - 0x74, 0x1a, 0x1b, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, - 0x31, 0x2e, 0x41, 0x75, 0x74, 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x16, - 0x82, 0xd3, 0xe4, 0x93, 0x02, 0x10, 0x3a, 0x01, 0x2a, 0x22, 0x0b, 0x2f, 0x76, 0x31, 0x2f, 0x73, - 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x8a, 0x01, 0x0a, 0x10, 0x56, 0x61, 0x6c, 0x69, 0x64, - 0x61, 0x74, 0x65, 0x4a, 0x77, 0x74, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x26, 0x2e, 0x61, 0x75, - 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x56, 0x61, 0x6c, 0x69, - 0x64, 0x61, 0x74, 0x65, 0x4a, 0x77, 0x74, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x52, 0x65, 0x71, 0x75, - 0x65, 0x73, 0x74, 0x1a, 0x27, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, - 0x2e, 0x76, 0x31, 0x2e, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x4a, 0x77, 0x74, 0x54, - 0x6f, 0x6b, 0x65, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x25, 0xa0, 0xb5, - 0x18, 0x01, 0x82, 0xd3, 0xe4, 0x93, 0x02, 0x1b, 0x3a, 0x01, 0x2a, 0x22, 0x16, 0x2f, 0x76, 0x31, - 0x2f, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x5f, 0x6a, 0x77, 0x74, 0x5f, 0x74, 0x6f, - 0x6b, 0x65, 0x6e, 0x12, 0x85, 0x01, 0x0a, 0x0f, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, - 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x25, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, + 0x69, 0x76, 0x61, 0x74, 0x65, 0x41, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x52, 0x65, 0x71, 0x75, + 0x65, 0x73, 0x74, 0x1a, 0x28, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, + 0x2e, 0x76, 0x31, 0x2e, 0x44, 0x65, 0x61, 0x63, 0x74, 0x69, 0x76, 0x61, 0x74, 0x65, 0x41, 0x63, + 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x2b, 0x92, + 0xb5, 0x18, 0x02, 0x18, 0x01, 0x98, 0xb5, 0x18, 0x01, 0x82, 0xd3, 0xe4, 0x93, 0x02, 0x1b, 0x3a, + 0x01, 0x2a, 0x22, 0x16, 0x2f, 0x76, 0x31, 0x2f, 0x64, 0x65, 0x61, 0x63, 0x74, 0x69, 0x76, 0x61, + 0x74, 0x65, 0x5f, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x64, 0x0a, 0x06, 0x52, 0x65, + 0x76, 0x6f, 0x6b, 0x65, 0x12, 0x1c, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, + 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x76, 0x6f, 0x6b, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x1a, 0x1d, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, + 0x76, 0x31, 0x2e, 0x52, 0x65, 0x76, 0x6f, 0x6b, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, + 0x65, 0x22, 0x1d, 0x98, 0xb5, 0x18, 0x01, 0xa0, 0xb5, 0x18, 0x01, 0x82, 0xd3, 0xe4, 0x93, 0x02, + 0x0f, 0x3a, 0x01, 0x2a, 0x22, 0x0a, 0x2f, 0x76, 0x31, 0x2f, 0x72, 0x65, 0x76, 0x6f, 0x6b, 0x65, + 0x12, 0x5d, 0x0a, 0x07, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x1d, 0x2e, 0x61, 0x75, + 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x65, 0x73, 0x73, + 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1b, 0x2e, 0x61, 0x75, 0x74, + 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x41, 0x75, 0x74, 0x68, 0x52, + 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x16, 0x82, 0xd3, 0xe4, 0x93, 0x02, 0x10, 0x3a, + 0x01, 0x2a, 0x22, 0x0b, 0x2f, 0x76, 0x31, 0x2f, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x12, + 0x8a, 0x01, 0x0a, 0x10, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x4a, 0x77, 0x74, 0x54, + 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x26, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, + 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x4a, 0x77, 0x74, + 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x27, 0x2e, 0x61, + 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x56, 0x61, 0x6c, + 0x69, 0x64, 0x61, 0x74, 0x65, 0x4a, 0x77, 0x74, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x52, 0x65, 0x73, + 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x25, 0xa0, 0xb5, 0x18, 0x01, 0x82, 0xd3, 0xe4, 0x93, 0x02, + 0x1b, 0x3a, 0x01, 0x2a, 0x22, 0x16, 0x2f, 0x76, 0x31, 0x2f, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x61, + 0x74, 0x65, 0x5f, 0x6a, 0x77, 0x74, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x85, 0x01, 0x0a, + 0x0f, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, + 0x12, 0x25, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, + 0x2e, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, + 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x26, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, - 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x26, - 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x56, - 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, - 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x23, 0xa0, 0xb5, 0x18, 0x01, 0x82, 0xd3, 0xe4, 0x93, - 0x02, 0x19, 0x3a, 0x01, 0x2a, 0x22, 0x14, 0x2f, 0x76, 0x31, 0x2f, 0x76, 0x61, 0x6c, 0x69, 0x64, - 0x61, 0x74, 0x65, 0x5f, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x53, 0x0a, 0x04, 0x4d, - 0x65, 0x74, 0x61, 0x12, 0x1a, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, - 0x2e, 0x76, 0x31, 0x2e, 0x4d, 0x65, 0x74, 0x61, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, - 0x13, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, - 0x4d, 0x65, 0x74, 0x61, 0x22, 0x1a, 0x92, 0xb5, 0x18, 0x02, 0x08, 0x01, 0xa0, 0xb5, 0x18, 0x01, - 0x82, 0xd3, 0xe4, 0x93, 0x02, 0x0a, 0x12, 0x08, 0x2f, 0x76, 0x31, 0x2f, 0x6d, 0x65, 0x74, 0x61, - 0x12, 0x8b, 0x01, 0x0a, 0x10, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x50, 0x65, 0x72, 0x6d, 0x69, 0x73, - 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x26, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, - 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x50, 0x65, 0x72, 0x6d, 0x69, - 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x27, 0x2e, - 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x68, - 0x65, 0x63, 0x6b, 0x50, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x52, 0x65, - 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x26, 0x92, 0xb5, 0x18, 0x02, 0x08, 0x01, 0x82, 0xd3, - 0xe4, 0x93, 0x02, 0x1a, 0x3a, 0x01, 0x2a, 0x22, 0x15, 0x2f, 0x76, 0x31, 0x2f, 0x63, 0x68, 0x65, - 0x63, 0x6b, 0x5f, 0x70, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x87, - 0x01, 0x0a, 0x0f, 0x4c, 0x69, 0x73, 0x74, 0x50, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, - 0x6e, 0x73, 0x12, 0x25, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, + 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, + 0x23, 0xa0, 0xb5, 0x18, 0x01, 0x82, 0xd3, 0xe4, 0x93, 0x02, 0x19, 0x3a, 0x01, 0x2a, 0x22, 0x14, + 0x2f, 0x76, 0x31, 0x2f, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x5f, 0x73, 0x65, 0x73, + 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x53, 0x0a, 0x04, 0x4d, 0x65, 0x74, 0x61, 0x12, 0x1a, 0x2e, 0x61, + 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x4d, 0x65, 0x74, + 0x61, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x13, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, + 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x4d, 0x65, 0x74, 0x61, 0x22, 0x1a, 0x92, + 0xb5, 0x18, 0x02, 0x08, 0x01, 0xa0, 0xb5, 0x18, 0x01, 0x82, 0xd3, 0xe4, 0x93, 0x02, 0x0a, 0x12, + 0x08, 0x2f, 0x76, 0x31, 0x2f, 0x6d, 0x65, 0x74, 0x61, 0x12, 0x8b, 0x01, 0x0a, 0x10, 0x43, 0x68, + 0x65, 0x63, 0x6b, 0x50, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x26, + 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x43, + 0x68, 0x65, 0x63, 0x6b, 0x50, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x52, + 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x27, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, + 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x50, 0x65, 0x72, 0x6d, + 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, + 0x26, 0x92, 0xb5, 0x18, 0x02, 0x08, 0x01, 0x82, 0xd3, 0xe4, 0x93, 0x02, 0x1a, 0x3a, 0x01, 0x2a, + 0x22, 0x15, 0x2f, 0x76, 0x31, 0x2f, 0x63, 0x68, 0x65, 0x63, 0x6b, 0x5f, 0x70, 0x65, 0x72, 0x6d, + 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x87, 0x01, 0x0a, 0x0f, 0x4c, 0x69, 0x73, 0x74, + 0x50, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x25, 0x2e, 0x61, 0x75, + 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x69, 0x73, 0x74, + 0x50, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x1a, 0x26, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x50, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, - 0x6e, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x26, 0x2e, 0x61, 0x75, 0x74, 0x68, - 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x50, 0x65, - 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, - 0x65, 0x22, 0x25, 0x92, 0xb5, 0x18, 0x02, 0x08, 0x01, 0x82, 0xd3, 0xe4, 0x93, 0x02, 0x19, 0x3a, - 0x01, 0x2a, 0x22, 0x14, 0x2f, 0x76, 0x31, 0x2f, 0x6c, 0x69, 0x73, 0x74, 0x5f, 0x70, 0x65, 0x72, - 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x42, 0xc0, 0x01, 0x0a, 0x11, 0x63, 0x6f, 0x6d, - 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x42, 0x0f, - 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x50, - 0x01, 0x5a, 0x45, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x61, 0x75, - 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x64, 0x65, 0x76, 0x2f, 0x61, 0x75, 0x74, 0x68, - 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2f, 0x67, 0x65, 0x6e, 0x2f, 0x67, 0x6f, 0x2f, 0x61, 0x75, - 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2f, 0x76, 0x31, 0x3b, 0x61, 0x75, 0x74, 0x68, - 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x76, 0x31, 0xa2, 0x02, 0x03, 0x41, 0x58, 0x58, 0xaa, 0x02, - 0x0d, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x56, 0x31, 0xca, 0x02, - 0x0d, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x5c, 0x56, 0x31, 0xe2, 0x02, - 0x19, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x5c, 0x56, 0x31, 0x5c, 0x47, - 0x50, 0x42, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0xea, 0x02, 0x0e, 0x41, 0x75, 0x74, - 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x3a, 0x3a, 0x56, 0x31, 0x62, 0x06, 0x70, 0x72, 0x6f, - 0x74, 0x6f, 0x33, + 0x6e, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x25, 0x92, 0xb5, 0x18, 0x02, + 0x08, 0x01, 0x82, 0xd3, 0xe4, 0x93, 0x02, 0x19, 0x3a, 0x01, 0x2a, 0x22, 0x14, 0x2f, 0x76, 0x31, + 0x2f, 0x6c, 0x69, 0x73, 0x74, 0x5f, 0x70, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, + 0x73, 0x42, 0xc0, 0x01, 0x0a, 0x11, 0x63, 0x6f, 0x6d, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, + 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x42, 0x0f, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, + 0x7a, 0x65, 0x72, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x50, 0x01, 0x5a, 0x45, 0x67, 0x69, 0x74, 0x68, + 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, + 0x72, 0x64, 0x65, 0x76, 0x2f, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2f, + 0x67, 0x65, 0x6e, 0x2f, 0x67, 0x6f, 0x2f, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, + 0x72, 0x2f, 0x76, 0x31, 0x3b, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x76, + 0x31, 0xa2, 0x02, 0x03, 0x41, 0x58, 0x58, 0xaa, 0x02, 0x0d, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, + 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x56, 0x31, 0xca, 0x02, 0x0d, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, + 0x69, 0x7a, 0x65, 0x72, 0x5c, 0x56, 0x31, 0xe2, 0x02, 0x19, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, + 0x69, 0x7a, 0x65, 0x72, 0x5c, 0x56, 0x31, 0x5c, 0x47, 0x50, 0x42, 0x4d, 0x65, 0x74, 0x61, 0x64, + 0x61, 0x74, 0x61, 0xea, 0x02, 0x0e, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, + 0x3a, 0x3a, 0x56, 0x31, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, } var ( @@ -2535,7 +2968,7 @@ func file_authorizer_v1_authorizer_proto_rawDescGZIP() []byte { return file_authorizer_v1_authorizer_proto_rawDescData } -var file_authorizer_v1_authorizer_proto_msgTypes = make([]protoimpl.MessageInfo, 33) +var file_authorizer_v1_authorizer_proto_msgTypes = make([]protoimpl.MessageInfo, 40) var file_authorizer_v1_authorizer_proto_goTypes = []any{ (*SignupRequest)(nil), // 0: authorizer.v1.SignupRequest (*LoginRequest)(nil), // 1: authorizer.v1.LoginRequest @@ -2549,47 +2982,54 @@ var file_authorizer_v1_authorizer_proto_goTypes = []any{ (*VerifyOtpRequest)(nil), // 9: authorizer.v1.VerifyOtpRequest (*ResendOtpRequest)(nil), // 10: authorizer.v1.ResendOtpRequest (*ResendOtpResponse)(nil), // 11: authorizer.v1.ResendOtpResponse - (*ForgotPasswordRequest)(nil), // 12: authorizer.v1.ForgotPasswordRequest - (*ForgotPasswordResponse)(nil), // 13: authorizer.v1.ForgotPasswordResponse - (*ResetPasswordRequest)(nil), // 14: authorizer.v1.ResetPasswordRequest - (*ResetPasswordResponse)(nil), // 15: authorizer.v1.ResetPasswordResponse - (*ProfileRequest)(nil), // 16: authorizer.v1.ProfileRequest - (*UpdateProfileRequest)(nil), // 17: authorizer.v1.UpdateProfileRequest - (*UpdateProfileResponse)(nil), // 18: authorizer.v1.UpdateProfileResponse - (*DeactivateAccountRequest)(nil), // 19: authorizer.v1.DeactivateAccountRequest - (*DeactivateAccountResponse)(nil), // 20: authorizer.v1.DeactivateAccountResponse - (*RevokeRequest)(nil), // 21: authorizer.v1.RevokeRequest - (*RevokeResponse)(nil), // 22: authorizer.v1.RevokeResponse - (*SessionRequest)(nil), // 23: authorizer.v1.SessionRequest - (*ValidateJwtTokenRequest)(nil), // 24: authorizer.v1.ValidateJwtTokenRequest - (*ValidateJwtTokenResponse)(nil), // 25: authorizer.v1.ValidateJwtTokenResponse - (*ValidateSessionRequest)(nil), // 26: authorizer.v1.ValidateSessionRequest - (*ValidateSessionResponse)(nil), // 27: authorizer.v1.ValidateSessionResponse - (*MetaRequest)(nil), // 28: authorizer.v1.MetaRequest - (*CheckPermissionsRequest)(nil), // 29: authorizer.v1.CheckPermissionsRequest - (*CheckPermissionsResponse)(nil), // 30: authorizer.v1.CheckPermissionsResponse - (*ListPermissionsRequest)(nil), // 31: authorizer.v1.ListPermissionsRequest - (*ListPermissionsResponse)(nil), // 32: authorizer.v1.ListPermissionsResponse - (*AppData)(nil), // 33: authorizer.v1.AppData - (*FgaRelationInput)(nil), // 34: authorizer.v1.FgaRelationInput - (*User)(nil), // 35: authorizer.v1.User - (*PermissionCheckInput)(nil), // 36: authorizer.v1.PermissionCheckInput - (*PermissionCheckResult)(nil), // 37: authorizer.v1.PermissionCheckResult - (*Permission)(nil), // 38: authorizer.v1.Permission - (*AuthResponse)(nil), // 39: authorizer.v1.AuthResponse - (*Meta)(nil), // 40: authorizer.v1.Meta + (*SkipMfaSetupRequest)(nil), // 12: authorizer.v1.SkipMfaSetupRequest + (*LockMfaRequest)(nil), // 13: authorizer.v1.LockMfaRequest + (*LockMfaResponse)(nil), // 14: authorizer.v1.LockMfaResponse + (*EmailOtpMfaSetupRequest)(nil), // 15: authorizer.v1.EmailOtpMfaSetupRequest + (*EmailOtpMfaSetupResponse)(nil), // 16: authorizer.v1.EmailOtpMfaSetupResponse + (*SmsOtpMfaSetupRequest)(nil), // 17: authorizer.v1.SmsOtpMfaSetupRequest + (*SmsOtpMfaSetupResponse)(nil), // 18: authorizer.v1.SmsOtpMfaSetupResponse + (*ForgotPasswordRequest)(nil), // 19: authorizer.v1.ForgotPasswordRequest + (*ForgotPasswordResponse)(nil), // 20: authorizer.v1.ForgotPasswordResponse + (*ResetPasswordRequest)(nil), // 21: authorizer.v1.ResetPasswordRequest + (*ResetPasswordResponse)(nil), // 22: authorizer.v1.ResetPasswordResponse + (*ProfileRequest)(nil), // 23: authorizer.v1.ProfileRequest + (*UpdateProfileRequest)(nil), // 24: authorizer.v1.UpdateProfileRequest + (*UpdateProfileResponse)(nil), // 25: authorizer.v1.UpdateProfileResponse + (*DeactivateAccountRequest)(nil), // 26: authorizer.v1.DeactivateAccountRequest + (*DeactivateAccountResponse)(nil), // 27: authorizer.v1.DeactivateAccountResponse + (*RevokeRequest)(nil), // 28: authorizer.v1.RevokeRequest + (*RevokeResponse)(nil), // 29: authorizer.v1.RevokeResponse + (*SessionRequest)(nil), // 30: authorizer.v1.SessionRequest + (*ValidateJwtTokenRequest)(nil), // 31: authorizer.v1.ValidateJwtTokenRequest + (*ValidateJwtTokenResponse)(nil), // 32: authorizer.v1.ValidateJwtTokenResponse + (*ValidateSessionRequest)(nil), // 33: authorizer.v1.ValidateSessionRequest + (*ValidateSessionResponse)(nil), // 34: authorizer.v1.ValidateSessionResponse + (*MetaRequest)(nil), // 35: authorizer.v1.MetaRequest + (*CheckPermissionsRequest)(nil), // 36: authorizer.v1.CheckPermissionsRequest + (*CheckPermissionsResponse)(nil), // 37: authorizer.v1.CheckPermissionsResponse + (*ListPermissionsRequest)(nil), // 38: authorizer.v1.ListPermissionsRequest + (*ListPermissionsResponse)(nil), // 39: authorizer.v1.ListPermissionsResponse + (*AppData)(nil), // 40: authorizer.v1.AppData + (*FgaRelationInput)(nil), // 41: authorizer.v1.FgaRelationInput + (*User)(nil), // 42: authorizer.v1.User + (*PermissionCheckInput)(nil), // 43: authorizer.v1.PermissionCheckInput + (*PermissionCheckResult)(nil), // 44: authorizer.v1.PermissionCheckResult + (*Permission)(nil), // 45: authorizer.v1.Permission + (*AuthResponse)(nil), // 46: authorizer.v1.AuthResponse + (*Meta)(nil), // 47: authorizer.v1.Meta } var file_authorizer_v1_authorizer_proto_depIdxs = []int32{ - 33, // 0: authorizer.v1.SignupRequest.app_data:type_name -> authorizer.v1.AppData - 33, // 1: authorizer.v1.UpdateProfileRequest.app_data:type_name -> authorizer.v1.AppData - 34, // 2: authorizer.v1.SessionRequest.required_relations:type_name -> authorizer.v1.FgaRelationInput - 34, // 3: authorizer.v1.ValidateJwtTokenRequest.required_relations:type_name -> authorizer.v1.FgaRelationInput - 33, // 4: authorizer.v1.ValidateJwtTokenResponse.claims:type_name -> authorizer.v1.AppData - 34, // 5: authorizer.v1.ValidateSessionRequest.required_relations:type_name -> authorizer.v1.FgaRelationInput - 35, // 6: authorizer.v1.ValidateSessionResponse.user:type_name -> authorizer.v1.User - 36, // 7: authorizer.v1.CheckPermissionsRequest.checks:type_name -> authorizer.v1.PermissionCheckInput - 37, // 8: authorizer.v1.CheckPermissionsResponse.results:type_name -> authorizer.v1.PermissionCheckResult - 38, // 9: authorizer.v1.ListPermissionsResponse.permissions:type_name -> authorizer.v1.Permission + 40, // 0: authorizer.v1.SignupRequest.app_data:type_name -> authorizer.v1.AppData + 40, // 1: authorizer.v1.UpdateProfileRequest.app_data:type_name -> authorizer.v1.AppData + 41, // 2: authorizer.v1.SessionRequest.required_relations:type_name -> authorizer.v1.FgaRelationInput + 41, // 3: authorizer.v1.ValidateJwtTokenRequest.required_relations:type_name -> authorizer.v1.FgaRelationInput + 40, // 4: authorizer.v1.ValidateJwtTokenResponse.claims:type_name -> authorizer.v1.AppData + 41, // 5: authorizer.v1.ValidateSessionRequest.required_relations:type_name -> authorizer.v1.FgaRelationInput + 42, // 6: authorizer.v1.ValidateSessionResponse.user:type_name -> authorizer.v1.User + 43, // 7: authorizer.v1.CheckPermissionsRequest.checks:type_name -> authorizer.v1.PermissionCheckInput + 44, // 8: authorizer.v1.CheckPermissionsResponse.results:type_name -> authorizer.v1.PermissionCheckResult + 45, // 9: authorizer.v1.ListPermissionsResponse.permissions:type_name -> authorizer.v1.Permission 0, // 10: authorizer.v1.AuthorizerService.Signup:input_type -> authorizer.v1.SignupRequest 1, // 11: authorizer.v1.AuthorizerService.Login:input_type -> authorizer.v1.LoginRequest 2, // 12: authorizer.v1.AuthorizerService.Logout:input_type -> authorizer.v1.LogoutRequest @@ -2598,40 +3038,48 @@ var file_authorizer_v1_authorizer_proto_depIdxs = []int32{ 7, // 15: authorizer.v1.AuthorizerService.ResendVerifyEmail:input_type -> authorizer.v1.ResendVerifyEmailRequest 9, // 16: authorizer.v1.AuthorizerService.VerifyOtp:input_type -> authorizer.v1.VerifyOtpRequest 10, // 17: authorizer.v1.AuthorizerService.ResendOtp:input_type -> authorizer.v1.ResendOtpRequest - 12, // 18: authorizer.v1.AuthorizerService.ForgotPassword:input_type -> authorizer.v1.ForgotPasswordRequest - 14, // 19: authorizer.v1.AuthorizerService.ResetPassword:input_type -> authorizer.v1.ResetPasswordRequest - 16, // 20: authorizer.v1.AuthorizerService.Profile:input_type -> authorizer.v1.ProfileRequest - 17, // 21: authorizer.v1.AuthorizerService.UpdateProfile:input_type -> authorizer.v1.UpdateProfileRequest - 19, // 22: authorizer.v1.AuthorizerService.DeactivateAccount:input_type -> authorizer.v1.DeactivateAccountRequest - 21, // 23: authorizer.v1.AuthorizerService.Revoke:input_type -> authorizer.v1.RevokeRequest - 23, // 24: authorizer.v1.AuthorizerService.Session:input_type -> authorizer.v1.SessionRequest - 24, // 25: authorizer.v1.AuthorizerService.ValidateJwtToken:input_type -> authorizer.v1.ValidateJwtTokenRequest - 26, // 26: authorizer.v1.AuthorizerService.ValidateSession:input_type -> authorizer.v1.ValidateSessionRequest - 28, // 27: authorizer.v1.AuthorizerService.Meta:input_type -> authorizer.v1.MetaRequest - 29, // 28: authorizer.v1.AuthorizerService.CheckPermissions:input_type -> authorizer.v1.CheckPermissionsRequest - 31, // 29: authorizer.v1.AuthorizerService.ListPermissions:input_type -> authorizer.v1.ListPermissionsRequest - 39, // 30: authorizer.v1.AuthorizerService.Signup:output_type -> authorizer.v1.AuthResponse - 39, // 31: authorizer.v1.AuthorizerService.Login:output_type -> authorizer.v1.AuthResponse - 3, // 32: authorizer.v1.AuthorizerService.Logout:output_type -> authorizer.v1.LogoutResponse - 5, // 33: authorizer.v1.AuthorizerService.MagicLinkLogin:output_type -> authorizer.v1.MagicLinkLoginResponse - 39, // 34: authorizer.v1.AuthorizerService.VerifyEmail:output_type -> authorizer.v1.AuthResponse - 8, // 35: authorizer.v1.AuthorizerService.ResendVerifyEmail:output_type -> authorizer.v1.ResendVerifyEmailResponse - 39, // 36: authorizer.v1.AuthorizerService.VerifyOtp:output_type -> authorizer.v1.AuthResponse - 11, // 37: authorizer.v1.AuthorizerService.ResendOtp:output_type -> authorizer.v1.ResendOtpResponse - 13, // 38: authorizer.v1.AuthorizerService.ForgotPassword:output_type -> authorizer.v1.ForgotPasswordResponse - 15, // 39: authorizer.v1.AuthorizerService.ResetPassword:output_type -> authorizer.v1.ResetPasswordResponse - 35, // 40: authorizer.v1.AuthorizerService.Profile:output_type -> authorizer.v1.User - 18, // 41: authorizer.v1.AuthorizerService.UpdateProfile:output_type -> authorizer.v1.UpdateProfileResponse - 20, // 42: authorizer.v1.AuthorizerService.DeactivateAccount:output_type -> authorizer.v1.DeactivateAccountResponse - 22, // 43: authorizer.v1.AuthorizerService.Revoke:output_type -> authorizer.v1.RevokeResponse - 39, // 44: authorizer.v1.AuthorizerService.Session:output_type -> authorizer.v1.AuthResponse - 25, // 45: authorizer.v1.AuthorizerService.ValidateJwtToken:output_type -> authorizer.v1.ValidateJwtTokenResponse - 27, // 46: authorizer.v1.AuthorizerService.ValidateSession:output_type -> authorizer.v1.ValidateSessionResponse - 40, // 47: authorizer.v1.AuthorizerService.Meta:output_type -> authorizer.v1.Meta - 30, // 48: authorizer.v1.AuthorizerService.CheckPermissions:output_type -> authorizer.v1.CheckPermissionsResponse - 32, // 49: authorizer.v1.AuthorizerService.ListPermissions:output_type -> authorizer.v1.ListPermissionsResponse - 30, // [30:50] is the sub-list for method output_type - 10, // [10:30] is the sub-list for method input_type + 12, // 18: authorizer.v1.AuthorizerService.SkipMfaSetup:input_type -> authorizer.v1.SkipMfaSetupRequest + 13, // 19: authorizer.v1.AuthorizerService.LockMfa:input_type -> authorizer.v1.LockMfaRequest + 15, // 20: authorizer.v1.AuthorizerService.EmailOtpMfaSetup:input_type -> authorizer.v1.EmailOtpMfaSetupRequest + 17, // 21: authorizer.v1.AuthorizerService.SmsOtpMfaSetup:input_type -> authorizer.v1.SmsOtpMfaSetupRequest + 19, // 22: authorizer.v1.AuthorizerService.ForgotPassword:input_type -> authorizer.v1.ForgotPasswordRequest + 21, // 23: authorizer.v1.AuthorizerService.ResetPassword:input_type -> authorizer.v1.ResetPasswordRequest + 23, // 24: authorizer.v1.AuthorizerService.Profile:input_type -> authorizer.v1.ProfileRequest + 24, // 25: authorizer.v1.AuthorizerService.UpdateProfile:input_type -> authorizer.v1.UpdateProfileRequest + 26, // 26: authorizer.v1.AuthorizerService.DeactivateAccount:input_type -> authorizer.v1.DeactivateAccountRequest + 28, // 27: authorizer.v1.AuthorizerService.Revoke:input_type -> authorizer.v1.RevokeRequest + 30, // 28: authorizer.v1.AuthorizerService.Session:input_type -> authorizer.v1.SessionRequest + 31, // 29: authorizer.v1.AuthorizerService.ValidateJwtToken:input_type -> authorizer.v1.ValidateJwtTokenRequest + 33, // 30: authorizer.v1.AuthorizerService.ValidateSession:input_type -> authorizer.v1.ValidateSessionRequest + 35, // 31: authorizer.v1.AuthorizerService.Meta:input_type -> authorizer.v1.MetaRequest + 36, // 32: authorizer.v1.AuthorizerService.CheckPermissions:input_type -> authorizer.v1.CheckPermissionsRequest + 38, // 33: authorizer.v1.AuthorizerService.ListPermissions:input_type -> authorizer.v1.ListPermissionsRequest + 46, // 34: authorizer.v1.AuthorizerService.Signup:output_type -> authorizer.v1.AuthResponse + 46, // 35: authorizer.v1.AuthorizerService.Login:output_type -> authorizer.v1.AuthResponse + 3, // 36: authorizer.v1.AuthorizerService.Logout:output_type -> authorizer.v1.LogoutResponse + 5, // 37: authorizer.v1.AuthorizerService.MagicLinkLogin:output_type -> authorizer.v1.MagicLinkLoginResponse + 46, // 38: authorizer.v1.AuthorizerService.VerifyEmail:output_type -> authorizer.v1.AuthResponse + 8, // 39: authorizer.v1.AuthorizerService.ResendVerifyEmail:output_type -> authorizer.v1.ResendVerifyEmailResponse + 46, // 40: authorizer.v1.AuthorizerService.VerifyOtp:output_type -> authorizer.v1.AuthResponse + 11, // 41: authorizer.v1.AuthorizerService.ResendOtp:output_type -> authorizer.v1.ResendOtpResponse + 46, // 42: authorizer.v1.AuthorizerService.SkipMfaSetup:output_type -> authorizer.v1.AuthResponse + 14, // 43: authorizer.v1.AuthorizerService.LockMfa:output_type -> authorizer.v1.LockMfaResponse + 16, // 44: authorizer.v1.AuthorizerService.EmailOtpMfaSetup:output_type -> authorizer.v1.EmailOtpMfaSetupResponse + 18, // 45: authorizer.v1.AuthorizerService.SmsOtpMfaSetup:output_type -> authorizer.v1.SmsOtpMfaSetupResponse + 20, // 46: authorizer.v1.AuthorizerService.ForgotPassword:output_type -> authorizer.v1.ForgotPasswordResponse + 22, // 47: authorizer.v1.AuthorizerService.ResetPassword:output_type -> authorizer.v1.ResetPasswordResponse + 42, // 48: authorizer.v1.AuthorizerService.Profile:output_type -> authorizer.v1.User + 25, // 49: authorizer.v1.AuthorizerService.UpdateProfile:output_type -> authorizer.v1.UpdateProfileResponse + 27, // 50: authorizer.v1.AuthorizerService.DeactivateAccount:output_type -> authorizer.v1.DeactivateAccountResponse + 29, // 51: authorizer.v1.AuthorizerService.Revoke:output_type -> authorizer.v1.RevokeResponse + 46, // 52: authorizer.v1.AuthorizerService.Session:output_type -> authorizer.v1.AuthResponse + 32, // 53: authorizer.v1.AuthorizerService.ValidateJwtToken:output_type -> authorizer.v1.ValidateJwtTokenResponse + 34, // 54: authorizer.v1.AuthorizerService.ValidateSession:output_type -> authorizer.v1.ValidateSessionResponse + 47, // 55: authorizer.v1.AuthorizerService.Meta:output_type -> authorizer.v1.Meta + 37, // 56: authorizer.v1.AuthorizerService.CheckPermissions:output_type -> authorizer.v1.CheckPermissionsResponse + 39, // 57: authorizer.v1.AuthorizerService.ListPermissions:output_type -> authorizer.v1.ListPermissionsResponse + 34, // [34:58] is the sub-list for method output_type + 10, // [10:34] is the sub-list for method input_type 10, // [10:10] is the sub-list for extension type_name 10, // [10:10] is the sub-list for extension extendee 0, // [0:10] is the sub-list for field type_name @@ -2645,14 +3093,14 @@ func file_authorizer_v1_authorizer_proto_init() { file_authorizer_v1_annotations_proto_init() file_authorizer_v1_common_proto_init() file_authorizer_v1_types_proto_init() - file_authorizer_v1_authorizer_proto_msgTypes[17].OneofWrappers = []any{} + file_authorizer_v1_authorizer_proto_msgTypes[24].OneofWrappers = []any{} type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: file_authorizer_v1_authorizer_proto_rawDesc, NumEnums: 0, - NumMessages: 33, + NumMessages: 40, NumExtensions: 0, NumServices: 1, }, diff --git a/gen/go/authorizer/v1/authorizer.pb.gw.go b/gen/go/authorizer/v1/authorizer.pb.gw.go index 17438927..c59817ed 100644 --- a/gen/go/authorizer/v1/authorizer.pb.gw.go +++ b/gen/go/authorizer/v1/authorizer.pb.gw.go @@ -231,6 +231,110 @@ func local_request_AuthorizerService_ResendOtp_0(ctx context.Context, marshaler } +func request_AuthorizerService_SkipMfaSetup_0(ctx context.Context, marshaler runtime.Marshaler, client AuthorizerServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var protoReq SkipMfaSetupRequest + var metadata runtime.ServerMetadata + + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && err != io.EOF { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + + msg, err := client.SkipMfaSetup(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err + +} + +func local_request_AuthorizerService_SkipMfaSetup_0(ctx context.Context, marshaler runtime.Marshaler, server AuthorizerServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var protoReq SkipMfaSetupRequest + var metadata runtime.ServerMetadata + + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && err != io.EOF { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + + msg, err := server.SkipMfaSetup(ctx, &protoReq) + return msg, metadata, err + +} + +func request_AuthorizerService_LockMfa_0(ctx context.Context, marshaler runtime.Marshaler, client AuthorizerServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var protoReq LockMfaRequest + var metadata runtime.ServerMetadata + + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && err != io.EOF { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + + msg, err := client.LockMfa(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err + +} + +func local_request_AuthorizerService_LockMfa_0(ctx context.Context, marshaler runtime.Marshaler, server AuthorizerServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var protoReq LockMfaRequest + var metadata runtime.ServerMetadata + + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && err != io.EOF { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + + msg, err := server.LockMfa(ctx, &protoReq) + return msg, metadata, err + +} + +func request_AuthorizerService_EmailOtpMfaSetup_0(ctx context.Context, marshaler runtime.Marshaler, client AuthorizerServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var protoReq EmailOtpMfaSetupRequest + var metadata runtime.ServerMetadata + + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && err != io.EOF { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + + msg, err := client.EmailOtpMfaSetup(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err + +} + +func local_request_AuthorizerService_EmailOtpMfaSetup_0(ctx context.Context, marshaler runtime.Marshaler, server AuthorizerServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var protoReq EmailOtpMfaSetupRequest + var metadata runtime.ServerMetadata + + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && err != io.EOF { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + + msg, err := server.EmailOtpMfaSetup(ctx, &protoReq) + return msg, metadata, err + +} + +func request_AuthorizerService_SmsOtpMfaSetup_0(ctx context.Context, marshaler runtime.Marshaler, client AuthorizerServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var protoReq SmsOtpMfaSetupRequest + var metadata runtime.ServerMetadata + + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && err != io.EOF { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + + msg, err := client.SmsOtpMfaSetup(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err + +} + +func local_request_AuthorizerService_SmsOtpMfaSetup_0(ctx context.Context, marshaler runtime.Marshaler, server AuthorizerServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var protoReq SmsOtpMfaSetupRequest + var metadata runtime.ServerMetadata + + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && err != io.EOF { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + + msg, err := server.SmsOtpMfaSetup(ctx, &protoReq) + return msg, metadata, err + +} + func request_AuthorizerService_ForgotPassword_0(ctx context.Context, marshaler runtime.Marshaler, client AuthorizerServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { var protoReq ForgotPasswordRequest var metadata runtime.ServerMetadata @@ -734,6 +838,106 @@ func RegisterAuthorizerServiceHandlerServer(ctx context.Context, mux *runtime.Se }) + mux.Handle("POST", pattern_AuthorizerService_SkipMfaSetup_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + var err error + var annotatedContext context.Context + annotatedContext, err = runtime.AnnotateIncomingContext(ctx, mux, req, "/authorizer.v1.AuthorizerService/SkipMfaSetup", runtime.WithHTTPPathPattern("/v1/skip_mfa_setup")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_AuthorizerService_SkipMfaSetup_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + + forward_AuthorizerService_SkipMfaSetup_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + + }) + + mux.Handle("POST", pattern_AuthorizerService_LockMfa_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + var err error + var annotatedContext context.Context + annotatedContext, err = runtime.AnnotateIncomingContext(ctx, mux, req, "/authorizer.v1.AuthorizerService/LockMfa", runtime.WithHTTPPathPattern("/v1/lock_mfa")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_AuthorizerService_LockMfa_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + + forward_AuthorizerService_LockMfa_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + + }) + + mux.Handle("POST", pattern_AuthorizerService_EmailOtpMfaSetup_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + var err error + var annotatedContext context.Context + annotatedContext, err = runtime.AnnotateIncomingContext(ctx, mux, req, "/authorizer.v1.AuthorizerService/EmailOtpMfaSetup", runtime.WithHTTPPathPattern("/v1/email_otp_mfa_setup")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_AuthorizerService_EmailOtpMfaSetup_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + + forward_AuthorizerService_EmailOtpMfaSetup_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + + }) + + mux.Handle("POST", pattern_AuthorizerService_SmsOtpMfaSetup_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + var err error + var annotatedContext context.Context + annotatedContext, err = runtime.AnnotateIncomingContext(ctx, mux, req, "/authorizer.v1.AuthorizerService/SmsOtpMfaSetup", runtime.WithHTTPPathPattern("/v1/sms_otp_mfa_setup")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_AuthorizerService_SmsOtpMfaSetup_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + + forward_AuthorizerService_SmsOtpMfaSetup_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + + }) + mux.Handle("POST", pattern_AuthorizerService_ForgotPassword_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { ctx, cancel := context.WithCancel(req.Context()) defer cancel() @@ -1251,6 +1455,94 @@ func RegisterAuthorizerServiceHandlerClient(ctx context.Context, mux *runtime.Se }) + mux.Handle("POST", pattern_AuthorizerService_SkipMfaSetup_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + var err error + var annotatedContext context.Context + annotatedContext, err = runtime.AnnotateContext(ctx, mux, req, "/authorizer.v1.AuthorizerService/SkipMfaSetup", runtime.WithHTTPPathPattern("/v1/skip_mfa_setup")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_AuthorizerService_SkipMfaSetup_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + + forward_AuthorizerService_SkipMfaSetup_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + + }) + + mux.Handle("POST", pattern_AuthorizerService_LockMfa_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + var err error + var annotatedContext context.Context + annotatedContext, err = runtime.AnnotateContext(ctx, mux, req, "/authorizer.v1.AuthorizerService/LockMfa", runtime.WithHTTPPathPattern("/v1/lock_mfa")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_AuthorizerService_LockMfa_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + + forward_AuthorizerService_LockMfa_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + + }) + + mux.Handle("POST", pattern_AuthorizerService_EmailOtpMfaSetup_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + var err error + var annotatedContext context.Context + annotatedContext, err = runtime.AnnotateContext(ctx, mux, req, "/authorizer.v1.AuthorizerService/EmailOtpMfaSetup", runtime.WithHTTPPathPattern("/v1/email_otp_mfa_setup")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_AuthorizerService_EmailOtpMfaSetup_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + + forward_AuthorizerService_EmailOtpMfaSetup_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + + }) + + mux.Handle("POST", pattern_AuthorizerService_SmsOtpMfaSetup_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + var err error + var annotatedContext context.Context + annotatedContext, err = runtime.AnnotateContext(ctx, mux, req, "/authorizer.v1.AuthorizerService/SmsOtpMfaSetup", runtime.WithHTTPPathPattern("/v1/sms_otp_mfa_setup")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_AuthorizerService_SmsOtpMfaSetup_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + + forward_AuthorizerService_SmsOtpMfaSetup_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + + }) + mux.Handle("POST", pattern_AuthorizerService_ForgotPassword_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { ctx, cancel := context.WithCancel(req.Context()) defer cancel() @@ -1535,6 +1827,14 @@ var ( pattern_AuthorizerService_ResendOtp_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"v1", "resend_otp"}, "")) + pattern_AuthorizerService_SkipMfaSetup_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"v1", "skip_mfa_setup"}, "")) + + pattern_AuthorizerService_LockMfa_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"v1", "lock_mfa"}, "")) + + pattern_AuthorizerService_EmailOtpMfaSetup_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"v1", "email_otp_mfa_setup"}, "")) + + pattern_AuthorizerService_SmsOtpMfaSetup_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"v1", "sms_otp_mfa_setup"}, "")) + pattern_AuthorizerService_ForgotPassword_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"v1", "forgot_password"}, "")) pattern_AuthorizerService_ResetPassword_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"v1", "reset_password"}, "")) @@ -1577,6 +1877,14 @@ var ( forward_AuthorizerService_ResendOtp_0 = runtime.ForwardResponseMessage + forward_AuthorizerService_SkipMfaSetup_0 = runtime.ForwardResponseMessage + + forward_AuthorizerService_LockMfa_0 = runtime.ForwardResponseMessage + + forward_AuthorizerService_EmailOtpMfaSetup_0 = runtime.ForwardResponseMessage + + forward_AuthorizerService_SmsOtpMfaSetup_0 = runtime.ForwardResponseMessage + forward_AuthorizerService_ForgotPassword_0 = runtime.ForwardResponseMessage forward_AuthorizerService_ResetPassword_0 = runtime.ForwardResponseMessage diff --git a/gen/go/authorizer/v1/authorizer_grpc.pb.go b/gen/go/authorizer/v1/authorizer_grpc.pb.go index 6ae658f3..079fab00 100644 --- a/gen/go/authorizer/v1/authorizer_grpc.pb.go +++ b/gen/go/authorizer/v1/authorizer_grpc.pb.go @@ -2,7 +2,8 @@ // public API. Method names match the GraphQL operation names 1:1 // (snake_case in GraphQL → PascalCase in proto): Signup, Login, // MagicLinkLogin, VerifyEmail, ResendVerifyEmail, ForgotPassword, -// ResetPassword, VerifyOtp, ResendOtp, UpdateProfile, DeactivateAccount, +// ResetPassword, VerifyOtp, ResendOtp, SkipMfaSetup, LockMfa, +// EmailOtpMfaSetup, SmsOtpMfaSetup, UpdateProfile, DeactivateAccount, // Revoke, Meta, Session, Profile, ValidateJwtToken, ValidateSession, // CheckPermissions, ListPermissions, Logout. // @@ -46,6 +47,10 @@ const ( AuthorizerService_ResendVerifyEmail_FullMethodName = "/authorizer.v1.AuthorizerService/ResendVerifyEmail" AuthorizerService_VerifyOtp_FullMethodName = "/authorizer.v1.AuthorizerService/VerifyOtp" AuthorizerService_ResendOtp_FullMethodName = "/authorizer.v1.AuthorizerService/ResendOtp" + AuthorizerService_SkipMfaSetup_FullMethodName = "/authorizer.v1.AuthorizerService/SkipMfaSetup" + AuthorizerService_LockMfa_FullMethodName = "/authorizer.v1.AuthorizerService/LockMfa" + AuthorizerService_EmailOtpMfaSetup_FullMethodName = "/authorizer.v1.AuthorizerService/EmailOtpMfaSetup" + AuthorizerService_SmsOtpMfaSetup_FullMethodName = "/authorizer.v1.AuthorizerService/SmsOtpMfaSetup" AuthorizerService_ForgotPassword_FullMethodName = "/authorizer.v1.AuthorizerService/ForgotPassword" AuthorizerService_ResetPassword_FullMethodName = "/authorizer.v1.AuthorizerService/ResetPassword" AuthorizerService_Profile_FullMethodName = "/authorizer.v1.AuthorizerService/Profile" @@ -80,6 +85,33 @@ type AuthorizerServiceClient interface { ResendVerifyEmail(ctx context.Context, in *ResendVerifyEmailRequest, opts ...grpc.CallOption) (*ResendVerifyEmailResponse, error) VerifyOtp(ctx context.Context, in *VerifyOtpRequest, opts ...grpc.CallOption) (*AuthResponse, error) ResendOtp(ctx context.Context, in *ResendOtpRequest, opts ...grpc.CallOption) (*ResendOtpResponse, error) + // SkipMfaSetup completes an in-progress, token-withheld MFA offer by + // recording that the caller explicitly declined it, then issues the + // access token that was withheld. Identified by the MFA session cookie + // (set when the offer screen was returned) plus email/phone_number to + // resolve the pending user — same identification pattern as VerifyOtp. + // Fails with FAILED_PRECONDITION if MFA is organization-enforced + // (enforce-mfa) — enforcement is never skippable. + SkipMfaSetup(ctx context.Context, in *SkipMfaSetupRequest, opts ...grpc.CallOption) (*AuthResponse, error) + // LockMfa records that the caller lost access to their only MFA + // factor(s). Only allowed when the caller has NO verified Email/SMS OTP + // fallback enrolled — if one exists, use it instead of locking. Does not + // issue a token; the account requires admin recovery afterward. + LockMfa(ctx context.Context, in *LockMfaRequest, opts ...grpc.CallOption) (*LockMfaResponse, error) + // EmailOtpMfaSetup sends a one-time code to the caller's own email and + // creates an unverified email-OTP MFA enrollment. Dual-mode permissions: + // (a) an authenticated caller (bearer token) — the settings-screen action + // for an ALREADY-logged-in user adding a second factor; the request body is + // unused in this mode. (b) a caller in the withheld first-time-offer + // state, with no bearer token yet — identified by the MFA session cookie + // plus email/phone_number, same pattern as SkipMfaSetup. Either mode + // reuses the same underlying Authenticator row once VerifyOtp marks it + // verified. + EmailOtpMfaSetup(ctx context.Context, in *EmailOtpMfaSetupRequest, opts ...grpc.CallOption) (*EmailOtpMfaSetupResponse, error) + // SmsOtpMfaSetup sends a one-time code to the caller's own phone number + // and creates an unverified SMS-OTP MFA enrollment. Same dual-mode + // permissions and relationship to VerifyOtp as EmailOtpMfaSetup. + SmsOtpMfaSetup(ctx context.Context, in *SmsOtpMfaSetupRequest, opts ...grpc.CallOption) (*SmsOtpMfaSetupResponse, error) ForgotPassword(ctx context.Context, in *ForgotPasswordRequest, opts ...grpc.CallOption) (*ForgotPasswordResponse, error) ResetPassword(ctx context.Context, in *ResetPasswordRequest, opts ...grpc.CallOption) (*ResetPasswordResponse, error) // Profile returns the authenticated user. @@ -203,6 +235,46 @@ func (c *authorizerServiceClient) ResendOtp(ctx context.Context, in *ResendOtpRe return out, nil } +func (c *authorizerServiceClient) SkipMfaSetup(ctx context.Context, in *SkipMfaSetupRequest, opts ...grpc.CallOption) (*AuthResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(AuthResponse) + err := c.cc.Invoke(ctx, AuthorizerService_SkipMfaSetup_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *authorizerServiceClient) LockMfa(ctx context.Context, in *LockMfaRequest, opts ...grpc.CallOption) (*LockMfaResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(LockMfaResponse) + err := c.cc.Invoke(ctx, AuthorizerService_LockMfa_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *authorizerServiceClient) EmailOtpMfaSetup(ctx context.Context, in *EmailOtpMfaSetupRequest, opts ...grpc.CallOption) (*EmailOtpMfaSetupResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(EmailOtpMfaSetupResponse) + err := c.cc.Invoke(ctx, AuthorizerService_EmailOtpMfaSetup_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *authorizerServiceClient) SmsOtpMfaSetup(ctx context.Context, in *SmsOtpMfaSetupRequest, opts ...grpc.CallOption) (*SmsOtpMfaSetupResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(SmsOtpMfaSetupResponse) + err := c.cc.Invoke(ctx, AuthorizerService_SmsOtpMfaSetup_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + func (c *authorizerServiceClient) ForgotPassword(ctx context.Context, in *ForgotPasswordRequest, opts ...grpc.CallOption) (*ForgotPasswordResponse, error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(ForgotPasswordResponse) @@ -343,6 +415,33 @@ type AuthorizerServiceServer interface { ResendVerifyEmail(context.Context, *ResendVerifyEmailRequest) (*ResendVerifyEmailResponse, error) VerifyOtp(context.Context, *VerifyOtpRequest) (*AuthResponse, error) ResendOtp(context.Context, *ResendOtpRequest) (*ResendOtpResponse, error) + // SkipMfaSetup completes an in-progress, token-withheld MFA offer by + // recording that the caller explicitly declined it, then issues the + // access token that was withheld. Identified by the MFA session cookie + // (set when the offer screen was returned) plus email/phone_number to + // resolve the pending user — same identification pattern as VerifyOtp. + // Fails with FAILED_PRECONDITION if MFA is organization-enforced + // (enforce-mfa) — enforcement is never skippable. + SkipMfaSetup(context.Context, *SkipMfaSetupRequest) (*AuthResponse, error) + // LockMfa records that the caller lost access to their only MFA + // factor(s). Only allowed when the caller has NO verified Email/SMS OTP + // fallback enrolled — if one exists, use it instead of locking. Does not + // issue a token; the account requires admin recovery afterward. + LockMfa(context.Context, *LockMfaRequest) (*LockMfaResponse, error) + // EmailOtpMfaSetup sends a one-time code to the caller's own email and + // creates an unverified email-OTP MFA enrollment. Dual-mode permissions: + // (a) an authenticated caller (bearer token) — the settings-screen action + // for an ALREADY-logged-in user adding a second factor; the request body is + // unused in this mode. (b) a caller in the withheld first-time-offer + // state, with no bearer token yet — identified by the MFA session cookie + // plus email/phone_number, same pattern as SkipMfaSetup. Either mode + // reuses the same underlying Authenticator row once VerifyOtp marks it + // verified. + EmailOtpMfaSetup(context.Context, *EmailOtpMfaSetupRequest) (*EmailOtpMfaSetupResponse, error) + // SmsOtpMfaSetup sends a one-time code to the caller's own phone number + // and creates an unverified SMS-OTP MFA enrollment. Same dual-mode + // permissions and relationship to VerifyOtp as EmailOtpMfaSetup. + SmsOtpMfaSetup(context.Context, *SmsOtpMfaSetupRequest) (*SmsOtpMfaSetupResponse, error) ForgotPassword(context.Context, *ForgotPasswordRequest) (*ForgotPasswordResponse, error) ResetPassword(context.Context, *ResetPasswordRequest) (*ResetPasswordResponse, error) // Profile returns the authenticated user. @@ -409,6 +508,18 @@ func (UnimplementedAuthorizerServiceServer) VerifyOtp(context.Context, *VerifyOt func (UnimplementedAuthorizerServiceServer) ResendOtp(context.Context, *ResendOtpRequest) (*ResendOtpResponse, error) { return nil, status.Errorf(codes.Unimplemented, "method ResendOtp not implemented") } +func (UnimplementedAuthorizerServiceServer) SkipMfaSetup(context.Context, *SkipMfaSetupRequest) (*AuthResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method SkipMfaSetup not implemented") +} +func (UnimplementedAuthorizerServiceServer) LockMfa(context.Context, *LockMfaRequest) (*LockMfaResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method LockMfa not implemented") +} +func (UnimplementedAuthorizerServiceServer) EmailOtpMfaSetup(context.Context, *EmailOtpMfaSetupRequest) (*EmailOtpMfaSetupResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method EmailOtpMfaSetup not implemented") +} +func (UnimplementedAuthorizerServiceServer) SmsOtpMfaSetup(context.Context, *SmsOtpMfaSetupRequest) (*SmsOtpMfaSetupResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method SmsOtpMfaSetup not implemented") +} func (UnimplementedAuthorizerServiceServer) ForgotPassword(context.Context, *ForgotPasswordRequest) (*ForgotPasswordResponse, error) { return nil, status.Errorf(codes.Unimplemented, "method ForgotPassword not implemented") } @@ -609,6 +720,78 @@ func _AuthorizerService_ResendOtp_Handler(srv interface{}, ctx context.Context, return interceptor(ctx, in, info, handler) } +func _AuthorizerService_SkipMfaSetup_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(SkipMfaSetupRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(AuthorizerServiceServer).SkipMfaSetup(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: AuthorizerService_SkipMfaSetup_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(AuthorizerServiceServer).SkipMfaSetup(ctx, req.(*SkipMfaSetupRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _AuthorizerService_LockMfa_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(LockMfaRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(AuthorizerServiceServer).LockMfa(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: AuthorizerService_LockMfa_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(AuthorizerServiceServer).LockMfa(ctx, req.(*LockMfaRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _AuthorizerService_EmailOtpMfaSetup_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(EmailOtpMfaSetupRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(AuthorizerServiceServer).EmailOtpMfaSetup(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: AuthorizerService_EmailOtpMfaSetup_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(AuthorizerServiceServer).EmailOtpMfaSetup(ctx, req.(*EmailOtpMfaSetupRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _AuthorizerService_SmsOtpMfaSetup_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(SmsOtpMfaSetupRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(AuthorizerServiceServer).SmsOtpMfaSetup(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: AuthorizerService_SmsOtpMfaSetup_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(AuthorizerServiceServer).SmsOtpMfaSetup(ctx, req.(*SmsOtpMfaSetupRequest)) + } + return interceptor(ctx, in, info, handler) +} + func _AuthorizerService_ForgotPassword_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { in := new(ForgotPasswordRequest) if err := dec(in); err != nil { @@ -864,6 +1047,22 @@ var AuthorizerService_ServiceDesc = grpc.ServiceDesc{ MethodName: "ResendOtp", Handler: _AuthorizerService_ResendOtp_Handler, }, + { + MethodName: "SkipMfaSetup", + Handler: _AuthorizerService_SkipMfaSetup_Handler, + }, + { + MethodName: "LockMfa", + Handler: _AuthorizerService_LockMfa_Handler, + }, + { + MethodName: "EmailOtpMfaSetup", + Handler: _AuthorizerService_EmailOtpMfaSetup_Handler, + }, + { + MethodName: "SmsOtpMfaSetup", + Handler: _AuthorizerService_SmsOtpMfaSetup_Handler, + }, { MethodName: "ForgotPassword", Handler: _AuthorizerService_ForgotPassword_Handler, diff --git a/gen/go/authorizer/v1/types.pb.go b/gen/go/authorizer/v1/types.pb.go index ded81f1b..36d4d32a 100644 --- a/gen/go/authorizer/v1/types.pb.go +++ b/gen/go/authorizer/v1/types.pb.go @@ -53,6 +53,10 @@ type User struct { IsMultiFactorAuthEnabled bool `protobuf:"varint,19,opt,name=is_multi_factor_auth_enabled,json=isMultiFactorAuthEnabled,proto3" json:"is_multi_factor_auth_enabled,omitempty"` // Free-form key/value bag — same as GraphQL `app_data: Map`. AppData *AppData `protobuf:"bytes,20,opt,name=app_data,json=appData,proto3" json:"app_data,omitempty"` + // Mirrors User.has_skipped_mfa_setup_at in GraphQL. 0 means never skipped. + HasSkippedMfaSetupAt int64 `protobuf:"varint,21,opt,name=has_skipped_mfa_setup_at,json=hasSkippedMfaSetupAt,proto3" json:"has_skipped_mfa_setup_at,omitempty"` + // Mirrors User.mfa_locked_at in GraphQL. 0 means not locked. + MfaLockedAt int64 `protobuf:"varint,22,opt,name=mfa_locked_at,json=mfaLockedAt,proto3" json:"mfa_locked_at,omitempty"` } func (x *User) Reset() { @@ -225,6 +229,20 @@ func (x *User) GetAppData() *AppData { return nil } +func (x *User) GetHasSkippedMfaSetupAt() int64 { + if x != nil { + return x.HasSkippedMfaSetupAt + } + return 0 +} + +func (x *User) GetMfaLockedAt() int64 { + if x != nil { + return x.MfaLockedAt + } + return 0 +} + // AuthResponse mirrors the GraphQL AuthResponse type. Returned (wrapped) by // every method that produces a session: Signup, Login, MagicLinkLogin, // VerifyEmail, VerifyOtp, Session. @@ -247,6 +265,14 @@ type AuthResponse struct { AuthenticatorScannerImage string `protobuf:"bytes,10,opt,name=authenticator_scanner_image,json=authenticatorScannerImage,proto3" json:"authenticator_scanner_image,omitempty"` AuthenticatorSecret string `protobuf:"bytes,11,opt,name=authenticator_secret,json=authenticatorSecret,proto3" json:"authenticator_secret,omitempty"` AuthenticatorRecoveryCodes []string `protobuf:"bytes,12,rep,name=authenticator_recovery_codes,json=authenticatorRecoveryCodes,proto3" json:"authenticator_recovery_codes,omitempty"` + // Mirrors AuthResponse.should_offer_webauthn_mfa_verify in GraphQL. + ShouldOfferWebauthnMfaVerify bool `protobuf:"varint,13,opt,name=should_offer_webauthn_mfa_verify,json=shouldOfferWebauthnMfaVerify,proto3" json:"should_offer_webauthn_mfa_verify,omitempty"` + // Mirrors AuthResponse.should_offer_webauthn_mfa_setup in GraphQL. + ShouldOfferWebauthnMfaSetup bool `protobuf:"varint,14,opt,name=should_offer_webauthn_mfa_setup,json=shouldOfferWebauthnMfaSetup,proto3" json:"should_offer_webauthn_mfa_setup,omitempty"` + // Mirrors AuthResponse.should_offer_email_otp_mfa_setup in GraphQL. + ShouldOfferEmailOtpMfaSetup bool `protobuf:"varint,15,opt,name=should_offer_email_otp_mfa_setup,json=shouldOfferEmailOtpMfaSetup,proto3" json:"should_offer_email_otp_mfa_setup,omitempty"` + // Mirrors AuthResponse.should_offer_sms_otp_mfa_setup in GraphQL. + ShouldOfferSmsOtpMfaSetup bool `protobuf:"varint,16,opt,name=should_offer_sms_otp_mfa_setup,json=shouldOfferSmsOtpMfaSetup,proto3" json:"should_offer_sms_otp_mfa_setup,omitempty"` } func (x *AuthResponse) Reset() { @@ -363,6 +389,34 @@ func (x *AuthResponse) GetAuthenticatorRecoveryCodes() []string { return nil } +func (x *AuthResponse) GetShouldOfferWebauthnMfaVerify() bool { + if x != nil { + return x.ShouldOfferWebauthnMfaVerify + } + return false +} + +func (x *AuthResponse) GetShouldOfferWebauthnMfaSetup() bool { + if x != nil { + return x.ShouldOfferWebauthnMfaSetup + } + return false +} + +func (x *AuthResponse) GetShouldOfferEmailOtpMfaSetup() bool { + if x != nil { + return x.ShouldOfferEmailOtpMfaSetup + } + return false +} + +func (x *AuthResponse) GetShouldOfferSmsOtpMfaSetup() bool { + if x != nil { + return x.ShouldOfferSmsOtpMfaSetup + } + return false +} + // Permission is one (object, relation) pair the subject holds: "subject has // `relation` on `object`". Mirrors the GraphQL Permission type. type Permission struct { @@ -697,6 +751,8 @@ type Meta struct { IsEmailOtpMfaEnabled bool `protobuf:"varint,23,opt,name=is_email_otp_mfa_enabled,json=isEmailOtpMfaEnabled,proto3" json:"is_email_otp_mfa_enabled,omitempty"` IsSmsOtpMfaEnabled bool `protobuf:"varint,24,opt,name=is_sms_otp_mfa_enabled,json=isSmsOtpMfaEnabled,proto3" json:"is_sms_otp_mfa_enabled,omitempty"` IsWebauthnEnabled bool `protobuf:"varint,25,opt,name=is_webauthn_enabled,json=isWebauthnEnabled,proto3" json:"is_webauthn_enabled,omitempty"` + // Mirrors Meta.is_mfa_enforced in GraphQL. + IsMfaEnforced bool `protobuf:"varint,26,opt,name=is_mfa_enforced,json=isMfaEnforced,proto3" json:"is_mfa_enforced,omitempty"` } func (x *Meta) Reset() { @@ -904,6 +960,13 @@ func (x *Meta) GetIsWebauthnEnabled() bool { return false } +func (x *Meta) GetIsMfaEnforced() bool { + if x != nil { + return x.IsMfaEnforced + } + return false +} + var File_authorizer_v1_types_proto protoreflect.FileDescriptor var file_authorizer_v1_types_proto_rawDesc = []byte{ @@ -911,7 +974,7 @@ var file_authorizer_v1_types_proto_rawDesc = []byte{ 0x74, 0x79, 0x70, 0x65, 0x73, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x0d, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x1a, 0x1a, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2f, 0x76, 0x31, 0x2f, 0x63, 0x6f, 0x6d, 0x6d, 0x6f, 0x6e, - 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0xc1, 0x05, 0x0a, 0x04, 0x55, 0x73, 0x65, 0x72, 0x12, + 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0x9d, 0x06, 0x0a, 0x04, 0x55, 0x73, 0x65, 0x72, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x14, 0x0a, 0x05, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x12, 0x25, 0x0a, 0x0e, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x5f, 0x76, @@ -955,174 +1018,200 @@ var file_authorizer_v1_types_proto_rawDesc = []byte{ 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x31, 0x0a, 0x08, 0x61, 0x70, 0x70, 0x5f, 0x64, 0x61, 0x74, 0x61, 0x18, 0x14, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x41, 0x70, 0x70, 0x44, 0x61, 0x74, - 0x61, 0x52, 0x07, 0x61, 0x70, 0x70, 0x44, 0x61, 0x74, 0x61, 0x22, 0xc1, 0x04, 0x0a, 0x0c, 0x41, - 0x75, 0x74, 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x6d, - 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x6d, 0x65, - 0x73, 0x73, 0x61, 0x67, 0x65, 0x12, 0x3e, 0x0a, 0x1c, 0x73, 0x68, 0x6f, 0x75, 0x6c, 0x64, 0x5f, - 0x73, 0x68, 0x6f, 0x77, 0x5f, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x5f, 0x6f, 0x74, 0x70, 0x5f, 0x73, - 0x63, 0x72, 0x65, 0x65, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x18, 0x73, 0x68, 0x6f, - 0x75, 0x6c, 0x64, 0x53, 0x68, 0x6f, 0x77, 0x45, 0x6d, 0x61, 0x69, 0x6c, 0x4f, 0x74, 0x70, 0x53, - 0x63, 0x72, 0x65, 0x65, 0x6e, 0x12, 0x40, 0x0a, 0x1d, 0x73, 0x68, 0x6f, 0x75, 0x6c, 0x64, 0x5f, - 0x73, 0x68, 0x6f, 0x77, 0x5f, 0x6d, 0x6f, 0x62, 0x69, 0x6c, 0x65, 0x5f, 0x6f, 0x74, 0x70, 0x5f, - 0x73, 0x63, 0x72, 0x65, 0x65, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x19, 0x73, 0x68, - 0x6f, 0x75, 0x6c, 0x64, 0x53, 0x68, 0x6f, 0x77, 0x4d, 0x6f, 0x62, 0x69, 0x6c, 0x65, 0x4f, 0x74, - 0x70, 0x53, 0x63, 0x72, 0x65, 0x65, 0x6e, 0x12, 0x35, 0x0a, 0x17, 0x73, 0x68, 0x6f, 0x75, 0x6c, - 0x64, 0x5f, 0x73, 0x68, 0x6f, 0x77, 0x5f, 0x74, 0x6f, 0x74, 0x70, 0x5f, 0x73, 0x63, 0x72, 0x65, - 0x65, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x14, 0x73, 0x68, 0x6f, 0x75, 0x6c, 0x64, - 0x53, 0x68, 0x6f, 0x77, 0x54, 0x6f, 0x74, 0x70, 0x53, 0x63, 0x72, 0x65, 0x65, 0x6e, 0x12, 0x21, - 0x0a, 0x0c, 0x61, 0x63, 0x63, 0x65, 0x73, 0x73, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x05, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x61, 0x63, 0x63, 0x65, 0x73, 0x73, 0x54, 0x6f, 0x6b, 0x65, - 0x6e, 0x12, 0x19, 0x0a, 0x08, 0x69, 0x64, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x06, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x07, 0x69, 0x64, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x23, 0x0a, 0x0d, - 0x72, 0x65, 0x66, 0x72, 0x65, 0x73, 0x68, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x07, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x0c, 0x72, 0x65, 0x66, 0x72, 0x65, 0x73, 0x68, 0x54, 0x6f, 0x6b, 0x65, - 0x6e, 0x12, 0x1d, 0x0a, 0x0a, 0x65, 0x78, 0x70, 0x69, 0x72, 0x65, 0x73, 0x5f, 0x69, 0x6e, 0x18, - 0x08, 0x20, 0x01, 0x28, 0x03, 0x52, 0x09, 0x65, 0x78, 0x70, 0x69, 0x72, 0x65, 0x73, 0x49, 0x6e, - 0x12, 0x27, 0x0a, 0x04, 0x75, 0x73, 0x65, 0x72, 0x18, 0x09, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x13, - 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x55, - 0x73, 0x65, 0x72, 0x52, 0x04, 0x75, 0x73, 0x65, 0x72, 0x12, 0x3e, 0x0a, 0x1b, 0x61, 0x75, 0x74, - 0x68, 0x65, 0x6e, 0x74, 0x69, 0x63, 0x61, 0x74, 0x6f, 0x72, 0x5f, 0x73, 0x63, 0x61, 0x6e, 0x6e, - 0x65, 0x72, 0x5f, 0x69, 0x6d, 0x61, 0x67, 0x65, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x09, 0x52, 0x19, - 0x61, 0x75, 0x74, 0x68, 0x65, 0x6e, 0x74, 0x69, 0x63, 0x61, 0x74, 0x6f, 0x72, 0x53, 0x63, 0x61, - 0x6e, 0x6e, 0x65, 0x72, 0x49, 0x6d, 0x61, 0x67, 0x65, 0x12, 0x31, 0x0a, 0x14, 0x61, 0x75, 0x74, - 0x68, 0x65, 0x6e, 0x74, 0x69, 0x63, 0x61, 0x74, 0x6f, 0x72, 0x5f, 0x73, 0x65, 0x63, 0x72, 0x65, - 0x74, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x09, 0x52, 0x13, 0x61, 0x75, 0x74, 0x68, 0x65, 0x6e, 0x74, - 0x69, 0x63, 0x61, 0x74, 0x6f, 0x72, 0x53, 0x65, 0x63, 0x72, 0x65, 0x74, 0x12, 0x40, 0x0a, 0x1c, - 0x61, 0x75, 0x74, 0x68, 0x65, 0x6e, 0x74, 0x69, 0x63, 0x61, 0x74, 0x6f, 0x72, 0x5f, 0x72, 0x65, - 0x63, 0x6f, 0x76, 0x65, 0x72, 0x79, 0x5f, 0x63, 0x6f, 0x64, 0x65, 0x73, 0x18, 0x0c, 0x20, 0x03, - 0x28, 0x09, 0x52, 0x1a, 0x61, 0x75, 0x74, 0x68, 0x65, 0x6e, 0x74, 0x69, 0x63, 0x61, 0x74, 0x6f, - 0x72, 0x52, 0x65, 0x63, 0x6f, 0x76, 0x65, 0x72, 0x79, 0x43, 0x6f, 0x64, 0x65, 0x73, 0x22, 0x40, - 0x0a, 0x0a, 0x50, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x16, 0x0a, 0x06, - 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x6f, 0x62, - 0x6a, 0x65, 0x63, 0x74, 0x12, 0x1a, 0x0a, 0x08, 0x72, 0x65, 0x6c, 0x61, 0x74, 0x69, 0x6f, 0x6e, - 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x72, 0x65, 0x6c, 0x61, 0x74, 0x69, 0x6f, 0x6e, - 0x22, 0x46, 0x0a, 0x10, 0x46, 0x67, 0x61, 0x52, 0x65, 0x6c, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x49, - 0x6e, 0x70, 0x75, 0x74, 0x12, 0x1a, 0x0a, 0x08, 0x72, 0x65, 0x6c, 0x61, 0x74, 0x69, 0x6f, 0x6e, - 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x72, 0x65, 0x6c, 0x61, 0x74, 0x69, 0x6f, 0x6e, - 0x12, 0x16, 0x0a, 0x06, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x06, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x22, 0x57, 0x0a, 0x0d, 0x46, 0x67, 0x61, 0x54, - 0x75, 0x70, 0x6c, 0x65, 0x49, 0x6e, 0x70, 0x75, 0x74, 0x12, 0x12, 0x0a, 0x04, 0x75, 0x73, 0x65, - 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x75, 0x73, 0x65, 0x72, 0x12, 0x1a, 0x0a, - 0x08, 0x72, 0x65, 0x6c, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x08, 0x72, 0x65, 0x6c, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x16, 0x0a, 0x06, 0x6f, 0x62, 0x6a, - 0x65, 0x63, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x6f, 0x62, 0x6a, 0x65, 0x63, - 0x74, 0x22, 0x95, 0x01, 0x0a, 0x14, 0x50, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, - 0x43, 0x68, 0x65, 0x63, 0x6b, 0x49, 0x6e, 0x70, 0x75, 0x74, 0x12, 0x1a, 0x0a, 0x08, 0x72, 0x65, - 0x6c, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x72, 0x65, - 0x6c, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x16, 0x0a, 0x06, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, - 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x12, 0x49, - 0x0a, 0x11, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x75, 0x61, 0x6c, 0x5f, 0x74, 0x75, 0x70, - 0x6c, 0x65, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x61, 0x75, 0x74, 0x68, - 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x46, 0x67, 0x61, 0x54, 0x75, 0x70, - 0x6c, 0x65, 0x49, 0x6e, 0x70, 0x75, 0x74, 0x52, 0x10, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, - 0x75, 0x61, 0x6c, 0x54, 0x75, 0x70, 0x6c, 0x65, 0x73, 0x22, 0x65, 0x0a, 0x15, 0x50, 0x65, 0x72, - 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x52, 0x65, 0x73, 0x75, - 0x6c, 0x74, 0x12, 0x1a, 0x0a, 0x08, 0x72, 0x65, 0x6c, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x01, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x72, 0x65, 0x6c, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x16, - 0x0a, 0x06, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, - 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x12, 0x18, 0x0a, 0x07, 0x61, 0x6c, 0x6c, 0x6f, 0x77, 0x65, - 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x61, 0x6c, 0x6c, 0x6f, 0x77, 0x65, 0x64, - 0x22, 0x80, 0x0b, 0x0a, 0x04, 0x4d, 0x65, 0x74, 0x61, 0x12, 0x18, 0x0a, 0x07, 0x76, 0x65, 0x72, - 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x76, 0x65, 0x72, 0x73, - 0x69, 0x6f, 0x6e, 0x12, 0x1b, 0x0a, 0x09, 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x5f, 0x69, 0x64, - 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x49, 0x64, - 0x12, 0x35, 0x0a, 0x17, 0x69, 0x73, 0x5f, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x5f, 0x6c, 0x6f, - 0x67, 0x69, 0x6e, 0x5f, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, - 0x08, 0x52, 0x14, 0x69, 0x73, 0x47, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x4c, 0x6f, 0x67, 0x69, 0x6e, - 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x39, 0x0a, 0x19, 0x69, 0x73, 0x5f, 0x66, 0x61, - 0x63, 0x65, 0x62, 0x6f, 0x6f, 0x6b, 0x5f, 0x6c, 0x6f, 0x67, 0x69, 0x6e, 0x5f, 0x65, 0x6e, 0x61, - 0x62, 0x6c, 0x65, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x16, 0x69, 0x73, 0x46, 0x61, - 0x63, 0x65, 0x62, 0x6f, 0x6f, 0x6b, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x45, 0x6e, 0x61, 0x62, 0x6c, - 0x65, 0x64, 0x12, 0x35, 0x0a, 0x17, 0x69, 0x73, 0x5f, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x5f, - 0x6c, 0x6f, 0x67, 0x69, 0x6e, 0x5f, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x05, 0x20, - 0x01, 0x28, 0x08, 0x52, 0x14, 0x69, 0x73, 0x47, 0x69, 0x74, 0x68, 0x75, 0x62, 0x4c, 0x6f, 0x67, - 0x69, 0x6e, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x39, 0x0a, 0x19, 0x69, 0x73, 0x5f, - 0x6c, 0x69, 0x6e, 0x6b, 0x65, 0x64, 0x69, 0x6e, 0x5f, 0x6c, 0x6f, 0x67, 0x69, 0x6e, 0x5f, 0x65, - 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x06, 0x20, 0x01, 0x28, 0x08, 0x52, 0x16, 0x69, 0x73, - 0x4c, 0x69, 0x6e, 0x6b, 0x65, 0x64, 0x69, 0x6e, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x45, 0x6e, 0x61, - 0x62, 0x6c, 0x65, 0x64, 0x12, 0x33, 0x0a, 0x16, 0x69, 0x73, 0x5f, 0x61, 0x70, 0x70, 0x6c, 0x65, - 0x5f, 0x6c, 0x6f, 0x67, 0x69, 0x6e, 0x5f, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x07, - 0x20, 0x01, 0x28, 0x08, 0x52, 0x13, 0x69, 0x73, 0x41, 0x70, 0x70, 0x6c, 0x65, 0x4c, 0x6f, 0x67, - 0x69, 0x6e, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x37, 0x0a, 0x18, 0x69, 0x73, 0x5f, - 0x64, 0x69, 0x73, 0x63, 0x6f, 0x72, 0x64, 0x5f, 0x6c, 0x6f, 0x67, 0x69, 0x6e, 0x5f, 0x65, 0x6e, - 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x08, 0x20, 0x01, 0x28, 0x08, 0x52, 0x15, 0x69, 0x73, 0x44, - 0x69, 0x73, 0x63, 0x6f, 0x72, 0x64, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x45, 0x6e, 0x61, 0x62, 0x6c, - 0x65, 0x64, 0x12, 0x37, 0x0a, 0x18, 0x69, 0x73, 0x5f, 0x74, 0x77, 0x69, 0x74, 0x74, 0x65, 0x72, - 0x5f, 0x6c, 0x6f, 0x67, 0x69, 0x6e, 0x5f, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x09, - 0x20, 0x01, 0x28, 0x08, 0x52, 0x15, 0x69, 0x73, 0x54, 0x77, 0x69, 0x74, 0x74, 0x65, 0x72, 0x4c, - 0x6f, 0x67, 0x69, 0x6e, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x3b, 0x0a, 0x1a, 0x69, - 0x73, 0x5f, 0x6d, 0x69, 0x63, 0x72, 0x6f, 0x73, 0x6f, 0x66, 0x74, 0x5f, 0x6c, 0x6f, 0x67, 0x69, - 0x6e, 0x5f, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x08, 0x52, - 0x17, 0x69, 0x73, 0x4d, 0x69, 0x63, 0x72, 0x6f, 0x73, 0x6f, 0x66, 0x74, 0x4c, 0x6f, 0x67, 0x69, - 0x6e, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x35, 0x0a, 0x17, 0x69, 0x73, 0x5f, 0x74, - 0x77, 0x69, 0x74, 0x63, 0x68, 0x5f, 0x6c, 0x6f, 0x67, 0x69, 0x6e, 0x5f, 0x65, 0x6e, 0x61, 0x62, - 0x6c, 0x65, 0x64, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x08, 0x52, 0x14, 0x69, 0x73, 0x54, 0x77, 0x69, - 0x74, 0x63, 0x68, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, - 0x35, 0x0a, 0x17, 0x69, 0x73, 0x5f, 0x72, 0x6f, 0x62, 0x6c, 0x6f, 0x78, 0x5f, 0x6c, 0x6f, 0x67, - 0x69, 0x6e, 0x5f, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x08, - 0x52, 0x14, 0x69, 0x73, 0x52, 0x6f, 0x62, 0x6c, 0x6f, 0x78, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x45, - 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x41, 0x0a, 0x1d, 0x69, 0x73, 0x5f, 0x65, 0x6d, 0x61, - 0x69, 0x6c, 0x5f, 0x76, 0x65, 0x72, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, - 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x0d, 0x20, 0x01, 0x28, 0x08, 0x52, 0x1a, 0x69, - 0x73, 0x45, 0x6d, 0x61, 0x69, 0x6c, 0x56, 0x65, 0x72, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x69, - 0x6f, 0x6e, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x45, 0x0a, 0x1f, 0x69, 0x73, 0x5f, - 0x62, 0x61, 0x73, 0x69, 0x63, 0x5f, 0x61, 0x75, 0x74, 0x68, 0x65, 0x6e, 0x74, 0x69, 0x63, 0x61, - 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x0e, 0x20, 0x01, - 0x28, 0x08, 0x52, 0x1c, 0x69, 0x73, 0x42, 0x61, 0x73, 0x69, 0x63, 0x41, 0x75, 0x74, 0x68, 0x65, + 0x61, 0x52, 0x07, 0x61, 0x70, 0x70, 0x44, 0x61, 0x74, 0x61, 0x12, 0x36, 0x0a, 0x18, 0x68, 0x61, + 0x73, 0x5f, 0x73, 0x6b, 0x69, 0x70, 0x70, 0x65, 0x64, 0x5f, 0x6d, 0x66, 0x61, 0x5f, 0x73, 0x65, + 0x74, 0x75, 0x70, 0x5f, 0x61, 0x74, 0x18, 0x15, 0x20, 0x01, 0x28, 0x03, 0x52, 0x14, 0x68, 0x61, + 0x73, 0x53, 0x6b, 0x69, 0x70, 0x70, 0x65, 0x64, 0x4d, 0x66, 0x61, 0x53, 0x65, 0x74, 0x75, 0x70, + 0x41, 0x74, 0x12, 0x22, 0x0a, 0x0d, 0x6d, 0x66, 0x61, 0x5f, 0x6c, 0x6f, 0x63, 0x6b, 0x65, 0x64, + 0x5f, 0x61, 0x74, 0x18, 0x16, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x6d, 0x66, 0x61, 0x4c, 0x6f, + 0x63, 0x6b, 0x65, 0x64, 0x41, 0x74, 0x22, 0xd9, 0x06, 0x0a, 0x0c, 0x41, 0x75, 0x74, 0x68, 0x52, + 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, + 0x67, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, + 0x65, 0x12, 0x3e, 0x0a, 0x1c, 0x73, 0x68, 0x6f, 0x75, 0x6c, 0x64, 0x5f, 0x73, 0x68, 0x6f, 0x77, + 0x5f, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x5f, 0x6f, 0x74, 0x70, 0x5f, 0x73, 0x63, 0x72, 0x65, 0x65, + 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x18, 0x73, 0x68, 0x6f, 0x75, 0x6c, 0x64, 0x53, + 0x68, 0x6f, 0x77, 0x45, 0x6d, 0x61, 0x69, 0x6c, 0x4f, 0x74, 0x70, 0x53, 0x63, 0x72, 0x65, 0x65, + 0x6e, 0x12, 0x40, 0x0a, 0x1d, 0x73, 0x68, 0x6f, 0x75, 0x6c, 0x64, 0x5f, 0x73, 0x68, 0x6f, 0x77, + 0x5f, 0x6d, 0x6f, 0x62, 0x69, 0x6c, 0x65, 0x5f, 0x6f, 0x74, 0x70, 0x5f, 0x73, 0x63, 0x72, 0x65, + 0x65, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x19, 0x73, 0x68, 0x6f, 0x75, 0x6c, 0x64, + 0x53, 0x68, 0x6f, 0x77, 0x4d, 0x6f, 0x62, 0x69, 0x6c, 0x65, 0x4f, 0x74, 0x70, 0x53, 0x63, 0x72, + 0x65, 0x65, 0x6e, 0x12, 0x35, 0x0a, 0x17, 0x73, 0x68, 0x6f, 0x75, 0x6c, 0x64, 0x5f, 0x73, 0x68, + 0x6f, 0x77, 0x5f, 0x74, 0x6f, 0x74, 0x70, 0x5f, 0x73, 0x63, 0x72, 0x65, 0x65, 0x6e, 0x18, 0x04, + 0x20, 0x01, 0x28, 0x08, 0x52, 0x14, 0x73, 0x68, 0x6f, 0x75, 0x6c, 0x64, 0x53, 0x68, 0x6f, 0x77, + 0x54, 0x6f, 0x74, 0x70, 0x53, 0x63, 0x72, 0x65, 0x65, 0x6e, 0x12, 0x21, 0x0a, 0x0c, 0x61, 0x63, + 0x63, 0x65, 0x73, 0x73, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x0b, 0x61, 0x63, 0x63, 0x65, 0x73, 0x73, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x19, 0x0a, + 0x08, 0x69, 0x64, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x07, 0x69, 0x64, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x23, 0x0a, 0x0d, 0x72, 0x65, 0x66, 0x72, + 0x65, 0x73, 0x68, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x0c, 0x72, 0x65, 0x66, 0x72, 0x65, 0x73, 0x68, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x1d, 0x0a, + 0x0a, 0x65, 0x78, 0x70, 0x69, 0x72, 0x65, 0x73, 0x5f, 0x69, 0x6e, 0x18, 0x08, 0x20, 0x01, 0x28, + 0x03, 0x52, 0x09, 0x65, 0x78, 0x70, 0x69, 0x72, 0x65, 0x73, 0x49, 0x6e, 0x12, 0x27, 0x0a, 0x04, + 0x75, 0x73, 0x65, 0x72, 0x18, 0x09, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x13, 0x2e, 0x61, 0x75, 0x74, + 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x55, 0x73, 0x65, 0x72, 0x52, + 0x04, 0x75, 0x73, 0x65, 0x72, 0x12, 0x3e, 0x0a, 0x1b, 0x61, 0x75, 0x74, 0x68, 0x65, 0x6e, 0x74, + 0x69, 0x63, 0x61, 0x74, 0x6f, 0x72, 0x5f, 0x73, 0x63, 0x61, 0x6e, 0x6e, 0x65, 0x72, 0x5f, 0x69, + 0x6d, 0x61, 0x67, 0x65, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x09, 0x52, 0x19, 0x61, 0x75, 0x74, 0x68, + 0x65, 0x6e, 0x74, 0x69, 0x63, 0x61, 0x74, 0x6f, 0x72, 0x53, 0x63, 0x61, 0x6e, 0x6e, 0x65, 0x72, + 0x49, 0x6d, 0x61, 0x67, 0x65, 0x12, 0x31, 0x0a, 0x14, 0x61, 0x75, 0x74, 0x68, 0x65, 0x6e, 0x74, + 0x69, 0x63, 0x61, 0x74, 0x6f, 0x72, 0x5f, 0x73, 0x65, 0x63, 0x72, 0x65, 0x74, 0x18, 0x0b, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x13, 0x61, 0x75, 0x74, 0x68, 0x65, 0x6e, 0x74, 0x69, 0x63, 0x61, 0x74, + 0x6f, 0x72, 0x53, 0x65, 0x63, 0x72, 0x65, 0x74, 0x12, 0x40, 0x0a, 0x1c, 0x61, 0x75, 0x74, 0x68, + 0x65, 0x6e, 0x74, 0x69, 0x63, 0x61, 0x74, 0x6f, 0x72, 0x5f, 0x72, 0x65, 0x63, 0x6f, 0x76, 0x65, + 0x72, 0x79, 0x5f, 0x63, 0x6f, 0x64, 0x65, 0x73, 0x18, 0x0c, 0x20, 0x03, 0x28, 0x09, 0x52, 0x1a, + 0x61, 0x75, 0x74, 0x68, 0x65, 0x6e, 0x74, 0x69, 0x63, 0x61, 0x74, 0x6f, 0x72, 0x52, 0x65, 0x63, + 0x6f, 0x76, 0x65, 0x72, 0x79, 0x43, 0x6f, 0x64, 0x65, 0x73, 0x12, 0x46, 0x0a, 0x20, 0x73, 0x68, + 0x6f, 0x75, 0x6c, 0x64, 0x5f, 0x6f, 0x66, 0x66, 0x65, 0x72, 0x5f, 0x77, 0x65, 0x62, 0x61, 0x75, + 0x74, 0x68, 0x6e, 0x5f, 0x6d, 0x66, 0x61, 0x5f, 0x76, 0x65, 0x72, 0x69, 0x66, 0x79, 0x18, 0x0d, + 0x20, 0x01, 0x28, 0x08, 0x52, 0x1c, 0x73, 0x68, 0x6f, 0x75, 0x6c, 0x64, 0x4f, 0x66, 0x66, 0x65, + 0x72, 0x57, 0x65, 0x62, 0x61, 0x75, 0x74, 0x68, 0x6e, 0x4d, 0x66, 0x61, 0x56, 0x65, 0x72, 0x69, + 0x66, 0x79, 0x12, 0x44, 0x0a, 0x1f, 0x73, 0x68, 0x6f, 0x75, 0x6c, 0x64, 0x5f, 0x6f, 0x66, 0x66, + 0x65, 0x72, 0x5f, 0x77, 0x65, 0x62, 0x61, 0x75, 0x74, 0x68, 0x6e, 0x5f, 0x6d, 0x66, 0x61, 0x5f, + 0x73, 0x65, 0x74, 0x75, 0x70, 0x18, 0x0e, 0x20, 0x01, 0x28, 0x08, 0x52, 0x1b, 0x73, 0x68, 0x6f, + 0x75, 0x6c, 0x64, 0x4f, 0x66, 0x66, 0x65, 0x72, 0x57, 0x65, 0x62, 0x61, 0x75, 0x74, 0x68, 0x6e, + 0x4d, 0x66, 0x61, 0x53, 0x65, 0x74, 0x75, 0x70, 0x12, 0x45, 0x0a, 0x20, 0x73, 0x68, 0x6f, 0x75, + 0x6c, 0x64, 0x5f, 0x6f, 0x66, 0x66, 0x65, 0x72, 0x5f, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x5f, 0x6f, + 0x74, 0x70, 0x5f, 0x6d, 0x66, 0x61, 0x5f, 0x73, 0x65, 0x74, 0x75, 0x70, 0x18, 0x0f, 0x20, 0x01, + 0x28, 0x08, 0x52, 0x1b, 0x73, 0x68, 0x6f, 0x75, 0x6c, 0x64, 0x4f, 0x66, 0x66, 0x65, 0x72, 0x45, + 0x6d, 0x61, 0x69, 0x6c, 0x4f, 0x74, 0x70, 0x4d, 0x66, 0x61, 0x53, 0x65, 0x74, 0x75, 0x70, 0x12, + 0x41, 0x0a, 0x1e, 0x73, 0x68, 0x6f, 0x75, 0x6c, 0x64, 0x5f, 0x6f, 0x66, 0x66, 0x65, 0x72, 0x5f, + 0x73, 0x6d, 0x73, 0x5f, 0x6f, 0x74, 0x70, 0x5f, 0x6d, 0x66, 0x61, 0x5f, 0x73, 0x65, 0x74, 0x75, + 0x70, 0x18, 0x10, 0x20, 0x01, 0x28, 0x08, 0x52, 0x19, 0x73, 0x68, 0x6f, 0x75, 0x6c, 0x64, 0x4f, + 0x66, 0x66, 0x65, 0x72, 0x53, 0x6d, 0x73, 0x4f, 0x74, 0x70, 0x4d, 0x66, 0x61, 0x53, 0x65, 0x74, + 0x75, 0x70, 0x22, 0x40, 0x0a, 0x0a, 0x50, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, + 0x12, 0x16, 0x0a, 0x06, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x06, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x12, 0x1a, 0x0a, 0x08, 0x72, 0x65, 0x6c, 0x61, + 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x72, 0x65, 0x6c, 0x61, + 0x74, 0x69, 0x6f, 0x6e, 0x22, 0x46, 0x0a, 0x10, 0x46, 0x67, 0x61, 0x52, 0x65, 0x6c, 0x61, 0x74, + 0x69, 0x6f, 0x6e, 0x49, 0x6e, 0x70, 0x75, 0x74, 0x12, 0x1a, 0x0a, 0x08, 0x72, 0x65, 0x6c, 0x61, + 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x72, 0x65, 0x6c, 0x61, + 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x16, 0x0a, 0x06, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x18, 0x02, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x22, 0x57, 0x0a, 0x0d, + 0x46, 0x67, 0x61, 0x54, 0x75, 0x70, 0x6c, 0x65, 0x49, 0x6e, 0x70, 0x75, 0x74, 0x12, 0x12, 0x0a, + 0x04, 0x75, 0x73, 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x75, 0x73, 0x65, + 0x72, 0x12, 0x1a, 0x0a, 0x08, 0x72, 0x65, 0x6c, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x08, 0x72, 0x65, 0x6c, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x16, 0x0a, + 0x06, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x6f, + 0x62, 0x6a, 0x65, 0x63, 0x74, 0x22, 0x95, 0x01, 0x0a, 0x14, 0x50, 0x65, 0x72, 0x6d, 0x69, 0x73, + 0x73, 0x69, 0x6f, 0x6e, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x49, 0x6e, 0x70, 0x75, 0x74, 0x12, 0x1a, + 0x0a, 0x08, 0x72, 0x65, 0x6c, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x08, 0x72, 0x65, 0x6c, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x16, 0x0a, 0x06, 0x6f, 0x62, + 0x6a, 0x65, 0x63, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x6f, 0x62, 0x6a, 0x65, + 0x63, 0x74, 0x12, 0x49, 0x0a, 0x11, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x75, 0x61, 0x6c, + 0x5f, 0x74, 0x75, 0x70, 0x6c, 0x65, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1c, 0x2e, + 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x46, 0x67, + 0x61, 0x54, 0x75, 0x70, 0x6c, 0x65, 0x49, 0x6e, 0x70, 0x75, 0x74, 0x52, 0x10, 0x63, 0x6f, 0x6e, + 0x74, 0x65, 0x78, 0x74, 0x75, 0x61, 0x6c, 0x54, 0x75, 0x70, 0x6c, 0x65, 0x73, 0x22, 0x65, 0x0a, + 0x15, 0x50, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x43, 0x68, 0x65, 0x63, 0x6b, + 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x12, 0x1a, 0x0a, 0x08, 0x72, 0x65, 0x6c, 0x61, 0x74, 0x69, + 0x6f, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x72, 0x65, 0x6c, 0x61, 0x74, 0x69, + 0x6f, 0x6e, 0x12, 0x16, 0x0a, 0x06, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x18, 0x02, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x06, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x12, 0x18, 0x0a, 0x07, 0x61, 0x6c, + 0x6c, 0x6f, 0x77, 0x65, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x61, 0x6c, 0x6c, + 0x6f, 0x77, 0x65, 0x64, 0x22, 0xa8, 0x0b, 0x0a, 0x04, 0x4d, 0x65, 0x74, 0x61, 0x12, 0x18, 0x0a, + 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, + 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x1b, 0x0a, 0x09, 0x63, 0x6c, 0x69, 0x65, 0x6e, + 0x74, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x63, 0x6c, 0x69, 0x65, + 0x6e, 0x74, 0x49, 0x64, 0x12, 0x35, 0x0a, 0x17, 0x69, 0x73, 0x5f, 0x67, 0x6f, 0x6f, 0x67, 0x6c, + 0x65, 0x5f, 0x6c, 0x6f, 0x67, 0x69, 0x6e, 0x5f, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, + 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x14, 0x69, 0x73, 0x47, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x4c, + 0x6f, 0x67, 0x69, 0x6e, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x39, 0x0a, 0x19, 0x69, + 0x73, 0x5f, 0x66, 0x61, 0x63, 0x65, 0x62, 0x6f, 0x6f, 0x6b, 0x5f, 0x6c, 0x6f, 0x67, 0x69, 0x6e, + 0x5f, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x16, + 0x69, 0x73, 0x46, 0x61, 0x63, 0x65, 0x62, 0x6f, 0x6f, 0x6b, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x45, + 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x35, 0x0a, 0x17, 0x69, 0x73, 0x5f, 0x67, 0x69, 0x74, + 0x68, 0x75, 0x62, 0x5f, 0x6c, 0x6f, 0x67, 0x69, 0x6e, 0x5f, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, + 0x64, 0x18, 0x05, 0x20, 0x01, 0x28, 0x08, 0x52, 0x14, 0x69, 0x73, 0x47, 0x69, 0x74, 0x68, 0x75, + 0x62, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x39, 0x0a, + 0x19, 0x69, 0x73, 0x5f, 0x6c, 0x69, 0x6e, 0x6b, 0x65, 0x64, 0x69, 0x6e, 0x5f, 0x6c, 0x6f, 0x67, + 0x69, 0x6e, 0x5f, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x06, 0x20, 0x01, 0x28, 0x08, + 0x52, 0x16, 0x69, 0x73, 0x4c, 0x69, 0x6e, 0x6b, 0x65, 0x64, 0x69, 0x6e, 0x4c, 0x6f, 0x67, 0x69, + 0x6e, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x33, 0x0a, 0x16, 0x69, 0x73, 0x5f, 0x61, + 0x70, 0x70, 0x6c, 0x65, 0x5f, 0x6c, 0x6f, 0x67, 0x69, 0x6e, 0x5f, 0x65, 0x6e, 0x61, 0x62, 0x6c, + 0x65, 0x64, 0x18, 0x07, 0x20, 0x01, 0x28, 0x08, 0x52, 0x13, 0x69, 0x73, 0x41, 0x70, 0x70, 0x6c, + 0x65, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x37, 0x0a, + 0x18, 0x69, 0x73, 0x5f, 0x64, 0x69, 0x73, 0x63, 0x6f, 0x72, 0x64, 0x5f, 0x6c, 0x6f, 0x67, 0x69, + 0x6e, 0x5f, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x08, 0x20, 0x01, 0x28, 0x08, 0x52, + 0x15, 0x69, 0x73, 0x44, 0x69, 0x73, 0x63, 0x6f, 0x72, 0x64, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x45, + 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x37, 0x0a, 0x18, 0x69, 0x73, 0x5f, 0x74, 0x77, 0x69, + 0x74, 0x74, 0x65, 0x72, 0x5f, 0x6c, 0x6f, 0x67, 0x69, 0x6e, 0x5f, 0x65, 0x6e, 0x61, 0x62, 0x6c, + 0x65, 0x64, 0x18, 0x09, 0x20, 0x01, 0x28, 0x08, 0x52, 0x15, 0x69, 0x73, 0x54, 0x77, 0x69, 0x74, + 0x74, 0x65, 0x72, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, + 0x3b, 0x0a, 0x1a, 0x69, 0x73, 0x5f, 0x6d, 0x69, 0x63, 0x72, 0x6f, 0x73, 0x6f, 0x66, 0x74, 0x5f, + 0x6c, 0x6f, 0x67, 0x69, 0x6e, 0x5f, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x0a, 0x20, + 0x01, 0x28, 0x08, 0x52, 0x17, 0x69, 0x73, 0x4d, 0x69, 0x63, 0x72, 0x6f, 0x73, 0x6f, 0x66, 0x74, + 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x35, 0x0a, 0x17, + 0x69, 0x73, 0x5f, 0x74, 0x77, 0x69, 0x74, 0x63, 0x68, 0x5f, 0x6c, 0x6f, 0x67, 0x69, 0x6e, 0x5f, + 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x08, 0x52, 0x14, 0x69, + 0x73, 0x54, 0x77, 0x69, 0x74, 0x63, 0x68, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x45, 0x6e, 0x61, 0x62, + 0x6c, 0x65, 0x64, 0x12, 0x35, 0x0a, 0x17, 0x69, 0x73, 0x5f, 0x72, 0x6f, 0x62, 0x6c, 0x6f, 0x78, + 0x5f, 0x6c, 0x6f, 0x67, 0x69, 0x6e, 0x5f, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x0c, + 0x20, 0x01, 0x28, 0x08, 0x52, 0x14, 0x69, 0x73, 0x52, 0x6f, 0x62, 0x6c, 0x6f, 0x78, 0x4c, 0x6f, + 0x67, 0x69, 0x6e, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x41, 0x0a, 0x1d, 0x69, 0x73, + 0x5f, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x5f, 0x76, 0x65, 0x72, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, + 0x69, 0x6f, 0x6e, 0x5f, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x0d, 0x20, 0x01, 0x28, + 0x08, 0x52, 0x1a, 0x69, 0x73, 0x45, 0x6d, 0x61, 0x69, 0x6c, 0x56, 0x65, 0x72, 0x69, 0x66, 0x69, + 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x45, 0x0a, + 0x1f, 0x69, 0x73, 0x5f, 0x62, 0x61, 0x73, 0x69, 0x63, 0x5f, 0x61, 0x75, 0x74, 0x68, 0x65, 0x6e, + 0x74, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, + 0x18, 0x0e, 0x20, 0x01, 0x28, 0x08, 0x52, 0x1c, 0x69, 0x73, 0x42, 0x61, 0x73, 0x69, 0x63, 0x41, + 0x75, 0x74, 0x68, 0x65, 0x6e, 0x74, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x61, + 0x62, 0x6c, 0x65, 0x64, 0x12, 0x3c, 0x0a, 0x1b, 0x69, 0x73, 0x5f, 0x6d, 0x61, 0x67, 0x69, 0x63, + 0x5f, 0x6c, 0x69, 0x6e, 0x6b, 0x5f, 0x6c, 0x6f, 0x67, 0x69, 0x6e, 0x5f, 0x65, 0x6e, 0x61, 0x62, + 0x6c, 0x65, 0x64, 0x18, 0x0f, 0x20, 0x01, 0x28, 0x08, 0x52, 0x17, 0x69, 0x73, 0x4d, 0x61, 0x67, + 0x69, 0x63, 0x4c, 0x69, 0x6e, 0x6b, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x45, 0x6e, 0x61, 0x62, 0x6c, + 0x65, 0x64, 0x12, 0x2b, 0x0a, 0x12, 0x69, 0x73, 0x5f, 0x73, 0x69, 0x67, 0x6e, 0x5f, 0x75, 0x70, + 0x5f, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x10, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0f, + 0x69, 0x73, 0x53, 0x69, 0x67, 0x6e, 0x55, 0x70, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, + 0x3b, 0x0a, 0x1a, 0x69, 0x73, 0x5f, 0x73, 0x74, 0x72, 0x6f, 0x6e, 0x67, 0x5f, 0x70, 0x61, 0x73, + 0x73, 0x77, 0x6f, 0x72, 0x64, 0x5f, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x11, 0x20, + 0x01, 0x28, 0x08, 0x52, 0x17, 0x69, 0x73, 0x53, 0x74, 0x72, 0x6f, 0x6e, 0x67, 0x50, 0x61, 0x73, + 0x73, 0x77, 0x6f, 0x72, 0x64, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x3e, 0x0a, 0x1c, + 0x69, 0x73, 0x5f, 0x6d, 0x75, 0x6c, 0x74, 0x69, 0x5f, 0x66, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x5f, + 0x61, 0x75, 0x74, 0x68, 0x5f, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x12, 0x20, 0x01, + 0x28, 0x08, 0x52, 0x18, 0x69, 0x73, 0x4d, 0x75, 0x6c, 0x74, 0x69, 0x46, 0x61, 0x63, 0x74, 0x6f, + 0x72, 0x41, 0x75, 0x74, 0x68, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x52, 0x0a, 0x26, + 0x69, 0x73, 0x5f, 0x6d, 0x6f, 0x62, 0x69, 0x6c, 0x65, 0x5f, 0x62, 0x61, 0x73, 0x69, 0x63, 0x5f, + 0x61, 0x75, 0x74, 0x68, 0x65, 0x6e, 0x74, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x65, + 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x13, 0x20, 0x01, 0x28, 0x08, 0x52, 0x22, 0x69, 0x73, + 0x4d, 0x6f, 0x62, 0x69, 0x6c, 0x65, 0x42, 0x61, 0x73, 0x69, 0x63, 0x41, 0x75, 0x74, 0x68, 0x65, 0x6e, 0x74, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, - 0x12, 0x3c, 0x0a, 0x1b, 0x69, 0x73, 0x5f, 0x6d, 0x61, 0x67, 0x69, 0x63, 0x5f, 0x6c, 0x69, 0x6e, - 0x6b, 0x5f, 0x6c, 0x6f, 0x67, 0x69, 0x6e, 0x5f, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, - 0x0f, 0x20, 0x01, 0x28, 0x08, 0x52, 0x17, 0x69, 0x73, 0x4d, 0x61, 0x67, 0x69, 0x63, 0x4c, 0x69, - 0x6e, 0x6b, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x2b, - 0x0a, 0x12, 0x69, 0x73, 0x5f, 0x73, 0x69, 0x67, 0x6e, 0x5f, 0x75, 0x70, 0x5f, 0x65, 0x6e, 0x61, - 0x62, 0x6c, 0x65, 0x64, 0x18, 0x10, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0f, 0x69, 0x73, 0x53, 0x69, - 0x67, 0x6e, 0x55, 0x70, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x3b, 0x0a, 0x1a, 0x69, - 0x73, 0x5f, 0x73, 0x74, 0x72, 0x6f, 0x6e, 0x67, 0x5f, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, - 0x64, 0x5f, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x11, 0x20, 0x01, 0x28, 0x08, 0x52, - 0x17, 0x69, 0x73, 0x53, 0x74, 0x72, 0x6f, 0x6e, 0x67, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, - 0x64, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x3e, 0x0a, 0x1c, 0x69, 0x73, 0x5f, 0x6d, - 0x75, 0x6c, 0x74, 0x69, 0x5f, 0x66, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x5f, 0x61, 0x75, 0x74, 0x68, - 0x5f, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x12, 0x20, 0x01, 0x28, 0x08, 0x52, 0x18, - 0x69, 0x73, 0x4d, 0x75, 0x6c, 0x74, 0x69, 0x46, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x41, 0x75, 0x74, - 0x68, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x52, 0x0a, 0x26, 0x69, 0x73, 0x5f, 0x6d, - 0x6f, 0x62, 0x69, 0x6c, 0x65, 0x5f, 0x62, 0x61, 0x73, 0x69, 0x63, 0x5f, 0x61, 0x75, 0x74, 0x68, - 0x65, 0x6e, 0x74, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x65, 0x6e, 0x61, 0x62, 0x6c, - 0x65, 0x64, 0x18, 0x13, 0x20, 0x01, 0x28, 0x08, 0x52, 0x22, 0x69, 0x73, 0x4d, 0x6f, 0x62, 0x69, - 0x6c, 0x65, 0x42, 0x61, 0x73, 0x69, 0x63, 0x41, 0x75, 0x74, 0x68, 0x65, 0x6e, 0x74, 0x69, 0x63, - 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x41, 0x0a, 0x1d, - 0x69, 0x73, 0x5f, 0x70, 0x68, 0x6f, 0x6e, 0x65, 0x5f, 0x76, 0x65, 0x72, 0x69, 0x66, 0x69, 0x63, - 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x14, 0x20, - 0x01, 0x28, 0x08, 0x52, 0x1a, 0x69, 0x73, 0x50, 0x68, 0x6f, 0x6e, 0x65, 0x56, 0x65, 0x72, 0x69, - 0x66, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, - 0x37, 0x0a, 0x18, 0x69, 0x73, 0x5f, 0x6f, 0x72, 0x67, 0x5f, 0x64, 0x69, 0x73, 0x63, 0x6f, 0x76, - 0x65, 0x72, 0x79, 0x5f, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x15, 0x20, 0x01, 0x28, - 0x08, 0x52, 0x15, 0x69, 0x73, 0x4f, 0x72, 0x67, 0x44, 0x69, 0x73, 0x63, 0x6f, 0x76, 0x65, 0x72, - 0x79, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x2d, 0x0a, 0x13, 0x69, 0x73, 0x5f, 0x74, - 0x6f, 0x74, 0x70, 0x5f, 0x6d, 0x66, 0x61, 0x5f, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, - 0x16, 0x20, 0x01, 0x28, 0x08, 0x52, 0x10, 0x69, 0x73, 0x54, 0x6f, 0x74, 0x70, 0x4d, 0x66, 0x61, - 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x36, 0x0a, 0x18, 0x69, 0x73, 0x5f, 0x65, 0x6d, - 0x61, 0x69, 0x6c, 0x5f, 0x6f, 0x74, 0x70, 0x5f, 0x6d, 0x66, 0x61, 0x5f, 0x65, 0x6e, 0x61, 0x62, - 0x6c, 0x65, 0x64, 0x18, 0x17, 0x20, 0x01, 0x28, 0x08, 0x52, 0x14, 0x69, 0x73, 0x45, 0x6d, 0x61, - 0x69, 0x6c, 0x4f, 0x74, 0x70, 0x4d, 0x66, 0x61, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, - 0x32, 0x0a, 0x16, 0x69, 0x73, 0x5f, 0x73, 0x6d, 0x73, 0x5f, 0x6f, 0x74, 0x70, 0x5f, 0x6d, 0x66, - 0x61, 0x5f, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x18, 0x20, 0x01, 0x28, 0x08, 0x52, - 0x12, 0x69, 0x73, 0x53, 0x6d, 0x73, 0x4f, 0x74, 0x70, 0x4d, 0x66, 0x61, 0x45, 0x6e, 0x61, 0x62, - 0x6c, 0x65, 0x64, 0x12, 0x2e, 0x0a, 0x13, 0x69, 0x73, 0x5f, 0x77, 0x65, 0x62, 0x61, 0x75, 0x74, - 0x68, 0x6e, 0x5f, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x19, 0x20, 0x01, 0x28, 0x08, - 0x52, 0x11, 0x69, 0x73, 0x57, 0x65, 0x62, 0x61, 0x75, 0x74, 0x68, 0x6e, 0x45, 0x6e, 0x61, 0x62, - 0x6c, 0x65, 0x64, 0x42, 0xbb, 0x01, 0x0a, 0x11, 0x63, 0x6f, 0x6d, 0x2e, 0x61, 0x75, 0x74, 0x68, - 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x42, 0x0a, 0x54, 0x79, 0x70, 0x65, 0x73, - 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x50, 0x01, 0x5a, 0x45, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, - 0x63, 0x6f, 0x6d, 0x2f, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x64, 0x65, - 0x76, 0x2f, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2f, 0x67, 0x65, 0x6e, - 0x2f, 0x67, 0x6f, 0x2f, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2f, 0x76, - 0x31, 0x3b, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x76, 0x31, 0xa2, 0x02, - 0x03, 0x41, 0x58, 0x58, 0xaa, 0x02, 0x0d, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, - 0x72, 0x2e, 0x56, 0x31, 0xca, 0x02, 0x0d, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, - 0x72, 0x5c, 0x56, 0x31, 0xe2, 0x02, 0x19, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, - 0x72, 0x5c, 0x56, 0x31, 0x5c, 0x47, 0x50, 0x42, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, - 0xea, 0x02, 0x0e, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x3a, 0x3a, 0x56, - 0x31, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, + 0x12, 0x41, 0x0a, 0x1d, 0x69, 0x73, 0x5f, 0x70, 0x68, 0x6f, 0x6e, 0x65, 0x5f, 0x76, 0x65, 0x72, + 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, + 0x64, 0x18, 0x14, 0x20, 0x01, 0x28, 0x08, 0x52, 0x1a, 0x69, 0x73, 0x50, 0x68, 0x6f, 0x6e, 0x65, + 0x56, 0x65, 0x72, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x6e, 0x61, 0x62, + 0x6c, 0x65, 0x64, 0x12, 0x37, 0x0a, 0x18, 0x69, 0x73, 0x5f, 0x6f, 0x72, 0x67, 0x5f, 0x64, 0x69, + 0x73, 0x63, 0x6f, 0x76, 0x65, 0x72, 0x79, 0x5f, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, + 0x15, 0x20, 0x01, 0x28, 0x08, 0x52, 0x15, 0x69, 0x73, 0x4f, 0x72, 0x67, 0x44, 0x69, 0x73, 0x63, + 0x6f, 0x76, 0x65, 0x72, 0x79, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x2d, 0x0a, 0x13, + 0x69, 0x73, 0x5f, 0x74, 0x6f, 0x74, 0x70, 0x5f, 0x6d, 0x66, 0x61, 0x5f, 0x65, 0x6e, 0x61, 0x62, + 0x6c, 0x65, 0x64, 0x18, 0x16, 0x20, 0x01, 0x28, 0x08, 0x52, 0x10, 0x69, 0x73, 0x54, 0x6f, 0x74, + 0x70, 0x4d, 0x66, 0x61, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x36, 0x0a, 0x18, 0x69, + 0x73, 0x5f, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x5f, 0x6f, 0x74, 0x70, 0x5f, 0x6d, 0x66, 0x61, 0x5f, + 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x17, 0x20, 0x01, 0x28, 0x08, 0x52, 0x14, 0x69, + 0x73, 0x45, 0x6d, 0x61, 0x69, 0x6c, 0x4f, 0x74, 0x70, 0x4d, 0x66, 0x61, 0x45, 0x6e, 0x61, 0x62, + 0x6c, 0x65, 0x64, 0x12, 0x32, 0x0a, 0x16, 0x69, 0x73, 0x5f, 0x73, 0x6d, 0x73, 0x5f, 0x6f, 0x74, + 0x70, 0x5f, 0x6d, 0x66, 0x61, 0x5f, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x18, 0x20, + 0x01, 0x28, 0x08, 0x52, 0x12, 0x69, 0x73, 0x53, 0x6d, 0x73, 0x4f, 0x74, 0x70, 0x4d, 0x66, 0x61, + 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x2e, 0x0a, 0x13, 0x69, 0x73, 0x5f, 0x77, 0x65, + 0x62, 0x61, 0x75, 0x74, 0x68, 0x6e, 0x5f, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x19, + 0x20, 0x01, 0x28, 0x08, 0x52, 0x11, 0x69, 0x73, 0x57, 0x65, 0x62, 0x61, 0x75, 0x74, 0x68, 0x6e, + 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x12, 0x26, 0x0a, 0x0f, 0x69, 0x73, 0x5f, 0x6d, 0x66, + 0x61, 0x5f, 0x65, 0x6e, 0x66, 0x6f, 0x72, 0x63, 0x65, 0x64, 0x18, 0x1a, 0x20, 0x01, 0x28, 0x08, + 0x52, 0x0d, 0x69, 0x73, 0x4d, 0x66, 0x61, 0x45, 0x6e, 0x66, 0x6f, 0x72, 0x63, 0x65, 0x64, 0x42, + 0xbb, 0x01, 0x0a, 0x11, 0x63, 0x6f, 0x6d, 0x2e, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, + 0x65, 0x72, 0x2e, 0x76, 0x31, 0x42, 0x0a, 0x54, 0x79, 0x70, 0x65, 0x73, 0x50, 0x72, 0x6f, 0x74, + 0x6f, 0x50, 0x01, 0x5a, 0x45, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, + 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x64, 0x65, 0x76, 0x2f, 0x61, 0x75, + 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2f, 0x67, 0x65, 0x6e, 0x2f, 0x67, 0x6f, 0x2f, + 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2f, 0x76, 0x31, 0x3b, 0x61, 0x75, + 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x76, 0x31, 0xa2, 0x02, 0x03, 0x41, 0x58, 0x58, + 0xaa, 0x02, 0x0d, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x2e, 0x56, 0x31, + 0xca, 0x02, 0x0d, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x5c, 0x56, 0x31, + 0xe2, 0x02, 0x19, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x5c, 0x56, 0x31, + 0x5c, 0x47, 0x50, 0x42, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0xea, 0x02, 0x0e, 0x41, + 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x72, 0x3a, 0x3a, 0x56, 0x31, 0x62, 0x06, 0x70, + 0x72, 0x6f, 0x74, 0x6f, 0x33, } var ( diff --git a/gen/openapi/authorizer.swagger.json b/gen/openapi/authorizer.swagger.json index dbeca209..7c20feec 100644 --- a/gen/openapi/authorizer.swagger.json +++ b/gen/openapi/authorizer.swagger.json @@ -1495,6 +1495,39 @@ ] } }, + "/v1/email_otp_mfa_setup": { + "post": { + "summary": "EmailOtpMfaSetup sends a one-time code to the caller's own email and\ncreates an unverified email-OTP MFA enrollment. Dual-mode permissions:\n(a) an authenticated caller (bearer token) — the settings-screen action\nfor an ALREADY-logged-in user adding a second factor; the request body is\nunused in this mode. (b) a caller in the withheld first-time-offer\nstate, with no bearer token yet — identified by the MFA session cookie\nplus email/phone_number, same pattern as SkipMfaSetup. Either mode\nreuses the same underlying Authenticator row once VerifyOtp marks it\nverified.", + "operationId": "AuthorizerService_EmailOtpMfaSetup", + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "$ref": "#/definitions/v1EmailOtpMfaSetupResponse" + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "$ref": "#/definitions/rpcStatus" + } + } + }, + "parameters": [ + { + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/v1EmailOtpMfaSetupRequest" + } + } + ], + "tags": [ + "AuthorizerService" + ] + } + }, "/v1/forgot_password": { "post": { "operationId": "AuthorizerService_ForgotPassword", @@ -1560,6 +1593,39 @@ ] } }, + "/v1/lock_mfa": { + "post": { + "summary": "LockMfa records that the caller lost access to their only MFA\nfactor(s). Only allowed when the caller has NO verified Email/SMS OTP\nfallback enrolled — if one exists, use it instead of locking. Does not\nissue a token; the account requires admin recovery afterward.", + "operationId": "AuthorizerService_LockMfa", + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "$ref": "#/definitions/v1LockMfaResponse" + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "$ref": "#/definitions/rpcStatus" + } + } + }, + "parameters": [ + { + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/v1LockMfaRequest" + } + } + ], + "tags": [ + "AuthorizerService" + ] + } + }, "/v1/login": { "post": { "summary": "Login authenticates with email/phone + password.", @@ -1890,6 +1956,72 @@ ] } }, + "/v1/skip_mfa_setup": { + "post": { + "summary": "SkipMfaSetup completes an in-progress, token-withheld MFA offer by\nrecording that the caller explicitly declined it, then issues the\naccess token that was withheld. Identified by the MFA session cookie\n(set when the offer screen was returned) plus email/phone_number to\nresolve the pending user — same identification pattern as VerifyOtp.\nFails with FAILED_PRECONDITION if MFA is organization-enforced\n(enforce-mfa) — enforcement is never skippable.", + "operationId": "AuthorizerService_SkipMfaSetup", + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "$ref": "#/definitions/v1AuthResponse" + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "$ref": "#/definitions/rpcStatus" + } + } + }, + "parameters": [ + { + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/v1SkipMfaSetupRequest" + } + } + ], + "tags": [ + "AuthorizerService" + ] + } + }, + "/v1/sms_otp_mfa_setup": { + "post": { + "summary": "SmsOtpMfaSetup sends a one-time code to the caller's own phone number\nand creates an unverified SMS-OTP MFA enrollment. Same dual-mode\npermissions and relationship to VerifyOtp as EmailOtpMfaSetup.", + "operationId": "AuthorizerService_SmsOtpMfaSetup", + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "$ref": "#/definitions/v1SmsOtpMfaSetupResponse" + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "$ref": "#/definitions/rpcStatus" + } + } + }, + "parameters": [ + { + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/v1SmsOtpMfaSetupRequest" + } + } + ], + "tags": [ + "AuthorizerService" + ] + } + }, "/v1/update_profile": { "post": { "operationId": "AuthorizerService_UpdateProfile", @@ -2156,6 +2288,10 @@ }, "is_webauthn_enabled": { "type": "boolean" + }, + "is_mfa_enforced": { + "type": "boolean", + "description": "Mirrors Meta.is_mfa_enforced in GraphQL." } }, "description": "Meta mirrors the GraphQL Meta type — server feature flags + provider\navailability, returned by the Meta query." @@ -2231,6 +2367,16 @@ "app_data": { "$ref": "#/definitions/v1AppData", "description": "Free-form key/value bag — same as GraphQL `app_data: Map`." + }, + "has_skipped_mfa_setup_at": { + "type": "string", + "format": "int64", + "description": "Mirrors User.has_skipped_mfa_setup_at in GraphQL. 0 means never skipped." + }, + "mfa_locked_at": { + "type": "string", + "format": "int64", + "description": "Mirrors User.mfa_locked_at in GraphQL. 0 means not locked." } }, "description": "User mirrors the GraphQL User type. Returned by Profile and embedded in\nAuthResponse." @@ -2555,6 +2701,22 @@ "items": { "type": "string" } + }, + "should_offer_webauthn_mfa_verify": { + "type": "boolean", + "description": "Mirrors AuthResponse.should_offer_webauthn_mfa_verify in GraphQL." + }, + "should_offer_webauthn_mfa_setup": { + "type": "boolean", + "description": "Mirrors AuthResponse.should_offer_webauthn_mfa_setup in GraphQL." + }, + "should_offer_email_otp_mfa_setup": { + "type": "boolean", + "description": "Mirrors AuthResponse.should_offer_email_otp_mfa_setup in GraphQL." + }, + "should_offer_sms_otp_mfa_setup": { + "type": "boolean", + "description": "Mirrors AuthResponse.should_offer_sms_otp_mfa_setup in GraphQL." } }, "description": "AuthResponse mirrors the GraphQL AuthResponse type. Returned (wrapped) by\nevery method that produces a session: Signup, Login, MagicLinkLogin,\nVerifyEmail, VerifyOtp, Session." @@ -2774,6 +2936,26 @@ } } }, + "v1EmailOtpMfaSetupRequest": { + "type": "object", + "properties": { + "email": { + "type": "string", + "description": "Only used in the MFA-session-cookie mode (a caller in the withheld\nfirst-time-offer state, with no bearer token yet) to resolve which\nuser's MFA session cookie this is — same pattern as SkipMfaSetupRequest\n/ LockMfaRequest. Ignored when the caller has a valid bearer\ntoken/session, which already identifies the user." + }, + "phone_number": { + "type": "string" + } + } + }, + "v1EmailOtpMfaSetupResponse": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + } + }, "v1EmailTemplate": { "type": "object", "properties": { @@ -3211,6 +3393,26 @@ } } }, + "v1LockMfaRequest": { + "type": "object", + "properties": { + "email": { + "type": "string", + "description": "Either email or phone_number is required, to resolve which user's MFA\nsession cookie this is — same pattern as SkipMfaSetupRequest." + }, + "phone_number": { + "type": "string" + } + } + }, + "v1LockMfaResponse": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + } + }, "v1LoginRequest": { "type": "object", "properties": { @@ -3577,6 +3779,41 @@ } } }, + "v1SkipMfaSetupRequest": { + "type": "object", + "properties": { + "email": { + "type": "string", + "description": "Either email or phone_number is required, to resolve which user's MFA\nsession cookie this is." + }, + "phone_number": { + "type": "string" + }, + "state": { + "type": "string" + } + } + }, + "v1SmsOtpMfaSetupRequest": { + "type": "object", + "properties": { + "email": { + "type": "string", + "description": "Same dual-mode identification semantics as EmailOtpMfaSetupRequest." + }, + "phone_number": { + "type": "string" + } + } + }, + "v1SmsOtpMfaSetupResponse": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + } + }, "v1TestEndpointRequest": { "type": "object", "properties": { @@ -3906,6 +4143,10 @@ }, "app_data": { "$ref": "#/definitions/v1AppData" + }, + "reset_mfa": { + "type": "boolean", + "description": "Mirrors UpdateUserRequest.reset_mfa in GraphQL — see schema.graphqls'\ndoc comment on that field for exact semantics (clears mfa_locked_at,\nis_multi_factor_auth_enabled, has_skipped_mfa_setup_at, deletes all\nauthenticators/passkeys)." } }, "description": "UpdateUserRequest mirrors model.UpdateUserRequest. Nullable GraphQL inputs use\nproto3 `optional` so an unset field is distinguishable from an empty value\n(e.g. clearing a name vs leaving it untouched, or email_verified=false)." diff --git a/go.mod b/go.mod index 7bcb2317..58133fa8 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/authorizerdev/authorizer -go 1.26.4 +go 1.26.5 require ( buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260415201107-50325440f8f2.1 @@ -42,8 +42,8 @@ require ( github.com/twilio/twilio-go v1.14.1 github.com/vektah/gqlparser/v2 v2.5.26 go.mongodb.org/mongo-driver v1.17.9 - golang.org/x/crypto v0.52.0 - golang.org/x/net v0.55.0 + golang.org/x/crypto v0.53.0 + golang.org/x/net v0.56.0 golang.org/x/oauth2 v0.36.0 golang.org/x/sync v0.21.0 golang.org/x/time v0.15.0 @@ -200,10 +200,10 @@ require ( go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/arch v0.3.0 // indirect golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect - golang.org/x/mod v0.35.0 // indirect - golang.org/x/sys v0.45.0 // indirect - golang.org/x/text v0.37.0 // indirect - golang.org/x/tools v0.44.0 // indirect + golang.org/x/mod v0.37.0 // indirect + golang.org/x/sys v0.46.0 // indirect + golang.org/x/text v0.39.0 // indirect + golang.org/x/tools v0.47.0 // indirect gonum.org/v1/gonum v0.17.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect diff --git a/go.sum b/go.sum index c9a0635d..cd4f41eb 100644 --- a/go.sum +++ b/go.sum @@ -599,8 +599,8 @@ golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58 golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0= golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw= -golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= -golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= +golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= +golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM= golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80= @@ -613,8 +613,8 @@ golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= -golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= +golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -633,8 +633,8 @@ golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI= -golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= -golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= @@ -669,8 +669,8 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= -golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= @@ -686,8 +686,8 @@ golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= -golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= -golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus= +golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= @@ -703,8 +703,8 @@ golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4f golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= -golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= -golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= +golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= +golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/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..e3393cf5 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,8 +341,10 @@ type ComplexityRoot struct { RotateClientSecret func(childComplexity int, params model.ClientRequest) int RotateScimToken func(childComplexity int, params model.ScimEndpointRequest) int Signup func(childComplexity int, params model.SignUpRequest) int - SkipMfaSetup func(childComplexity int) int + SkipMfaSetup func(childComplexity int, params model.SkipMfaSetupRequest) int + SmsOtpMfaSetup func(childComplexity int, params *model.OtpMfaSetupRequest) int TestEndpoint func(childComplexity int, params model.TestEndpointRequest) int + TotpMfaSetup func(childComplexity int, params *model.OtpMfaSetupRequest) int UpdateClient func(childComplexity int, params model.UpdateClientRequest) int UpdateEmailTemplate func(childComplexity int, params model.UpdateEmailTemplateRequest) int UpdateEnv func(childComplexity int, params model.UpdateEnvRequest) int @@ -546,6 +553,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 +672,11 @@ 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) + TotpMfaSetup(ctx context.Context, params *model.OtpMfaSetupRequest) (*model.AuthResponse, 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 +962,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 +976,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 +2297,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 +2400,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 +2592,24 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin break } - return e.complexity.Mutation.SkipMfaSetup(childComplexity), true + args, err := ec.field_Mutation_skip_mfa_setup_args(ctx, rawArgs) + if err != nil { + return 0, false + } + + return e.complexity.Mutation.SkipMfaSetup(childComplexity, args["params"].(model.SkipMfaSetupRequest)), true + + case "Mutation.sms_otp_mfa_setup": + if e.complexity.Mutation.SmsOtpMfaSetup == nil { + break + } + + args, err := ec.field_Mutation_sms_otp_mfa_setup_args(ctx, rawArgs) + if err != nil { + return 0, false + } + + return e.complexity.Mutation.SmsOtpMfaSetup(childComplexity, args["params"].(*model.OtpMfaSetupRequest)), true case "Mutation._test_endpoint": if e.complexity.Mutation.TestEndpoint == nil { @@ -2549,6 +2623,18 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return e.complexity.Mutation.TestEndpoint(childComplexity, args["params"].(model.TestEndpointRequest)), true + case "Mutation.totp_mfa_setup": + if e.complexity.Mutation.TotpMfaSetup == nil { + break + } + + args, err := ec.field_Mutation_totp_mfa_setup_args(ctx, rawArgs) + if err != nil { + return 0, false + } + + return e.complexity.Mutation.TotpMfaSetup(childComplexity, args["params"].(*model.OtpMfaSetupRequest)), true + case "Mutation._update_client": if e.complexity.Mutation.UpdateClient == nil { break @@ -3836,6 +3922,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 +4380,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 +4389,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 +4401,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 +4613,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 +4659,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 +5360,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 +5768,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 +5910,41 @@ type Mutation { revoke(params: OAuthRevokeRequest!): Response! verify_otp(params: VerifyOTPRequest!): AuthResponse! resend_otp(params: ResendOTPRequest!): Response! - # skip_mfa_setup records that the authenticated caller explicitly declined - # the optional MFA setup prompt. Fails with FAILED_PRECONDITION if MFA is - # organization-enforced (enforce-mfa) — enforcement is never skippable. - skip_mfa_setup: Response! + # skip_mfa_setup completes an in-progress, token-withheld MFA offer by + # recording that the caller explicitly declined it, then issues the + # access token that was withheld. Identified by the MFA session cookie + # (set when the offer screen was returned) plus email/phone_number to + # resolve the pending user — same identification pattern as verify_otp. + # Fails with FAILED_PRECONDITION if MFA is organization-enforced + # (enforce-mfa) — enforcement is never skippable. + skip_mfa_setup(params: SkipMfaSetupRequest!): AuthResponse! + # lock_mfa records that the caller lost access to their only MFA + # factor(s). Only allowed when the caller has NO verified Email/SMS OTP + # fallback enrolled — if one exists, use it instead of locking. Does not + # issue a token; the account requires admin recovery afterward. + lock_mfa(params: LockMfaRequest!): Response! + # email_otp_mfa_setup sends a one-time code to the caller's own email and + # creates an unverified email-OTP MFA enrollment. Dual-mode permissions: + # (a) an authenticated caller (bearer token) — the settings-screen action + # for an ALREADY-logged-in user adding a second factor; params is unused + # in this mode. (b) a caller in the withheld first-time-offer state, with + # no bearer token yet — identified by the MFA session cookie plus + # params.email/phone_number, same pattern as skip_mfa_setup. Either mode + # reuses the same underlying Authenticator row once verify_otp marks it + # verified. + email_otp_mfa_setup(params: OtpMfaSetupRequest): Response! + # sms_otp_mfa_setup sends a one-time code to the caller's own phone number + # and creates an unverified SMS-OTP MFA enrollment. Same dual-mode + # permissions and relationship to verify_otp as email_otp_mfa_setup. + sms_otp_mfa_setup(params: OtpMfaSetupRequest): Response! + # totp_mfa_setup generates a fresh TOTP secret/QR/recovery-codes for the + # caller to enroll as an MFA method. Same dual-mode permissions as + # email_otp_mfa_setup/sms_otp_mfa_setup. Unlike those, nothing is sent + # anywhere - the enrollment payload is returned directly (same + # authenticator_scanner_image/authenticator_secret/authenticator_recovery_codes + # fields the login/signup gate response uses) so the caller scans the + # QR/enters the code, then completes enrollment via verify_otp(is_totp: true). + totp_mfa_setup(params: OtpMfaSetupRequest): AuthResponse! # WebAuthn / passkey self-service ceremonies (no admin ` + "`" + `_` + "`" + ` prefix). webauthn_registration_options( email: String @@ -7135,6 +7309,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 +7365,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 +7645,90 @@ func (ec *executionContext) field_Mutation_signup_argsParams( return zeroVal, nil } +func (ec *executionContext) field_Mutation_skip_mfa_setup_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { + var err error + args := map[string]any{} + arg0, err := ec.field_Mutation_skip_mfa_setup_argsParams(ctx, rawArgs) + if err != nil { + return nil, err + } + args["params"] = arg0 + return args, nil +} +func (ec *executionContext) field_Mutation_skip_mfa_setup_argsParams( + ctx context.Context, + rawArgs map[string]any, +) (model.SkipMfaSetupRequest, error) { + if _, ok := rawArgs["params"]; !ok { + var zeroVal model.SkipMfaSetupRequest + return zeroVal, nil + } + + ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("params")) + if tmp, ok := rawArgs["params"]; ok { + return ec.unmarshalNSkipMfaSetupRequest2githubᚗcomᚋauthorizerdevᚋauthorizerᚋinternalᚋgraphᚋmodelᚐSkipMfaSetupRequest(ctx, tmp) + } + + var zeroVal model.SkipMfaSetupRequest + return zeroVal, nil +} + +func (ec *executionContext) field_Mutation_sms_otp_mfa_setup_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { + var err error + args := map[string]any{} + arg0, err := ec.field_Mutation_sms_otp_mfa_setup_argsParams(ctx, rawArgs) + if err != nil { + return nil, err + } + args["params"] = arg0 + return args, nil +} +func (ec *executionContext) field_Mutation_sms_otp_mfa_setup_argsParams( + ctx context.Context, + rawArgs map[string]any, +) (*model.OtpMfaSetupRequest, error) { + if _, ok := rawArgs["params"]; !ok { + var zeroVal *model.OtpMfaSetupRequest + return zeroVal, nil + } + + ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("params")) + if tmp, ok := rawArgs["params"]; ok { + return ec.unmarshalOOtpMfaSetupRequest2ᚖgithubᚗcomᚋauthorizerdevᚋauthorizerᚋinternalᚋgraphᚋmodelᚐOtpMfaSetupRequest(ctx, tmp) + } + + var zeroVal *model.OtpMfaSetupRequest + return zeroVal, nil +} + +func (ec *executionContext) field_Mutation_totp_mfa_setup_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { + var err error + args := map[string]any{} + arg0, err := ec.field_Mutation_totp_mfa_setup_argsParams(ctx, rawArgs) + if err != nil { + return nil, err + } + args["params"] = arg0 + return args, nil +} +func (ec *executionContext) field_Mutation_totp_mfa_setup_argsParams( + ctx context.Context, + rawArgs map[string]any, +) (*model.OtpMfaSetupRequest, error) { + if _, ok := rawArgs["params"]; !ok { + var zeroVal *model.OtpMfaSetupRequest + return zeroVal, nil + } + + ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("params")) + if tmp, ok := rawArgs["params"]; ok { + return ec.unmarshalOOtpMfaSetupRequest2ᚖgithubᚗcomᚋauthorizerdevᚋauthorizerᚋinternalᚋgraphᚋmodelᚐOtpMfaSetupRequest(ctx, tmp) + } + + var zeroVal *model.OtpMfaSetupRequest + return zeroVal, nil +} + func (ec *executionContext) field_Mutation_update_profile_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { var err error args := map[string]any{} @@ -9572,6 +9886,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 +10249,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 +15298,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 +16642,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 +16733,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 +16824,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 +16915,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 +17172,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 +17501,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 +17614,216 @@ func (ec *executionContext) _Mutation_skip_mfa_setup(ctx context.Context, field }() resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) { ctx = rctx // use context from middleware stack in children - return ec.resolvers.Mutation().SkipMfaSetup(rctx) + return ec.resolvers.Mutation().SkipMfaSetup(rctx, fc.Args["params"].(model.SkipMfaSetupRequest)) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(*model.AuthResponse) + fc.Result = res + return ec.marshalNAuthResponse2ᚖgithubᚗcomᚋauthorizerdevᚋauthorizerᚋinternalᚋgraphᚋmodelᚐAuthResponse(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_Mutation_skip_mfa_setup(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Mutation", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "message": + return ec.fieldContext_AuthResponse_message(ctx, field) + case "should_show_email_otp_screen": + return ec.fieldContext_AuthResponse_should_show_email_otp_screen(ctx, field) + case "should_show_mobile_otp_screen": + return ec.fieldContext_AuthResponse_should_show_mobile_otp_screen(ctx, field) + case "should_show_totp_screen": + return ec.fieldContext_AuthResponse_should_show_totp_screen(ctx, field) + case "should_offer_webauthn_mfa_verify": + return ec.fieldContext_AuthResponse_should_offer_webauthn_mfa_verify(ctx, field) + case "should_offer_mfa_setup": + return ec.fieldContext_AuthResponse_should_offer_mfa_setup(ctx, field) + case "should_offer_webauthn_mfa_setup": + return ec.fieldContext_AuthResponse_should_offer_webauthn_mfa_setup(ctx, field) + case "should_offer_email_otp_mfa_setup": + return ec.fieldContext_AuthResponse_should_offer_email_otp_mfa_setup(ctx, field) + case "should_offer_sms_otp_mfa_setup": + return ec.fieldContext_AuthResponse_should_offer_sms_otp_mfa_setup(ctx, field) + case "access_token": + return ec.fieldContext_AuthResponse_access_token(ctx, field) + case "id_token": + return ec.fieldContext_AuthResponse_id_token(ctx, field) + case "refresh_token": + return ec.fieldContext_AuthResponse_refresh_token(ctx, field) + case "expires_in": + return ec.fieldContext_AuthResponse_expires_in(ctx, field) + case "user": + return ec.fieldContext_AuthResponse_user(ctx, field) + case "authenticator_scanner_image": + return ec.fieldContext_AuthResponse_authenticator_scanner_image(ctx, field) + case "authenticator_secret": + return ec.fieldContext_AuthResponse_authenticator_secret(ctx, field) + case "authenticator_recovery_codes": + return ec.fieldContext_AuthResponse_authenticator_recovery_codes(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type AuthResponse", field.Name) + }, + } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Mutation_skip_mfa_setup_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + +func (ec *executionContext) _Mutation_lock_mfa(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_Mutation_lock_mfa(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) { + ctx = rctx // use context from middleware stack in children + return ec.resolvers.Mutation().LockMfa(rctx, fc.Args["params"].(model.LockMfaRequest)) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(*model.Response) + fc.Result = res + return ec.marshalNResponse2ᚖgithubᚗcomᚋauthorizerdevᚋauthorizerᚋinternalᚋgraphᚋmodelᚐResponse(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_Mutation_lock_mfa(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Mutation", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "message": + return ec.fieldContext_Response_message(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type Response", field.Name) + }, + } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Mutation_lock_mfa_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + +func (ec *executionContext) _Mutation_email_otp_mfa_setup(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_Mutation_email_otp_mfa_setup(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) { + ctx = rctx // use context from middleware stack in children + return ec.resolvers.Mutation().EmailOtpMfaSetup(rctx, fc.Args["params"].(*model.OtpMfaSetupRequest)) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(*model.Response) + fc.Result = res + return ec.marshalNResponse2ᚖgithubᚗcomᚋauthorizerdevᚋauthorizerᚋinternalᚋgraphᚋmodelᚐResponse(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_Mutation_email_otp_mfa_setup(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Mutation", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "message": + return ec.fieldContext_Response_message(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type Response", field.Name) + }, + } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Mutation_email_otp_mfa_setup_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + +func (ec *executionContext) _Mutation_sms_otp_mfa_setup(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_Mutation_sms_otp_mfa_setup(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) { + ctx = rctx // use context from middleware stack in children + return ec.resolvers.Mutation().SmsOtpMfaSetup(rctx, fc.Args["params"].(*model.OtpMfaSetupRequest)) }) if err != nil { ec.Error(ctx, err) @@ -17154,7 +17840,7 @@ func (ec *executionContext) _Mutation_skip_mfa_setup(ctx context.Context, field return ec.marshalNResponse2ᚖgithubᚗcomᚋauthorizerdevᚋauthorizerᚋinternalᚋgraphᚋmodelᚐResponse(ctx, field.Selections, res) } -func (ec *executionContext) fieldContext_Mutation_skip_mfa_setup(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { +func (ec *executionContext) fieldContext_Mutation_sms_otp_mfa_setup(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { fc = &graphql.FieldContext{ Object: "Mutation", Field: field, @@ -17168,6 +17854,108 @@ func (ec *executionContext) fieldContext_Mutation_skip_mfa_setup(_ context.Conte return nil, fmt.Errorf("no field named %q was found under type Response", field.Name) }, } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Mutation_sms_otp_mfa_setup_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + +func (ec *executionContext) _Mutation_totp_mfa_setup(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_Mutation_totp_mfa_setup(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) { + ctx = rctx // use context from middleware stack in children + return ec.resolvers.Mutation().TotpMfaSetup(rctx, fc.Args["params"].(*model.OtpMfaSetupRequest)) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(*model.AuthResponse) + fc.Result = res + return ec.marshalNAuthResponse2ᚖgithubᚗcomᚋauthorizerdevᚋauthorizerᚋinternalᚋgraphᚋmodelᚐAuthResponse(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_Mutation_totp_mfa_setup(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Mutation", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "message": + return ec.fieldContext_AuthResponse_message(ctx, field) + case "should_show_email_otp_screen": + return ec.fieldContext_AuthResponse_should_show_email_otp_screen(ctx, field) + case "should_show_mobile_otp_screen": + return ec.fieldContext_AuthResponse_should_show_mobile_otp_screen(ctx, field) + case "should_show_totp_screen": + return ec.fieldContext_AuthResponse_should_show_totp_screen(ctx, field) + case "should_offer_webauthn_mfa_verify": + return ec.fieldContext_AuthResponse_should_offer_webauthn_mfa_verify(ctx, field) + case "should_offer_mfa_setup": + return ec.fieldContext_AuthResponse_should_offer_mfa_setup(ctx, field) + case "should_offer_webauthn_mfa_setup": + return ec.fieldContext_AuthResponse_should_offer_webauthn_mfa_setup(ctx, field) + case "should_offer_email_otp_mfa_setup": + return ec.fieldContext_AuthResponse_should_offer_email_otp_mfa_setup(ctx, field) + case "should_offer_sms_otp_mfa_setup": + return ec.fieldContext_AuthResponse_should_offer_sms_otp_mfa_setup(ctx, field) + case "access_token": + return ec.fieldContext_AuthResponse_access_token(ctx, field) + case "id_token": + return ec.fieldContext_AuthResponse_id_token(ctx, field) + case "refresh_token": + return ec.fieldContext_AuthResponse_refresh_token(ctx, field) + case "expires_in": + return ec.fieldContext_AuthResponse_expires_in(ctx, field) + case "user": + return ec.fieldContext_AuthResponse_user(ctx, field) + case "authenticator_scanner_image": + return ec.fieldContext_AuthResponse_authenticator_scanner_image(ctx, field) + case "authenticator_secret": + return ec.fieldContext_AuthResponse_authenticator_secret(ctx, field) + case "authenticator_recovery_codes": + return ec.fieldContext_AuthResponse_authenticator_recovery_codes(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type AuthResponse", field.Name) + }, + } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Mutation_totp_mfa_setup_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } return fc, nil } @@ -17399,6 +18187,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 +18472,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 +24144,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 +24263,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 +24594,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 +28581,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 +29002,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 +29221,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 +34332,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 +34799,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 +35354,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 +36483,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 +36588,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 +37202,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 +38535,34 @@ func (ec *executionContext) _Mutation(ctx context.Context, sel ast.SelectionSet) if out.Values[i] == graphql.Null { out.Invalids++ } + case "lock_mfa": + out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { + return ec._Mutation_lock_mfa(ctx, field) + }) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "email_otp_mfa_setup": + out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { + return ec._Mutation_email_otp_mfa_setup(ctx, field) + }) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "sms_otp_mfa_setup": + out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { + return ec._Mutation_sms_otp_mfa_setup(ctx, field) + }) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "totp_mfa_setup": + out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { + return ec._Mutation_totp_mfa_setup(ctx, field) + }) + if out.Values[i] == graphql.Null { + out.Invalids++ + } case "webauthn_registration_options": out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { return ec._Mutation_webauthn_registration_options(ctx, field) @@ -39755,6 +40756,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 +42432,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 +42945,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 +44074,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..95db4507 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,41 @@ type Mutation { revoke(params: OAuthRevokeRequest!): Response! verify_otp(params: VerifyOTPRequest!): AuthResponse! resend_otp(params: ResendOTPRequest!): Response! - # skip_mfa_setup records that the authenticated caller explicitly declined - # the optional MFA setup prompt. Fails with FAILED_PRECONDITION if MFA is - # organization-enforced (enforce-mfa) — enforcement is never skippable. - skip_mfa_setup: Response! + # skip_mfa_setup completes an in-progress, token-withheld MFA offer by + # recording that the caller explicitly declined it, then issues the + # access token that was withheld. Identified by the MFA session cookie + # (set when the offer screen was returned) plus email/phone_number to + # resolve the pending user — same identification pattern as verify_otp. + # Fails with FAILED_PRECONDITION if MFA is organization-enforced + # (enforce-mfa) — enforcement is never skippable. + skip_mfa_setup(params: SkipMfaSetupRequest!): AuthResponse! + # lock_mfa records that the caller lost access to their only MFA + # factor(s). Only allowed when the caller has NO verified Email/SMS OTP + # fallback enrolled — if one exists, use it instead of locking. Does not + # issue a token; the account requires admin recovery afterward. + lock_mfa(params: LockMfaRequest!): Response! + # email_otp_mfa_setup sends a one-time code to the caller's own email and + # creates an unverified email-OTP MFA enrollment. Dual-mode permissions: + # (a) an authenticated caller (bearer token) — the settings-screen action + # for an ALREADY-logged-in user adding a second factor; params is unused + # in this mode. (b) a caller in the withheld first-time-offer state, with + # no bearer token yet — identified by the MFA session cookie plus + # params.email/phone_number, same pattern as skip_mfa_setup. Either mode + # reuses the same underlying Authenticator row once verify_otp marks it + # verified. + email_otp_mfa_setup(params: OtpMfaSetupRequest): Response! + # sms_otp_mfa_setup sends a one-time code to the caller's own phone number + # and creates an unverified SMS-OTP MFA enrollment. Same dual-mode + # permissions and relationship to verify_otp as email_otp_mfa_setup. + sms_otp_mfa_setup(params: OtpMfaSetupRequest): Response! + # totp_mfa_setup generates a fresh TOTP secret/QR/recovery-codes for the + # caller to enroll as an MFA method. Same dual-mode permissions as + # email_otp_mfa_setup/sms_otp_mfa_setup. Unlike those, nothing is sent + # anywhere - the enrollment payload is returned directly (same + # authenticator_scanner_image/authenticator_secret/authenticator_recovery_codes + # fields the login/signup gate response uses) so the caller scans the + # QR/enters the code, then completes enrollment via verify_otp(is_totp: true). + totp_mfa_setup(params: OtpMfaSetupRequest): AuthResponse! # WebAuthn / passkey self-service ceremonies (no admin `_` prefix). webauthn_registration_options( email: String diff --git a/internal/graph/schema.resolvers.go b/internal/graph/schema.resolvers.go index fba97056..2b576f7e 100644 --- a/internal/graph/schema.resolvers.go +++ b/internal/graph/schema.resolvers.go @@ -83,8 +83,28 @@ func (r *mutationResolver) ResendOtp(ctx context.Context, params model.ResendOTP } // SkipMfaSetup is the resolver for the skip_mfa_setup field. -func (r *mutationResolver) SkipMfaSetup(ctx context.Context) (*model.Response, error) { - return r.GraphQLProvider.SkipMFASetup(ctx) +func (r *mutationResolver) SkipMfaSetup(ctx context.Context, params model.SkipMfaSetupRequest) (*model.AuthResponse, error) { + return r.GraphQLProvider.SkipMFASetup(ctx, ¶ms) +} + +// LockMfa is the resolver for the lock_mfa field. +func (r *mutationResolver) LockMfa(ctx context.Context, params model.LockMfaRequest) (*model.Response, error) { + return r.GraphQLProvider.LockMFA(ctx, ¶ms) +} + +// EmailOtpMfaSetup is the resolver for the email_otp_mfa_setup field. +func (r *mutationResolver) EmailOtpMfaSetup(ctx context.Context, params *model.OtpMfaSetupRequest) (*model.Response, error) { + return r.GraphQLProvider.EmailOTPMFASetup(ctx, params) +} + +// SmsOtpMfaSetup is the resolver for the sms_otp_mfa_setup field. +func (r *mutationResolver) SmsOtpMfaSetup(ctx context.Context, params *model.OtpMfaSetupRequest) (*model.Response, error) { + return r.GraphQLProvider.SMSOTPMFASetup(ctx, params) +} + +// TotpMfaSetup is the resolver for the totp_mfa_setup field. +func (r *mutationResolver) TotpMfaSetup(ctx context.Context, params *model.OtpMfaSetupRequest) (*model.AuthResponse, error) { + return r.GraphQLProvider.TOTPMFASetup(ctx, params) } // WebauthnRegistrationOptions is the resolver for the webauthn_registration_options field. diff --git a/internal/graphql/lock_mfa.go b/internal/graphql/lock_mfa.go new file mode 100644 index 00000000..77d6cd27 --- /dev/null +++ b/internal/graphql/lock_mfa.go @@ -0,0 +1,28 @@ +// internal/graphql/lock_mfa.go +package graphql + +import ( + "context" + + "github.com/authorizerdev/authorizer/internal/graph/model" + "github.com/authorizerdev/authorizer/internal/metrics" + "github.com/authorizerdev/authorizer/internal/service" + "github.com/authorizerdev/authorizer/internal/utils" +) + +// LockMFA delegates to the transport-agnostic service layer. +// Permissions: none. +func (g *graphqlProvider) LockMFA(ctx context.Context, params *model.LockMfaRequest) (*model.Response, error) { + gc, err := utils.GinContextFromContext(ctx) + if err != nil { + g.Log.Debug().Err(err).Msg("failed to get gin context") + metrics.RecordSecurityEvent(metrics.SecurityEventGinContextMissing, "graphql") + return nil, err + } + res, side, err := g.ServiceProvider.LockMFA(ctx, service.MetaFromGin(gc), params) + if err != nil { + return nil, err + } + service.ApplyToGin(gc, side) + return res, nil +} diff --git a/internal/graphql/otp_mfa_setup.go b/internal/graphql/otp_mfa_setup.go new file mode 100644 index 00000000..ac51ca60 --- /dev/null +++ b/internal/graphql/otp_mfa_setup.go @@ -0,0 +1,62 @@ +// internal/graphql/otp_mfa_setup.go +package graphql + +import ( + "context" + + "github.com/authorizerdev/authorizer/internal/graph/model" + "github.com/authorizerdev/authorizer/internal/metrics" + "github.com/authorizerdev/authorizer/internal/service" + "github.com/authorizerdev/authorizer/internal/utils" +) + +// EmailOTPMFASetup delegates to the transport-agnostic service layer. +// Permissions: authenticated user (bearer token) OR MFA session cookie. +func (g *graphqlProvider) EmailOTPMFASetup(ctx context.Context, params *model.OtpMfaSetupRequest) (*model.Response, error) { + gc, err := utils.GinContextFromContext(ctx) + if err != nil { + g.Log.Debug().Err(err).Msg("failed to get gin context") + metrics.RecordSecurityEvent(metrics.SecurityEventGinContextMissing, "graphql") + return nil, err + } + res, side, err := g.ServiceProvider.EmailOTPMFASetup(ctx, service.MetaFromGin(gc), params) + if err != nil { + return nil, err + } + service.ApplyToGin(gc, side) + return res, nil +} + +// SMSOTPMFASetup delegates to the transport-agnostic service layer. +// Permissions: authenticated user (bearer token) OR MFA session cookie. +func (g *graphqlProvider) SMSOTPMFASetup(ctx context.Context, params *model.OtpMfaSetupRequest) (*model.Response, error) { + gc, err := utils.GinContextFromContext(ctx) + if err != nil { + g.Log.Debug().Err(err).Msg("failed to get gin context") + metrics.RecordSecurityEvent(metrics.SecurityEventGinContextMissing, "graphql") + return nil, err + } + res, side, err := g.ServiceProvider.SMSOTPMFASetup(ctx, service.MetaFromGin(gc), params) + if err != nil { + return nil, err + } + service.ApplyToGin(gc, side) + return res, nil +} + +// TOTPMFASetup delegates to the transport-agnostic service layer. +// Permissions: authenticated user (bearer token) OR MFA session cookie. +func (g *graphqlProvider) TOTPMFASetup(ctx context.Context, params *model.OtpMfaSetupRequest) (*model.AuthResponse, error) { + gc, err := utils.GinContextFromContext(ctx) + if err != nil { + g.Log.Debug().Err(err).Msg("failed to get gin context") + metrics.RecordSecurityEvent(metrics.SecurityEventGinContextMissing, "graphql") + return nil, err + } + res, side, err := g.ServiceProvider.TOTPMFASetup(ctx, service.MetaFromGin(gc), params) + if err != nil { + return nil, err + } + service.ApplyToGin(gc, side) + return res, nil +} diff --git a/internal/graphql/provider.go b/internal/graphql/provider.go index 11d7ea71..5eaf3b9c 100644 --- a/internal/graphql/provider.go +++ b/internal/graphql/provider.go @@ -96,9 +96,30 @@ type Provider interface { // DeactivateAccount is the method to deactivate account. // Permissions: authorized user DeactivateAccount(ctx context.Context) (*model.Response, error) - // SkipMFASetup is the method to skip optional MFA setup. - // Permissions: authorized user - SkipMFASetup(ctx context.Context) (*model.Response, error) + // SkipMFASetup completes a token-withheld first-time MFA offer by + // recording the decline and issuing the previously-withheld token. + // Permissions: none — identified via the MFA session cookie, not a + // bearer token. + SkipMFASetup(ctx context.Context, params *model.SkipMfaSetupRequest) (*model.AuthResponse, error) + // LockMFA records that the caller lost access to their only MFA + // factor(s), refusing when a verified OTP fallback exists. Does not + // issue a token. + // Permissions: none — identified via the MFA session cookie, not a + // bearer token. + LockMFA(ctx context.Context, params *model.LockMfaRequest) (*model.Response, error) + // EmailOTPMFASetup sends a one-time code to the caller's own email and + // begins an email-OTP MFA enrollment. Verified via VerifyOtp. Dual-mode: + // bearer token (params ignored) or, absent a token, the MFA session + // cookie plus params.email/phone_number. + // Permissions: authorized user (bearer token) OR MFA session cookie. + EmailOTPMFASetup(ctx context.Context, params *model.OtpMfaSetupRequest) (*model.Response, error) + // SMSOTPMFASetup is EmailOTPMFASetup's SMS twin. + // Permissions: authorized user (bearer token) OR MFA session cookie. + SMSOTPMFASetup(ctx context.Context, params *model.OtpMfaSetupRequest) (*model.Response, error) + // TOTPMFASetup generates a fresh TOTP secret/QR/recovery-codes for the + // caller to enroll as an MFA method. Same dual-mode permissions as + // EmailOTPMFASetup/SMSOTPMFASetup. + TOTPMFASetup(ctx context.Context, params *model.OtpMfaSetupRequest) (*model.AuthResponse, error) // DeleteEmailTemplate is the method to delete email template. // Permissions: authorizer:admin DeleteEmailTemplate(ctx context.Context, params *model.DeleteEmailTemplateRequest) (*model.Response, error) 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/http_handlers/oauth_sso.go b/internal/http_handlers/oauth_sso.go index 885b2320..b753acb2 100644 --- a/internal/http_handlers/oauth_sso.go +++ b/internal/http_handlers/oauth_sso.go @@ -269,6 +269,16 @@ func (h *httpProvider) SSOCallbackHandler() gin.HandlerFunc { ssoFail(c, &log, "sso_not_configured", "connection unavailable") return } + // SSOLoginHandler checked org.Enabled at dispatch time (resolveActiveOIDCConnection), + // but the state TTL window (~30-90s) gives an admin time to disable the org + // before the callback lands. Re-check here so an in-flight login into a + // just-disabled org cannot complete. + org, err := h.StorageProvider.GetOrganizationByID(ctx, conn.OrgID) + if err != nil || org == nil || !org.Enabled { + log.Debug().Err(err).Str("org", slug).Msg("organization disabled or missing since login was initiated") + ssoFail(c, &log, "sso_not_configured", "organization disabled") + return + } secret, err := crypto.DecryptAES(h.Config.ClientSecret, conn.SSOClientSecretEnc) if err != nil { log.Debug().Err(err).Msg("failed to decrypt upstream client secret") diff --git a/internal/http_handlers/oauth_sso_callback_test.go b/internal/http_handlers/oauth_sso_callback_test.go index 97787824..e05bd632 100644 --- a/internal/http_handlers/oauth_sso_callback_test.go +++ b/internal/http_handlers/oauth_sso_callback_test.go @@ -1,7 +1,9 @@ package http_handlers import ( + "context" "encoding/json" + "errors" "net/http" "net/http/httptest" "testing" @@ -12,7 +14,10 @@ import ( "github.com/stretchr/testify/require" "github.com/authorizerdev/authorizer/internal/config" + "github.com/authorizerdev/authorizer/internal/constants" "github.com/authorizerdev/authorizer/internal/memory_store" + "github.com/authorizerdev/authorizer/internal/storage" + "github.com/authorizerdev/authorizer/internal/storage/schemas" ) // ssoFakeStore serves a single preset flow entry via GetAndRemoveState and @@ -97,3 +102,91 @@ func TestSSOCallback_MixupIssParamRejected(t *testing.T) { require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body)) assert.Equal(t, "invalid_issuer", body["error"]) } + +// ssoOrgStore is a minimal storage.Provider stub serving only what +// SSOCallbackHandler's connection/org lookups touch: GetTrustedIssuerByID and +// GetOrganizationByID. Every other method panics via the embedded nil. +type ssoOrgStore struct { + storage.Provider + conn *schemas.TrustedIssuer + org *schemas.Organization +} + +func (s *ssoOrgStore) GetTrustedIssuerByID(_ context.Context, id string) (*schemas.TrustedIssuer, error) { + if s.conn != nil && s.conn.ID == id { + return s.conn, nil + } + return nil, errors.New("not found") +} + +func (s *ssoOrgStore) GetOrganizationByID(_ context.Context, id string) (*schemas.Organization, error) { + if s.org != nil && s.org.ID == id { + return s.org, nil + } + return nil, errors.New("not found") +} + +// REGRESSION (org-disabled mid-flow race): SSOLoginHandler checks org.Enabled +// only at dispatch time (resolveActiveOIDCConnection). The callback re-fetches +// the connection by ID directly and, before this fix, never re-checked the +// owning org — so a login started before an admin disables the org still +// completed successfully within the state TTL window. Simulate that race: +// seed a flow (as SSOLoginHandler would have), then disable the org "mid-flow" +// via storage before the callback lands, and confirm the callback rejects +// instead of proceeding to code exchange / session issuance. +func TestSSOCallback_OrgDisabledMidFlightRejected(t *testing.T) { + flow := ssoFlowState{ + ConnID: "conn-1", + OrgID: "org-1", + OrgSlug: "acme", + ExpectedIssuer: ssoTestIssuer, + } + raw, _ := json.Marshal(flow) + memStore := &ssoFakeStore{entries: map[string]string{ssoFlowPrefix + "s3": string(raw)}} + + conn := &schemas.TrustedIssuer{ID: "conn-1", OrgID: "org-1", Kind: constants.TrustKindSSOOIDC, IsActive: true} + // Org was enabled when SSOLoginHandler dispatched the flow, but an admin + // disabled it before the callback arrived. + org := &schemas.Organization{ID: "org-1", Name: "acme", Enabled: false} + + h := newSSOCallbackProvider(memStore) + h.StorageProvider = &ssoOrgStore{conn: conn, org: org} + + c, rec := callbackCtx("acme", "state=s3&code=abc") + h.SSOCallbackHandler()(c) + + assert.Equal(t, http.StatusBadRequest, rec.Code, "a disabled org must reject the callback, not issue a session") + var body map[string]string + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body)) + assert.Equal(t, "sso_not_configured", body["error"]) +} + +// Sanity counterpart: the same setup with the org still enabled must pass the +// org-enabled gate and proceed past it (fails later on the secret decrypt +// step since conn.SSOClientSecretEnc is empty here — that's fine, it proves +// the org check itself did not fire). +func TestSSOCallback_OrgEnabledPassesOrgGate(t *testing.T) { + flow := ssoFlowState{ + ConnID: "conn-1", + OrgID: "org-1", + OrgSlug: "acme", + ExpectedIssuer: ssoTestIssuer, + } + raw, _ := json.Marshal(flow) + memStore := &ssoFakeStore{entries: map[string]string{ssoFlowPrefix + "s4": string(raw)}} + + conn := &schemas.TrustedIssuer{ID: "conn-1", OrgID: "org-1", Kind: constants.TrustKindSSOOIDC, IsActive: true} + org := &schemas.Organization{ID: "org-1", Name: "acme", Enabled: true} + + h := newSSOCallbackProvider(memStore) + h.StorageProvider = &ssoOrgStore{conn: conn, org: org} + h.Config = &config.Config{} + + c, rec := callbackCtx("acme", "state=s4&code=abc") + h.SSOCallbackHandler()(c) + + assert.Equal(t, http.StatusBadRequest, rec.Code) + var body map[string]string + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body)) + assert.NotEqual(t, "sso_not_configured", body["error"], "an enabled org must not be rejected by the org gate") +} diff --git a/internal/http_handlers/oauth_sso_jit_test.go b/internal/http_handlers/oauth_sso_jit_test.go index d82c4050..58b3a91f 100644 --- a/internal/http_handlers/oauth_sso_jit_test.go +++ b/internal/http_handlers/oauth_sso_jit_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "testing" + "time" "github.com/golang-jwt/jwt/v4" "github.com/google/uuid" @@ -196,3 +197,25 @@ func TestSSOJIT_SignupDisabledRejected(t *testing.T) { require.Error(t, err) assert.Nil(t, user) } + +// REGRESSION: a revoked user reaching the SSO callback via the RETURNING-user +// path (resolved through an existing FederatedIdentity row, not first-time JIT +// provisioning) must be rejected, not silently handed a session. Confirms +// jitProvisionFederatedUser's RevokedTimestamp check fires on this path too. +func TestSSOJIT_RevokedReturningUserRejected(t *testing.T) { + store := newJITStore() + revokedAt := time.Now().Unix() + user := &schemas.User{ID: "revoked-user", Email: refs.NewStringRef("evicted@corp.example.com"), RevokedTimestamp: &revokedAt} + store.usersByID[user.ID] = user + store.usersByEmail["evicted@corp.example.com"] = user + store.federated[fedKey("org-1", ssoTestIssuer, "upstream-revoked")] = &schemas.FederatedIdentity{ + OrgID: "org-1", Issuer: ssoTestIssuer, Subject: "upstream-revoked", UserID: user.ID, + } + + h := newJITProvider(store, true) + got, isSignUp, err := h.jitProvisionSSOUser(context.Background(), jitFlow(), jitClaims("upstream-revoked", "evicted@corp.example.com")) + require.Error(t, err) + assert.Nil(t, got) + assert.False(t, isSignUp) + assert.Contains(t, err.Error(), "revoked") +} diff --git a/internal/http_handlers/oauth_sso_login_test.go b/internal/http_handlers/oauth_sso_login_test.go new file mode 100644 index 00000000..55677b21 --- /dev/null +++ b/internal/http_handlers/oauth_sso_login_test.go @@ -0,0 +1,126 @@ +package http_handlers + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/rs/zerolog" + "github.com/stretchr/testify/assert" + + "github.com/authorizerdev/authorizer/internal/config" + "github.com/authorizerdev/authorizer/internal/storage" + "github.com/authorizerdev/authorizer/internal/storage/schemas" +) + +// ssoLoginStore is a minimal storage.Provider stub serving only what +// resolveActiveOIDCConnection touches: GetOrganizationByName and +// GetTrustedIssuerByOrgIDAndKind. Every other method panics via the embedded nil. +type ssoLoginStore struct { + storage.Provider + orgsByName map[string]*schemas.Organization + connsByOrg map[string]*schemas.TrustedIssuer +} + +func (s *ssoLoginStore) GetOrganizationByName(_ context.Context, name string) (*schemas.Organization, error) { + if org, ok := s.orgsByName[name]; ok { + return org, nil + } + return nil, errors.New("not found") +} + +func (s *ssoLoginStore) GetTrustedIssuerByOrgIDAndKind(_ context.Context, orgID, _ string) (*schemas.TrustedIssuer, error) { + if conn, ok := s.connsByOrg[orgID]; ok { + return conn, nil + } + return nil, errors.New("not found") +} + +func newSSOLoginProvider(store *ssoLoginStore) *httpProvider { + logger := zerolog.Nop() + return &httpProvider{ + Config: &config.Config{}, + // SSOLoginHandler checks MemoryStoreProvider != nil before resolving the + // connection; an empty fake state store is enough to clear that gate for + // these org-resolution tests (SetState is only reached on the success path). + Dependencies: Dependencies{Log: &logger, StorageProvider: store, MemoryStoreProvider: &ssoFakeStore{entries: map[string]string{}}}, + } +} + +func loginCtx(orgSlug, query string) (*gin.Context, *httptest.ResponseRecorder) { + gin.SetMode(gin.TestMode) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodGet, "/oauth/sso/"+orgSlug+"/login?"+query, nil) + c.Params = gin.Params{{Key: "org_slug", Value: orgSlug}} + return c, rec +} + +// validSSORedirectQuery is a redirect_uri that passes IsValidRedirectURI against +// the default (wildcard) AllowedOrigins: httptest.NewRequest defaults Host to +// "example.com", and the wildcard rule restricts redirects to the server's own +// hostname. +const validSSORedirectQuery = "redirect_uri=http%3A%2F%2Fexample.com%2Fapp%2Fcallback&state=xyz" + +// An unknown org slug must reject with an error response, never redirect to an +// upstream IdP. +func TestSSOLogin_UnknownOrgRejected(t *testing.T) { + store := &ssoLoginStore{orgsByName: map[string]*schemas.Organization{}} + h := newSSOLoginProvider(store) + c, rec := loginCtx("no-such-org", validSSORedirectQuery) + h.SSOLoginHandler()(c) + + assert.Equal(t, http.StatusNotFound, rec.Code) + assert.NotEqual(t, http.StatusTemporaryRedirect, rec.Code) +} + +// A disabled org must reject with an error response, never redirect to an +// upstream IdP — this is the same org.Enabled gate SSOCallbackHandler now +// re-checks mid-flow; here it's the initiation-time check that was already +// correct (resolveActiveOIDCConnection). +func TestSSOLogin_DisabledOrgRejected(t *testing.T) { + store := &ssoLoginStore{ + orgsByName: map[string]*schemas.Organization{ + "acme": {ID: "org-1", Name: "acme", Enabled: false}, + }, + } + h := newSSOLoginProvider(store) + c, rec := loginCtx("acme", validSSORedirectQuery) + h.SSOLoginHandler()(c) + + assert.Equal(t, http.StatusForbidden, rec.Code) + assert.NotEqual(t, http.StatusTemporaryRedirect, rec.Code) +} + +// An enabled org with no active sso_oidc connection must also reject, not +// redirect (distinguishes "org disabled" from "SSO not configured"). +func TestSSOLogin_EnabledOrgNoConnectionRejected(t *testing.T) { + store := &ssoLoginStore{ + orgsByName: map[string]*schemas.Organization{ + "acme": {ID: "org-1", Name: "acme", Enabled: true}, + }, + connsByOrg: map[string]*schemas.TrustedIssuer{}, + } + h := newSSOLoginProvider(store) + c, rec := loginCtx("acme", validSSORedirectQuery) + h.SSOLoginHandler()(c) + + assert.Equal(t, http.StatusNotFound, rec.Code) + assert.NotEqual(t, http.StatusTemporaryRedirect, rec.Code) +} + +// An enabled org with an active connection passes the resolveActiveOIDCConnection +// gate and proceeds to upstream discovery, which fails fast here since +// conn.IssuerURL is not a real reachable IdP — proving the org/connection gate +// itself did not reject the request. +func TestSSOLogin_MissingRedirectURIRejected(t *testing.T) { + store := &ssoLoginStore{orgsByName: map[string]*schemas.Organization{}} + h := newSSOLoginProvider(store) + c, rec := loginCtx("acme", "state=xyz") + h.SSOLoginHandler()(c) + + assert.Equal(t, http.StatusBadRequest, rec.Code) +} diff --git a/internal/http_handlers/saml_sp_verify_test.go b/internal/http_handlers/saml_sp_verify_test.go index b0a3906d..ebc7e0b6 100644 --- a/internal/http_handlers/saml_sp_verify_test.go +++ b/internal/http_handlers/saml_sp_verify_test.go @@ -284,6 +284,20 @@ func TestSAML_WrongRecipientRejected(t *testing.T) { require.Error(t, err) } +// Destination (the attribute) is checked independently of +// SubjectConfirmationData/Recipient — a response signed for a *different* +// org's ACS URL but replayed at org A, with Recipient left correct, must +// still be rejected. Closes a coverage gap: TestSAML_WrongRecipientRejected +// only varied Recipient, never isolated Destination. +func TestSAML_WrongDestinationRejected(t *testing.T) { + idp := newSAMLIdP(t) + p := defaultAssertionParams() + p.destination = "https://auth.example.com/oauth/saml/other/acs" + raw := makeSAMLResponse(t, idp, p) + _, err := parseWith(t, samlTestConn(idp), raw, []string{samlTestRequestID}) + require.Error(t, err) +} + func TestSAML_ExpiredAssertionRejected(t *testing.T) { idp := newSAMLIdP(t) p := defaultAssertionParams() diff --git a/internal/http_handlers/token_exchange.go b/internal/http_handlers/token_exchange.go index 4453d462..cf46b102 100644 --- a/internal/http_handlers/token_exchange.go +++ b/internal/http_handlers/token_exchange.go @@ -129,6 +129,31 @@ func (h *httpProvider) handleTokenExchangeGrant(gc *gin.Context, agent *schemas. onBehalfOfType := constants.AuditActorTypeUser if lm, _ := subjectClaims["login_method"].(string); lm == constants.AuthRecipeMethodServiceAccount { onBehalfOfType = "agent" + // validateExchangeToken only checks the JWT's own signature/exp — it + // has no session-store lookup, so a service-account subject_token + // stays cryptographically valid until its natural expiry even after + // the account is deactivated. Without this check, a still-unexpired + // token from a just-deactivated service account could keep seeding + // fresh delegated tokens through a willing downstream agent, + // extending its effective lifetime past deactivation. Same + // fail-closed contract as the user branch below: a subject we + // cannot load or confirm active must not seed a delegation. + subjectClient, cErr := h.StorageProvider.GetClientByID(gc, subject) + if cErr != nil || subjectClient == nil { + log.Debug().Err(cErr).Msg("subject service account could not be verified") + gc.JSON(http.StatusBadRequest, gin.H{ + "error": "invalid_grant", + "error_description": "The subject could not be verified", + }) + return + } + if !subjectClient.IsActive { + gc.JSON(http.StatusBadRequest, gin.H{ + "error": "invalid_grant", + "error_description": "The subject is no longer active", + }) + return + } } else { user, uErr := h.StorageProvider.GetUserByID(gc, subject) if uErr != nil || user == nil { diff --git a/internal/http_handlers/verify_email.go b/internal/http_handlers/verify_email.go index e333b50c..2cd0125c 100644 --- a/internal/http_handlers/verify_email.go +++ b/internal/http_handlers/verify_email.go @@ -16,6 +16,7 @@ import ( "github.com/authorizerdev/authorizer/internal/metrics" "github.com/authorizerdev/authorizer/internal/parsers" "github.com/authorizerdev/authorizer/internal/refs" + "github.com/authorizerdev/authorizer/internal/service" "github.com/authorizerdev/authorizer/internal/storage/schemas" "github.com/authorizerdev/authorizer/internal/token" "github.com/authorizerdev/authorizer/internal/utils" @@ -82,6 +83,52 @@ func (h *httpProvider) VerifyEmailHandler() gin.HandlerFunc { return } + if user.RevokedTimestamp != nil { + log.Debug().Msg("User access has been revoked") + errorRes["error"] = "user access has been revoked" + utils.HandleRedirectORJsonResponse(c, http.StatusBadRequest, errorRes, generateRedirectURL(redirectURL, errorRes)) + return + } + + // Resolved once, early: needed both for the MFA-gate-withheld redirect + // below and the success redirect further down. + if redirectURL == "" { + redirectURL = claim["redirect_uri"].(string) + } + if !validators.IsValidRedirectURI(redirectURL, h.Config.AllowedOrigins, hostname) { + log.Debug().Msg("Invalid redirect URI in token claim") + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid redirect uri"}) + return + } + + // MFA gate: this REST endpoint is what the emailed verification/magic + // link literally points to, so it must enforce the same gate every + // other login entry point does (login.go/signup.go/oauth_callback.go). + // Previously this handler issued tokens unconditionally, bypassing MFA + // entirely for magic-link login and signup email verification - the + // GraphQL verify_email mutation's own gate fix (service.VerifyEmail) + // never covered this REST path since it's a separate implementation. + meta := service.MetaFromGin(c) + side := &service.ResponseSideEffects{} + withheld, redirectSuffix, gateErr := h.ServiceProvider.EvaluateMFAGateForOAuth(c, meta, side, user) + if gateErr != nil { + log.Debug().Err(gateErr).Msg("MFA gate rejected email verification") + errorRes["error"] = gateErr.Error() + utils.HandleRedirectORJsonResponse(c, http.StatusBadRequest, errorRes, generateRedirectURL(redirectURL, errorRes)) + return + } + if withheld { + service.ApplyToGin(c, side) + target := redirectURL + if strings.Contains(target, "?") { + target = target + "&" + redirectSuffix + } else { + target = target + "?" + strings.TrimPrefix(redirectSuffix, "&") + } + c.Redirect(http.StatusTemporaryRedirect, target) + return + } + isSignUp := false // update email_verified_at in users table if user.EmailVerifiedAt == nil { @@ -199,15 +246,6 @@ func (h *httpProvider) VerifyEmailHandler() gin.HandlerFunc { _ = h.MemoryStoreProvider.SetUserSession(sessionKey, constants.TokenTypeRefreshToken+"_"+authToken.FingerPrint, authToken.RefreshToken.Token, authToken.RefreshToken.ExpiresAt) } - if redirectURL == "" { - redirectURL = claim["redirect_uri"].(string) - } - if !validators.IsValidRedirectURI(redirectURL, h.Config.AllowedOrigins, hostname) { - log.Debug().Msg("Invalid redirect URI in token claim") - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid redirect uri"}) - return - } - if strings.Contains(redirectURL, "?") { redirectURL = redirectURL + "&" + params } else { diff --git a/internal/integration_tests/admin_reset_mfa_test.go b/internal/integration_tests/admin_reset_mfa_test.go new file mode 100644 index 00000000..5ae42d95 --- /dev/null +++ b/internal/integration_tests/admin_reset_mfa_test.go @@ -0,0 +1,104 @@ +package integration_tests + +import ( + "fmt" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/authorizerdev/authorizer/internal/constants" + "github.com/authorizerdev/authorizer/internal/crypto" + "github.com/authorizerdev/authorizer/internal/graph/model" + "github.com/authorizerdev/authorizer/internal/refs" + "github.com/authorizerdev/authorizer/internal/storage/schemas" +) + +// TestAdminResetMFA covers the _update_user{reset_mfa: true} admin recovery +// path: it must clear mfa_locked_at, is_multi_factor_auth_enabled, and +// has_skipped_mfa_setup_at, and delete every enrolled authenticator and +// webauthn credential row for the user. +func TestAdminResetMFA(t *testing.T) { + cfg := getTestConfig() + cfg.EnableMFA = true + cfg.EnableTOTPLogin = true + ts := initTestSetup(t, cfg) + req, ctx := createContext(ts) + + email := "admin_reset_mfa_" + uuid.NewString() + "@authorizer.dev" + password := "Password@123" + _, err := ts.GraphQLProvider.SignUp(ctx, &model.SignUpRequest{ + Email: &email, Password: password, ConfirmPassword: password, + }) + require.NoError(t, err) + // cfg.EnableMFA/EnableTOTPLogin are both on here, so SignUp itself now + // runs the same MFA gate as Login (Task 7): its response withholds the + // token and the User field (matching login.go's own mfaGateOfferAll/ + // BlockEnroll responses, which never set User either). Look the user up + // by email instead of relying on a User field that isn't there for this + // path. + signedUpUser, err := ts.StorageProvider.GetUserByEmail(ctx, email) + require.NoError(t, err) + userID := signedUpUser.ID + + // Put the user into the exact state reset_mfa is meant to unwind: locked, + // MFA enabled, skip recorded, plus a real TOTP authenticator row and a + // webauthn credential row. + now := time.Now().Unix() + user, err := ts.StorageProvider.GetUserByID(ctx, userID) + require.NoError(t, err) + user.MFALockedAt = &now + user.IsMultiFactorAuthEnabled = refs.NewBoolRef(true) + user.HasSkippedMFASetupAt = &now + _, err = ts.StorageProvider.UpdateUser(ctx, user) + require.NoError(t, err) + + _, err = ts.StorageProvider.AddAuthenticator(ctx, &schemas.Authenticator{ + UserID: userID, + Method: constants.EnvKeyTOTPAuthenticator, + Secret: "test-secret", + VerifiedAt: &now, + }) + require.NoError(t, err) + + _, err = ts.StorageProvider.AddWebauthnCredential(ctx, &schemas.WebauthnCredential{ + UserID: userID, + CredentialID: uuid.NewString(), + PublicKey: "test-public-key", + }) + require.NoError(t, err) + + // sanity-check the fixture actually landed before asserting the reset. + authenticator, err := ts.StorageProvider.GetAuthenticatorDetailsByUserId(ctx, userID, constants.EnvKeyTOTPAuthenticator) + require.NoError(t, err) + require.NotNil(t, authenticator) + creds, err := ts.StorageProvider.ListWebauthnCredentialsByUserID(ctx, userID) + require.NoError(t, err) + require.Len(t, creds, 1) + + h, err := crypto.EncryptPassword(cfg.AdminSecret) + require.NoError(t, err) + req.Header.Set("Cookie", fmt.Sprintf("%s=%s", constants.AdminCookieName, h)) + + updateRes, err := ts.GraphQLProvider.UpdateUser(ctx, &model.UpdateUserRequest{ + ID: userID, + ResetMfa: refs.NewBoolRef(true), + }) + require.NoError(t, err) + require.NotNil(t, updateRes) + + reset, err := ts.StorageProvider.GetUserByID(ctx, userID) + require.NoError(t, err) + assert.Nil(t, reset.MFALockedAt, "reset_mfa must clear mfa_locked_at") + assert.Nil(t, reset.IsMultiFactorAuthEnabled, "reset_mfa must clear is_multi_factor_auth_enabled") + assert.Nil(t, reset.HasSkippedMFASetupAt, "reset_mfa must clear has_skipped_mfa_setup_at") + + _, err = ts.StorageProvider.GetAuthenticatorDetailsByUserId(ctx, userID, constants.EnvKeyTOTPAuthenticator) + assert.Error(t, err, "reset_mfa must delete the user's authenticator rows") + + remainingCreds, err := ts.StorageProvider.ListWebauthnCredentialsByUserID(ctx, userID) + require.NoError(t, err) + assert.Empty(t, remainingCreds, "reset_mfa must delete the user's webauthn credentials") +} diff --git a/internal/integration_tests/admin_update_user_enforce_mfa_test.go b/internal/integration_tests/admin_update_user_enforce_mfa_test.go new file mode 100644 index 00000000..56b0509d --- /dev/null +++ b/internal/integration_tests/admin_update_user_enforce_mfa_test.go @@ -0,0 +1,53 @@ +package integration_tests + +import ( + "fmt" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/authorizerdev/authorizer/internal/constants" + "github.com/authorizerdev/authorizer/internal/crypto" + "github.com/authorizerdev/authorizer/internal/graph/model" + "github.com/authorizerdev/authorizer/internal/refs" + "github.com/authorizerdev/authorizer/internal/storage/schemas" +) + +// TestAdminUpdateUserEnforceMFA verifies EnforceMFA is absolute on the admin +// path: an admin cannot persist IsMultiFactorAuthEnabled=false while the org +// enforces MFA, matching the self-service update_profile.go guard. +func TestAdminUpdateUserEnforceMFA(t *testing.T) { + cfg := getTestConfig() + cfg.EnableMFA = true + cfg.EnableTOTPLogin = true + cfg.EnforceMFA = true + ts := initTestSetup(t, cfg) + req, ctx := createContext(ts) + + email := "admin_enforce_mfa_" + uuid.NewString() + "@authorizer.dev" + now := time.Now().Unix() + user, err := ts.StorageProvider.AddUser(ctx, &schemas.User{ + Email: refs.NewStringRef(email), + EmailVerifiedAt: &now, + SignupMethods: constants.AuthRecipeMethodBasicAuth, + IsMultiFactorAuthEnabled: refs.NewBoolRef(true), + }) + require.NoError(t, err) + + h, err := crypto.EncryptPassword(cfg.AdminSecret) + require.NoError(t, err) + req.Header.Set("Cookie", fmt.Sprintf("%s=%s", constants.AdminCookieName, h)) + + _, err = ts.GraphQLProvider.UpdateUser(ctx, &model.UpdateUserRequest{ + ID: user.ID, + IsMultiFactorAuthEnabled: refs.NewBoolRef(false), + }) + require.Error(t, err, "admin must not be able to disable MFA while EnforceMFA is on") + + persisted, err := ts.StorageProvider.GetUserByID(ctx, user.ID) + require.NoError(t, err) + assert.True(t, refs.BoolValue(persisted.IsMultiFactorAuthEnabled), "MFA must remain enabled after a rejected disable") +} diff --git a/internal/integration_tests/admin_users_grpc_test.go b/internal/integration_tests/admin_users_grpc_test.go index 51c95218..4a16d729 100644 --- a/internal/integration_tests/admin_users_grpc_test.go +++ b/internal/integration_tests/admin_users_grpc_test.go @@ -6,6 +6,7 @@ import ( "testing" "github.com/google/uuid" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "google.golang.org/grpc" "google.golang.org/grpc/codes" @@ -165,6 +166,29 @@ func TestAdminUpdateUserGRPC(t *testing.T) { _, err := client.UpdateUser(adminCtx(cfg.AdminSecret), &authorizerv1.UpdateUserRequest{Id: id}) require.Error(t, err) }) + + t.Run("reset_mfa clears locked/enabled/skipped MFA state", func(t *testing.T) { + now := int64(1) + user, err := ts.StorageProvider.AddUser(context.Background(), &schemas.User{ + Email: refs.NewStringRef("admin-users-grpc-reset-mfa-" + uuid.New().String() + "@authorizer.test"), + SignupMethods: constants.AuthRecipeMethodBasicAuth, + EmailVerifiedAt: &now, + IsMultiFactorAuthEnabled: refs.NewBoolRef(true), + MFALockedAt: &now, + HasSkippedMFASetupAt: &now, + }) + require.NoError(t, err) + + resp, err := client.UpdateUser(adminCtx(cfg.AdminSecret), &authorizerv1.UpdateUserRequest{ + Id: user.ID, + ResetMfa: refs.NewBoolRef(true), + }) + require.NoError(t, err) + require.NotNil(t, resp.User) + assert.False(t, resp.User.IsMultiFactorAuthEnabled) + assert.Zero(t, resp.User.MfaLockedAt) + assert.Zero(t, resp.User.HasSkippedMfaSetupAt) + }) } // TestAdminDeleteUserGRPC exercises AuthorizerAdminService.DeleteUser over gRPC: diff --git a/internal/integration_tests/fga_test.go b/internal/integration_tests/fga_test.go index cc124b59..631ab65f 100644 --- a/internal/integration_tests/fga_test.go +++ b/internal/integration_tests/fga_test.go @@ -68,7 +68,7 @@ func initFGATestSetup(t *testing.T, cfg *config.Config) (*testSetup, engine.Auth require.NoError(t, err) smsProvider, err := sms.New(cfg, &sms.Dependencies{Log: &logger}) require.NoError(t, err) - tokenProvider, err := token.New(cfg, &token.Dependencies{Log: &logger, MemoryStoreProvider: memoryStoreProvider}) + tokenProvider, err := token.New(cfg, &token.Dependencies{Log: &logger, MemoryStoreProvider: memoryStoreProvider, StorageProvider: storageProvider}) require.NoError(t, err) rateLimitProvider, err := rate_limit.New(cfg, &rate_limit.Dependencies{Log: &logger}) require.NoError(t, err) diff --git a/internal/integration_tests/grpc_mfa_gate_test.go b/internal/integration_tests/grpc_mfa_gate_test.go new file mode 100644 index 00000000..863527b2 --- /dev/null +++ b/internal/integration_tests/grpc_mfa_gate_test.go @@ -0,0 +1,364 @@ +package integration_tests + +import ( + "context" + "fmt" + "net" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/status" + "google.golang.org/grpc/test/bufconn" + + "github.com/authorizerdev/authorizer/internal/config" + "github.com/authorizerdev/authorizer/internal/constants" + "github.com/authorizerdev/authorizer/internal/grpcsrv" + "github.com/authorizerdev/authorizer/internal/refs" + "github.com/authorizerdev/authorizer/internal/storage/schemas" + + authorizerv1 "github.com/authorizerdev/authorizer/gen/go/authorizer/v1" +) + +// bootPublicClientForConfig is newPublicClient's sibling for tests that need +// to seed cfg (e.g. EnableMFA) before initTestSetup boots the service +// provider; newPublicClient always calls getTestConfig() internally so it +// can't be reused here. +func bootPublicClientForConfig(t *testing.T, cfg *config.Config) (authorizerv1.AuthorizerServiceClient, *testSetup) { + t.Helper() + ts := initTestSetup(t, cfg) + + srv, err := grpcsrv.New(":0", &grpcsrv.Dependencies{ + Log: ts.Logger, + Config: cfg, + ServiceProvider: ts.ServiceProvider, + TokenProvider: ts.TokenProvider, + }) + require.NoError(t, err) + + lis := bufconn.Listen(1 << 20) + t.Cleanup(func() { _ = lis.Close() }) + go func() { _ = srv.GRPCServer().Serve(lis) }() + t.Cleanup(srv.GRPCServer().GracefulStop) + + conn, err := grpc.NewClient( + "passthrough:///bufconn", + grpc.WithContextDialer(func(_ context.Context, _ string) (net.Conn, error) { return lis.Dial() }), + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + require.NoError(t, err) + t.Cleanup(func() { _ = conn.Close() }) + + return authorizerv1.NewAuthorizerServiceClient(conn), ts +} + +// mfaSessionCookieCtx builds an outgoing gRPC context carrying the mfa +// session cookie directly (no "cookie:" prefix parsing needed since the +// value already is "name=value"). +func mfaSessionCookieCtx(session string) context.Context { + return metadata.NewOutgoingContext(context.Background(), + metadata.Pairs("cookie", constants.MfaCookieName+"_session="+session)) +} + +// findSetCookie returns the first Set-Cookie header value (as captured via +// grpc.Header) whose cookie name matches, or "" if none match. Mirrors how +// TestSessionGRPCRequiresCookieOnly extracts the session cookie from gRPC +// response metadata. +func findSetCookie(cookies []string, name string) string { + prefix := name + "=" + for _, c := range cookies { + if len(c) >= len(prefix) && c[:len(prefix)] == prefix { + return c + } + } + return "" +} + +// TestSkipMfaSetupGRPC exercises the new SkipMfaSetup RPC end-to-end over +// gRPC: sign up + enable MFA, log in (token withheld behind an MFA session +// cookie), then prove skip_mfa_setup issues the withheld token and persists +// HasSkippedMfaSetupAt. Closes the REST/gRPC parity gap for the GraphQL-only +// skip_mfa_setup mutation added in PR #686. +func TestSkipMfaSetupGRPC(t *testing.T) { + cfg := getTestConfig() + cfg.EnableMFA = true + cfg.EnableTOTPLogin = true + c, ts := bootPublicClientForConfig(t, cfg) + ctx := context.Background() + + email := "grpc_skip_mfa_" + uuid.New().String() + "@authorizer.dev" + password := "Password@123" + + _, err := c.Signup(ctx, &authorizerv1.SignupRequest{ + Email: email, Password: password, ConfirmPassword: password, + }) + require.NoError(t, err) + + user, err := ts.StorageProvider.GetUserByEmail(ctx, email) + require.NoError(t, err) + user.IsMultiFactorAuthEnabled = refs.NewBoolRef(true) + _, err = ts.StorageProvider.UpdateUser(ctx, user) + require.NoError(t, err) + + var header metadata.MD + loginResp, err := c.Login(ctx, &authorizerv1.LoginRequest{Email: email, Password: password}, grpc.Header(&header)) + require.NoError(t, err) + require.Empty(t, loginResp.AccessToken, "first login with optional MFA and no prior enrollment must withhold the token") + require.True(t, loginResp.ShouldShowTotpScreen) + + mfaCookie := findSetCookie(header.Get("Set-Cookie"), constants.MfaCookieName+"_session") + require.NotEmpty(t, mfaCookie, "login must set an mfa session cookie via gRPC response metadata") + mfaCtx := metadata.NewOutgoingContext(context.Background(), metadata.Pairs("cookie", mfaCookie)) + + t.Run("skip issues the withheld token and persists HasSkippedMfaSetupAt", func(t *testing.T) { + resp, err := c.SkipMfaSetup(mfaCtx, &authorizerv1.SkipMfaSetupRequest{Email: email}) + require.NoError(t, err) + require.NotEmpty(t, resp.AccessToken, "skip must issue the token withheld at login") + + updated, err := ts.StorageProvider.GetUserByEmail(ctx, email) + require.NoError(t, err) + assert.NotNil(t, updated.HasSkippedMFASetupAt) + }) + + t.Run("without a valid mfa session cookie it is rejected, not Unimplemented", func(t *testing.T) { + _, err := c.SkipMfaSetup(context.Background(), &authorizerv1.SkipMfaSetupRequest{Email: "nobody@authorizer.dev"}) + require.Error(t, err) + st, ok := status.FromError(err) + require.True(t, ok) + assert.Equal(t, codes.Unauthenticated, st.Code()) + }) +} + +// TestLockMfaGRPC exercises the new LockMfa RPC end-to-end over gRPC: a +// caller with a valid mfa session cookie can lock their account (no verified +// OTP fallback enrolled), and a caller without one is rejected. +func TestLockMfaGRPC(t *testing.T) { + cfg := getTestConfig() + cfg.EnableMFA = true + c, ts := bootPublicClientForConfig(t, cfg) + ctx := context.Background() + + email := "grpc_lock_mfa_" + uuid.New().String() + "@authorizer.dev" + now := time.Now().Unix() + user, err := ts.StorageProvider.AddUser(ctx, &schemas.User{ + Email: refs.NewStringRef(email), + EmailVerifiedAt: &now, + SignupMethods: constants.AuthRecipeMethodBasicAuth, + IsMultiFactorAuthEnabled: refs.NewBoolRef(true), + }) + require.NoError(t, err) + + mfaSession := uuid.NewString() + require.NoError(t, ts.MemoryStoreProvider.SetMfaSession(user.ID, mfaSession, constants.MFASessionPurposeVerified, time.Now().Add(5*time.Minute).Unix())) + + t.Run("locks the account with a valid mfa session", func(t *testing.T) { + resp, err := c.LockMfa(mfaSessionCookieCtx(mfaSession), &authorizerv1.LockMfaRequest{Email: email}) + require.NoError(t, err) + assert.NotEmpty(t, resp.Message) + + updated, err := ts.StorageProvider.GetUserByEmail(ctx, email) + require.NoError(t, err) + assert.NotNil(t, updated.MFALockedAt) + }) + + t.Run("without a valid mfa session it is rejected, not Unimplemented", func(t *testing.T) { + _, err := c.LockMfa(context.Background(), &authorizerv1.LockMfaRequest{Email: email}) + require.Error(t, err) + st, ok := status.FromError(err) + require.True(t, ok) + assert.Equal(t, codes.Unauthenticated, st.Code()) + }) +} + +// TestEmailOtpMfaSetupGRPC exercises the new EmailOtpMfaSetup RPC end-to-end +// over gRPC, proving the transport correctly reaches both auth modes +// resolveOTPSetupCaller supports (already proven at the GraphQL layer by +// TestEmailOTPMFASetupViaMfaSessionCookie / TestOTPMFASetupRejectsUnauthenticatedCaller +// in otp_mfa_setup_test.go): the MFA-session-cookie + email fallback for a +// caller in the withheld first-time-offer state, and the ordinary bearer +// token used by an already-authenticated caller adding a second factor. +func TestEmailOtpMfaSetupGRPC(t *testing.T) { + cfg := getTestConfig() + cfg.EnableMFA = true + cfg.EnableTOTPLogin = true + cfg.EnableEmailOTP = true + cfg.SMTPHost = "localhost" + cfg.SMTPPort = 1025 + cfg.SMTPSenderEmail = "test@authorizer.dev" + cfg.SMTPSenderName = "Test" + cfg.SMTPLocalName = "Test" + cfg.SMTPSkipTLSVerification = true + cfg.IsEmailServiceEnabled = true + c, ts := bootPublicClientForConfig(t, cfg) + ctx := context.Background() + password := "Password@123" + + t.Run("cookie mode: mfa session cookie + email, no bearer token", func(t *testing.T) { + email := "grpc_email_otp_cookie_" + uuid.New().String() + "@authorizer.dev" + + _, err := c.Signup(ctx, &authorizerv1.SignupRequest{ + Email: email, Password: password, ConfirmPassword: password, + }) + require.NoError(t, err) + + user, err := ts.StorageProvider.GetUserByEmail(ctx, email) + require.NoError(t, err) + user.IsMultiFactorAuthEnabled = refs.NewBoolRef(true) + _, err = ts.StorageProvider.UpdateUser(ctx, user) + require.NoError(t, err) + + var header metadata.MD + loginResp, err := c.Login(ctx, &authorizerv1.LoginRequest{Email: email, Password: password}, grpc.Header(&header)) + require.NoError(t, err) + require.Empty(t, loginResp.AccessToken, "first login with optional MFA and no prior enrollment must withhold the token") + + mfaCookie := findSetCookie(header.Get("Set-Cookie"), constants.MfaCookieName+"_session") + require.NotEmpty(t, mfaCookie, "login must set an mfa session cookie via gRPC response metadata") + mfaCtx := metadata.NewOutgoingContext(context.Background(), metadata.Pairs("cookie", mfaCookie)) + + resp, err := c.EmailOtpMfaSetup(mfaCtx, &authorizerv1.EmailOtpMfaSetupRequest{Email: email}) + require.NoError(t, err, "email_otp_mfa_setup must be reachable via cookie+email with no bearer token") + require.NotEmpty(t, resp.Message) + + authenticator, err := ts.StorageProvider.GetAuthenticatorDetailsByUserId(ctx, user.ID, constants.EnvKeyEmailOTPAuthenticator) + require.NoError(t, err) + require.NotNil(t, authenticator) + assert.Nil(t, authenticator.VerifiedAt, "setup alone must not mark the enrollment verified") + }) + + t.Run("bearer-token mode: already authenticated caller, no email param", func(t *testing.T) { + email := "grpc_email_otp_bearer_" + uuid.New().String() + "@authorizer.dev" + + // Signup omits is_multi_factor_auth_enabled, which the gRPC handler + // still forwards explicitly as false (see AuthorizerHandler.Signup), + // so the MFA gate never engages here and the token is issued + // directly -- this caller reaches EmailOtpMfaSetup the ordinary + // already-logged-in "add a second factor from settings" way. + signupResp, err := c.Signup(ctx, &authorizerv1.SignupRequest{ + Email: email, Password: password, ConfirmPassword: password, + }) + require.NoError(t, err) + require.NotEmpty(t, signupResp.AccessToken, "no MFA enrolled -> token issued directly") + + user, err := ts.StorageProvider.GetUserByEmail(ctx, email) + require.NoError(t, err) + + resp, err := c.EmailOtpMfaSetup(bearerCtx(signupResp.AccessToken), &authorizerv1.EmailOtpMfaSetupRequest{}) + require.NoError(t, err, "email_otp_mfa_setup must be reachable via bearer token with no email param") + require.NotEmpty(t, resp.Message) + + authenticator, err := ts.StorageProvider.GetAuthenticatorDetailsByUserId(ctx, user.ID, constants.EnvKeyEmailOTPAuthenticator) + require.NoError(t, err) + require.NotNil(t, authenticator) + assert.Nil(t, authenticator.VerifiedAt, "setup alone must not mark the enrollment verified") + }) + + t.Run("without a valid token or cookie+email it is rejected, not Unimplemented", func(t *testing.T) { + _, err := c.EmailOtpMfaSetup(context.Background(), &authorizerv1.EmailOtpMfaSetupRequest{}) + require.Error(t, err) + st, ok := status.FromError(err) + require.True(t, ok) + assert.Equal(t, codes.Unauthenticated, st.Code()) + }) +} + +// TestSmsOtpMfaSetupGRPC is TestEmailOtpMfaSetupGRPC's SMS twin -- same two +// auth modes and rejection case, keyed by phone_number instead of email. +func TestSmsOtpMfaSetupGRPC(t *testing.T) { + cfg := getTestConfig() + cfg.EnableMFA = true + cfg.EnableSMSOTP = true + cfg.IsSMSServiceEnabled = true + cfg.EnableMobileBasicAuthentication = true + cfg.TwilioAPISecret = "test-twilio-api-secret" + cfg.TwilioAPIKey = "test-twilio-api-key" + cfg.TwilioAccountSID = "test-twilio-account-sid" + cfg.TwilioSender = "test-twilio-sender" + c, ts := bootPublicClientForConfig(t, cfg) + ctx := context.Background() + password := "Password@123" + + t.Run("cookie mode: mfa session cookie + phone_number, no bearer token", func(t *testing.T) { + mobile := fmt.Sprintf("+1%010d", time.Now().UnixNano()%10000000000) + + _, err := c.Signup(ctx, &authorizerv1.SignupRequest{ + PhoneNumber: mobile, Password: password, ConfirmPassword: password, + }) + require.NoError(t, err) + + user, err := ts.StorageProvider.GetUserByPhoneNumber(ctx, mobile) + require.NoError(t, err) + // Signup's own phone-verification OTP is irrelevant here; mark the + // phone verified directly so login reaches the MFA gate instead of + // the phone-verification challenge, same as the GraphQL-layer twin. + now := time.Now().Unix() + user.PhoneNumberVerifiedAt = &now + user.IsMultiFactorAuthEnabled = refs.NewBoolRef(true) + _, err = ts.StorageProvider.UpdateUser(ctx, user) + require.NoError(t, err) + + var header metadata.MD + loginResp, err := c.Login(ctx, &authorizerv1.LoginRequest{PhoneNumber: mobile, Password: password}, grpc.Header(&header)) + require.NoError(t, err) + require.Empty(t, loginResp.AccessToken, "first login with optional MFA and no prior enrollment must withhold the token") + + mfaCookie := findSetCookie(header.Get("Set-Cookie"), constants.MfaCookieName+"_session") + require.NotEmpty(t, mfaCookie, "login must set an mfa session cookie via gRPC response metadata") + mfaCtx := metadata.NewOutgoingContext(context.Background(), metadata.Pairs("cookie", mfaCookie)) + + resp, err := c.SmsOtpMfaSetup(mfaCtx, &authorizerv1.SmsOtpMfaSetupRequest{PhoneNumber: mobile}) + require.NoError(t, err, "sms_otp_mfa_setup must be reachable via cookie+phone_number with no bearer token") + require.NotEmpty(t, resp.Message) + + authenticator, err := ts.StorageProvider.GetAuthenticatorDetailsByUserId(ctx, user.ID, constants.EnvKeySMSOTPAuthenticator) + require.NoError(t, err) + require.NotNil(t, authenticator) + assert.Nil(t, authenticator.VerifiedAt, "setup alone must not mark the enrollment verified") + }) + + t.Run("bearer-token mode: already authenticated caller, no phone_number param", func(t *testing.T) { + mobile := fmt.Sprintf("+1%010d", time.Now().UnixNano()%10000000000) + + _, err := c.Signup(ctx, &authorizerv1.SignupRequest{ + PhoneNumber: mobile, Password: password, ConfirmPassword: password, + }) + require.NoError(t, err) + + user, err := ts.StorageProvider.GetUserByPhoneNumber(ctx, mobile) + require.NoError(t, err) + now := time.Now().Unix() + user.PhoneNumberVerifiedAt = &now + _, err = ts.StorageProvider.UpdateUser(ctx, user) + require.NoError(t, err) + + // Same reasoning as the email twin: is_multi_factor_auth_enabled is + // always forwarded explicitly (false here), so login issues the + // token directly instead of hitting the MFA gate. + loginResp, err := c.Login(ctx, &authorizerv1.LoginRequest{PhoneNumber: mobile, Password: password}) + require.NoError(t, err) + require.NotEmpty(t, loginResp.AccessToken, "no MFA enrolled -> token issued directly") + + resp, err := c.SmsOtpMfaSetup(bearerCtx(loginResp.AccessToken), &authorizerv1.SmsOtpMfaSetupRequest{}) + require.NoError(t, err, "sms_otp_mfa_setup must be reachable via bearer token with no phone_number param") + require.NotEmpty(t, resp.Message) + + authenticator, err := ts.StorageProvider.GetAuthenticatorDetailsByUserId(ctx, user.ID, constants.EnvKeySMSOTPAuthenticator) + require.NoError(t, err) + require.NotNil(t, authenticator) + assert.Nil(t, authenticator.VerifiedAt, "setup alone must not mark the enrollment verified") + }) + + t.Run("without a valid token or cookie+phone_number it is rejected, not Unimplemented", func(t *testing.T) { + _, err := c.SmsOtpMfaSetup(context.Background(), &authorizerv1.SmsOtpMfaSetupRequest{}) + require.Error(t, err) + st, ok := status.FromError(err) + require.True(t, ok) + assert.Equal(t, codes.Unauthenticated, st.Code()) + }) +} diff --git a/internal/integration_tests/lock_mfa_test.go b/internal/integration_tests/lock_mfa_test.go new file mode 100644 index 00000000..68edd744 --- /dev/null +++ b/internal/integration_tests/lock_mfa_test.go @@ -0,0 +1,178 @@ +package integration_tests + +import ( + "errors" + "fmt" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/authorizerdev/authorizer/internal/constants" + "github.com/authorizerdev/authorizer/internal/graph/model" + "github.com/authorizerdev/authorizer/internal/refs" + "github.com/authorizerdev/authorizer/internal/service" + "github.com/authorizerdev/authorizer/internal/storage/schemas" +) + +// TestLockMFA covers: locking with a valid mfa session and no OTP fallback +// succeeds and blocks subsequent login; a caller with no valid mfa session +// is rejected with Unauthenticated. +func TestLockMFA(t *testing.T) { + const password = "Password@123" + + t.Run("locks the account and blocks subsequent login", func(t *testing.T) { + cfg := getTestConfig() + cfg.EnableMFA = true + cfg.EnableTOTPLogin = true + ts := initTestSetup(t, cfg) + req, ctx := createContext(ts) + + email := "lock_mfa_" + uuid.NewString() + "@authorizer.dev" + _, err := ts.GraphQLProvider.SignUp(ctx, &model.SignUpRequest{ + Email: &email, Password: password, ConfirmPassword: password, + }) + require.NoError(t, err) + user, err := ts.StorageProvider.GetUserByEmail(ctx, email) + require.NoError(t, err) + user.IsMultiFactorAuthEnabled = refs.NewBoolRef(true) + _, err = ts.StorageProvider.UpdateUser(ctx, user) + require.NoError(t, err) + + // LockMFA is reached mid-MFA-flow, identified by the mfa session + // cookie plus email — same identification pattern as SkipMFASetup. + // Set the session directly rather than driving a full TOTP/OTP + // challenge; LockMFA itself never issues a token, so there is + // nothing about a real challenge this test needs to exercise. + mfaSession := uuid.NewString() + require.NoError(t, ts.MemoryStoreProvider.SetMfaSession(user.ID, mfaSession, constants.MFASessionPurposeVerified, time.Now().Add(5*time.Minute).Unix())) + req.Header.Set("Cookie", fmt.Sprintf("%s=%s", constants.MfaCookieName+"_session", mfaSession)) + + lockRes, err := ts.GraphQLProvider.LockMFA(ctx, &model.LockMfaRequest{Email: &email}) + require.NoError(t, err) + require.NotNil(t, lockRes) + + locked, err := ts.StorageProvider.GetUserByEmail(ctx, email) + require.NoError(t, err) + assert.NotNil(t, locked.MFALockedAt, "lock_mfa must persist MFALockedAt") + + // The signup password is real (unlike a bare AddUser fixture), so a + // rejection here is unambiguously the lockout check, not an + // incidental bad-password mismatch. + loginRes, err := ts.GraphQLProvider.Login(ctx, &model.LoginRequest{Email: &email, Password: password}) + require.Error(t, err) + assert.Nil(t, loginRes) + + var svcErr *service.Error + require.True(t, errors.As(err, &svcErr), "expected a *service.Error, got %T: %v", err, err) + assert.Equal(t, service.KindFailedPrecondition, svcErr.Kind, "a locked account must be rejected by the lockout check specifically, not any other error kind") + }) + + t.Run("refuses to lock when a verified SMS-OTP fallback exists", func(t *testing.T) { + cfg := getTestConfig() + cfg.EnableMFA = true + ts := initTestSetup(t, cfg) + req, ctx := createContext(ts) + + email := "lock_mfa_otp_fallback_" + uuid.NewString() + "@authorizer.dev" + now := time.Now().Unix() + user, err := ts.StorageProvider.AddUser(ctx, &schemas.User{ + Email: refs.NewStringRef(email), + EmailVerifiedAt: &now, + SignupMethods: constants.AuthRecipeMethodBasicAuth, + IsMultiFactorAuthEnabled: refs.NewBoolRef(true), + }) + require.NoError(t, err) + + // A verified SMS-OTP authenticator is a working recovery path, so + // locking must be refused: the user should use it instead. + _, err = ts.StorageProvider.AddAuthenticator(ctx, &schemas.Authenticator{ + UserID: user.ID, + Method: constants.EnvKeySMSOTPAuthenticator, + VerifiedAt: &now, + }) + require.NoError(t, err) + + mfaSession := uuid.NewString() + require.NoError(t, ts.MemoryStoreProvider.SetMfaSession(user.ID, mfaSession, constants.MFASessionPurposeVerified, time.Now().Add(5*time.Minute).Unix())) + req.Header.Set("Cookie", fmt.Sprintf("%s=%s", constants.MfaCookieName+"_session", mfaSession)) + + lockRes, err := ts.GraphQLProvider.LockMFA(ctx, &model.LockMfaRequest{Email: &email}) + require.Error(t, err) + assert.Nil(t, lockRes) + + var svcErr *service.Error + require.True(t, errors.As(err, &svcErr), "expected a *service.Error, got %T: %v", err, err) + assert.Equal(t, service.KindFailedPrecondition, svcErr.Kind, "a verified OTP fallback must block locking with FailedPrecondition") + + unlocked, err := ts.StorageProvider.GetUserByEmail(ctx, email) + require.NoError(t, err) + assert.Nil(t, unlocked.MFALockedAt, "a refused lock must not have persisted MFALockedAt") + }) + + for _, enforceMFA := range []bool{false, true} { + t.Run(fmt.Sprintf("rejects with Unauthenticated when caller has no valid mfa session (EnforceMFA=%v)", enforceMFA), func(t *testing.T) { + cfg := getTestConfig() + cfg.EnableMFA = true + cfg.EnforceMFA = enforceMFA + ts := initTestSetup(t, cfg) + _, ctx := createContext(ts) + + email := "lock_mfa_nosession_" + uuid.NewString() + "@authorizer.dev" + now := time.Now().Unix() + _, err := ts.StorageProvider.AddUser(ctx, &schemas.User{ + Email: refs.NewStringRef(email), + EmailVerifiedAt: &now, + SignupMethods: constants.AuthRecipeMethodBasicAuth, + IsMultiFactorAuthEnabled: refs.NewBoolRef(true), + }) + require.NoError(t, err) + + lockRes, err := ts.GraphQLProvider.LockMFA(ctx, &model.LockMfaRequest{Email: &email}) + require.Error(t, err) + assert.Nil(t, lockRes) + + var svcErr *service.Error + require.True(t, errors.As(err, &svcErr)) + assert.Equal(t, service.KindUnauthenticated, svcErr.Kind) + }) + } + + t.Run("rejects a Challenge session (ResendOTP/ForgotPassword) with Unauthenticated", func(t *testing.T) { + cfg := getTestConfig() + cfg.EnableMFA = true + ts := initTestSetup(t, cfg) + req, ctx := createContext(ts) + + email := "lock_mfa_challenge_" + uuid.NewString() + "@authorizer.dev" + now := time.Now().Unix() + user, err := ts.StorageProvider.AddUser(ctx, &schemas.User{ + Email: refs.NewStringRef(email), + EmailVerifiedAt: &now, + SignupMethods: constants.AuthRecipeMethodBasicAuth, + IsMultiFactorAuthEnabled: refs.NewBoolRef(true), + }) + require.NoError(t, err) + + // The pre-auth account-lockout DoS: an attacker who only knows the + // victim's email obtains a Challenge session via ResendOTP, then tries + // to permanently lock the account. It must be rejected. + mfaSession := uuid.NewString() + require.NoError(t, ts.MemoryStoreProvider.SetMfaSession(user.ID, mfaSession, constants.MFASessionPurposeChallenge, time.Now().Add(5*time.Minute).Unix())) + req.Header.Set("Cookie", fmt.Sprintf("%s=%s", constants.MfaCookieName+"_session", mfaSession)) + + lockRes, err := ts.GraphQLProvider.LockMFA(ctx, &model.LockMfaRequest{Email: &email}) + require.Error(t, err) + assert.Nil(t, lockRes) + + var svcErr *service.Error + require.True(t, errors.As(err, &svcErr), "expected a *service.Error, got %T: %v", err, err) + assert.Equal(t, service.KindUnauthenticated, svcErr.Kind, "a Challenge session must not be able to lock an account") + + unlocked, err := ts.StorageProvider.GetUserByEmail(ctx, email) + require.NoError(t, err) + assert.Nil(t, unlocked.MFALockedAt, "a rejected Challenge session must not have locked the account") + }) +} diff --git a/internal/integration_tests/login_mfa_cross_identifier_test.go b/internal/integration_tests/login_mfa_cross_identifier_test.go new file mode 100644 index 00000000..142ab296 --- /dev/null +++ b/internal/integration_tests/login_mfa_cross_identifier_test.go @@ -0,0 +1,107 @@ +package integration_tests + +import ( + "fmt" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/authorizerdev/authorizer/internal/constants" + "github.com/authorizerdev/authorizer/internal/crypto" + "github.com/authorizerdev/authorizer/internal/graph/model" + "github.com/authorizerdev/authorizer/internal/refs" + "github.com/authorizerdev/authorizer/internal/storage/schemas" +) + +// TestLoginMFACrossIdentifierChallenge is the regression guard for the bug +// where login.go's inline email/SMS-OTP MFA challenge only fired when the +// enrolled method matched the identifier the caller logged in with. A user +// who signed up with email, later verified a phone number, and enrolled +// SMS-OTP as their second factor was silently NOT challenged on an +// email+password login — the SMS branch required isMobileLogin — and fell +// through to resolveMFAGate, which (correctly) does not count email/SMS OTP, +// so they were offered a fresh setup instead of being blocked to verify the +// factor they already opted into. +// +// The challenge must now fire on enrollment alone and send the code to the +// account's own stored contact (user.PhoneNumber), independent of the login +// identifier. +func TestLoginMFACrossIdentifierChallenge(t *testing.T) { + const password = "Password@123" + + cfg := getTestConfig() + cfg.EnableMFA = true + cfg.EnableSMSOTP = true + cfg.IsSMSServiceEnabled = true + ts := initTestSetup(t, cfg) + req, ctx := createContext(ts) + + // Email signup (auto-verified: email verification is off in getTestConfig) + // so the stored password hash is one login.go's bcrypt check accepts. + email := "login_cross_id_" + uuid.NewString() + "@authorizer.dev" + _, err := ts.GraphQLProvider.SignUp(ctx, &model.SignUpRequest{ + Email: &email, Password: password, ConfirmPassword: password, + }) + require.NoError(t, err) + + user, err := ts.StorageProvider.GetUserByEmail(ctx, email) + require.NoError(t, err) + + // Later this account verifies a phone number and opts into MFA. + now := time.Now().Unix() + phone := fmt.Sprintf("+1%010d", time.Now().UnixNano()%10000000000) + user.PhoneNumber = refs.NewStringRef(phone) + user.PhoneNumberVerifiedAt = &now + user.IsMultiFactorAuthEnabled = refs.NewBoolRef(true) + user, err = ts.StorageProvider.UpdateUser(ctx, user) + require.NoError(t, err) + + // SMS-OTP is the user's ONLY enrolled/verified second factor. + _, err = ts.StorageProvider.AddAuthenticator(ctx, &schemas.Authenticator{ + UserID: user.ID, + Method: constants.EnvKeySMSOTPAuthenticator, + VerifiedAt: &now, + }) + require.NoError(t, err) + + // Login with EMAIL + password (not phone). The SMS-OTP factor must still + // be challenged. + res, err := ts.GraphQLProvider.Login(ctx, &model.LoginRequest{Email: &email, Password: password}) + require.NoError(t, err) + require.NotNil(t, res) + assert.Nil(t, res.AccessToken, "must not issue a token before the enrolled SMS-OTP factor is verified") + assert.True(t, refs.BoolValue(res.ShouldShowMobileOtpScreen), "an email login must still challenge the account's enrolled SMS-OTP factor") + assert.False(t, refs.BoolValue(res.ShouldOfferSmsOtpMfaSetup), "the user already enrolled SMS-OTP; this must be a verify challenge, not a setup offer") + + // The plaintext OTP is only sent over SMS (which the suite can't + // intercept) and stored as an HMAC digest, keyed by both email and phone + // (generateAndStoreOTP writes both). Overwrite it with a known + // plaintext/digest pair, then complete the challenge via the phone. + storedOTP, err := ts.StorageProvider.GetOTPByPhoneNumber(ctx, phone) + require.NoError(t, err) + require.NotNil(t, storedOTP) + const knownPlainOTP = "123456" + storedOTP.Otp = crypto.HashOTP(knownPlainOTP, cfg.JWTSecret) + storedOTP.ExpiresAt = time.Now().Add(5 * time.Minute).Unix() + _, err = ts.StorageProvider.UpsertOTP(ctx, storedOTP) + require.NoError(t, err) + + // The MFA session cookie is only set on the login response; copy it onto + // the next request by hand (http.Request cookies aren't auto-updated from + // responses in this in-process setup). + mfaCookie := latestMfaSessionCookie(ts) + require.NotEmpty(t, mfaCookie, "the SMS-OTP challenge must arm an mfa session cookie") + req.Header.Set("Cookie", fmt.Sprintf("%s=%s", constants.MfaCookieName+"_session", mfaCookie)) + + verifyRes, err := ts.GraphQLProvider.VerifyOTP(ctx, &model.VerifyOTPRequest{ + PhoneNumber: &phone, + Otp: knownPlainOTP, + }) + require.NoError(t, err) + require.NotNil(t, verifyRes) + assert.NotNil(t, verifyRes.AccessToken, "verifying the SMS OTP must complete login") + assert.NotEmpty(t, *verifyRes.AccessToken) +} diff --git a/internal/integration_tests/magic_link_login_test.go b/internal/integration_tests/magic_link_login_test.go index 970fe2b6..087e4ff1 100644 --- a/internal/integration_tests/magic_link_login_test.go +++ b/internal/integration_tests/magic_link_login_test.go @@ -2,12 +2,15 @@ package integration_tests import ( "testing" + "time" "github.com/google/uuid" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "github.com/authorizerdev/authorizer/internal/constants" "github.com/authorizerdev/authorizer/internal/graph/model" + "github.com/authorizerdev/authorizer/internal/storage/schemas" ) // TestMagicLinkLogin tests the magic link login functionality of the Authorizer application. @@ -70,3 +73,77 @@ func TestMagicLinkLogin(t *testing.T) { ts.GinContext.Request.Header.Set("Authorization", "") }) } + +// TestMagicLinkLoginMFAGate is a regression guard for VerifyEmail's MFA +// check: it used to be an ad-hoc TOTP-only condition +// (refs.BoolValue(user.IsMultiFactorAuthEnabled) && isMFAEnabled && +// isTOTPLoginEnabled) that silently skipped WebAuthn, email/SMS-OTP-as-MFA, +// EnforceMFA, and — most severely — MFALockedAt entirely, letting a locked +// or non-TOTP-MFA account complete a magic-link login with zero challenge. +// VerifyEmail now calls the same resolveMFAGate every other entry point +// (login.go, signup.go, oauth_mfa_gate.go, webauthn.go) uses. +func TestMagicLinkLoginMFAGate(t *testing.T) { + cfg := getTestConfig() + cfg.EnableMagicLinkLogin = true + cfg.SMTPHost = "localhost" + cfg.SMTPPort = 1025 + cfg.SMTPSenderEmail = "test@authorizer.dev" + cfg.SMTPSenderName = "Test" + cfg.SMTPLocalName = "Test" + cfg.SMTPSkipTLSVerification = true + cfg.IsEmailServiceEnabled = true + cfg.EnableEmailVerification = true + cfg.EnableMFA = true + ts := initTestSetup(t, cfg) + _, ctx := createContext(ts) + + startMagicLinkLogin := func(t *testing.T, email string) *schemas.VerificationRequest { + t.Helper() + res, err := ts.GraphQLProvider.MagicLinkLogin(ctx, &model.MagicLinkLoginRequest{ + Email: email, + }) + require.NoError(t, err) + require.NotNil(t, res) + verificationRequest, err := ts.StorageProvider.GetVerificationRequestByEmail(ctx, email, constants.VerificationTypeMagicLinkLogin) + require.NoError(t, err) + require.NotNil(t, verificationRequest) + return verificationRequest + } + + t.Run("locked account is blocked, not logged in", func(t *testing.T) { + email := "magic_link_locked_" + uuid.New().String() + "@authorizer.dev" + verificationRequest := startMagicLinkLogin(t, email) + + // MagicLinkLogin creates the user record on the fly before the + // verification email is sent - lock it before the click-through. + user, err := ts.StorageProvider.GetUserByEmail(ctx, email) + require.NoError(t, err) + now := time.Now().Unix() + user.MFALockedAt = &now + _, err = ts.StorageProvider.UpdateUser(ctx, user) + require.NoError(t, err) + + verifyRes, err := ts.GraphQLProvider.VerifyEmail(ctx, &model.VerifyEmailRequest{ + Token: verificationRequest.Token, + }) + assert.Error(t, err) + assert.Nil(t, verifyRes) + assert.Contains(t, err.Error(), "locked") + }) + + t.Run("MFA available and not yet configured withholds the token", func(t *testing.T) { + email := "magic_link_offer_" + uuid.New().String() + "@authorizer.dev" + verificationRequest := startMagicLinkLogin(t, email) + + verifyRes, err := ts.GraphQLProvider.VerifyEmail(ctx, &model.VerifyEmailRequest{ + Token: verificationRequest.Token, + }) + require.NoError(t, err) + require.NotNil(t, verifyRes) + // The old ad-hoc check unconditionally issued a full session here + // whenever EnableTOTPLogin was off (or the user had a + // non-TOTP-only MFA config) - the gate must withhold instead. + assert.Nil(t, verifyRes.AccessToken) + assert.Equal(t, "Proceed to mfa setup", verifyRes.Message) + }) +} diff --git a/internal/integration_tests/meta_test.go b/internal/integration_tests/meta_test.go index 8fd49809..ea3234f0 100644 --- a/internal/integration_tests/meta_test.go +++ b/internal/integration_tests/meta_test.go @@ -68,18 +68,18 @@ func TestMeta(t *testing.T) { meta, err := ts.GraphQLProvider.Meta(ctx) require.NoError(t, err) require.NotNil(t, meta) - // Default config has MFA disabled, so all OTP/TOTP methods are unavailable. + // Default config has MFA disabled, so all OTP/TOTP/WebAuthn methods are unavailable. assert.False(t, meta.IsTotpMfaEnabled) assert.False(t, meta.IsEmailOtpMfaEnabled) assert.False(t, meta.IsSmsOtpMfaEnabled) - // WebAuthn/passkey ships always-on. - assert.True(t, meta.IsWebauthnEnabled) + assert.False(t, meta.IsWebauthnEnabled) }) - t.Run("should enable TOTP MFA when MFA and TOTP login are on", func(t *testing.T) { + t.Run("should enable TOTP and WebAuthn MFA when MFA, TOTP login, and WebAuthn MFA are on", func(t *testing.T) { cfg2 := getTestConfig() cfg2.EnableMFA = true cfg2.EnableTOTPLogin = true + cfg2.EnableWebauthnMFA = true ts2 := initTestSetup(t, cfg2) _, ctx2 := createContext(ts2) @@ -93,6 +93,22 @@ func TestMeta(t *testing.T) { assert.True(t, meta.IsWebauthnEnabled) }) + t.Run("should disable WebAuthn MFA when --disable-webauthn-mfa is set, even with MFA on", func(t *testing.T) { + cfg2 := getTestConfig() + cfg2.EnableMFA = true + cfg2.EnableTOTPLogin = true + cfg2.EnableWebauthnMFA = false + ts2 := initTestSetup(t, cfg2) + _, ctx2 := createContext(ts2) + + meta, err := ts2.GraphQLProvider.Meta(ctx2) + require.NoError(t, err) + require.NotNil(t, meta) + assert.False(t, meta.IsWebauthnEnabled) + // Other MFA methods are unaffected by disabling WebAuthn specifically. + assert.True(t, meta.IsTotpMfaEnabled) + }) + t.Run("should gate email/SMS OTP MFA on service availability", func(t *testing.T) { cfg2 := getTestConfig() cfg2.EnableMFA = true diff --git a/internal/integration_tests/mfa_gate_login_test.go b/internal/integration_tests/mfa_gate_login_test.go index 146c6d44..e25e0246 100644 --- a/internal/integration_tests/mfa_gate_login_test.go +++ b/internal/integration_tests/mfa_gate_login_test.go @@ -17,20 +17,20 @@ import ( // TestLoginMFAGateTokenWithholding is the regression guard for the security // property described in mfa_gate.go and wired into login.go's TOTP branch: -// mfaGateBlockVerify and mfaGateBlockEnroll must NEVER reach the code path -// that sets AccessToken on the login response, while mfaGateNone, -// mfaGateOfferSetup and mfaGateSkippedSetup must all fall through to normal -// token issuance. +// mfaGateBlockVerify, mfaGateBlockEnroll, and mfaGateOfferAll must NEVER +// reach the code path that sets AccessToken on the login response, while +// mfaGateNone and mfaGateSkippedSetup must fall through to normal token +// issuance. // // mfa_gate_test.go already covers resolveMFAGate's pure decision table in // isolation. This test drives the same 5 outcomes through the real // login.go switch (via GraphQLProvider.Login, a thin wrapper around // service.Provider.Login) with a user/config combination engineered to land // on exactly one outcome, and asserts on AccessToken directly. A future edit -// that removes a `return` from the mfaGateBlockVerify/mfaGateBlockEnroll -// cases — or that lets one of them fall through — would compile and pass -// TestResolveMFAGate unchanged, but would fail here because AccessToken -// stops being empty. +// that removes a `return` from the mfaGateBlockVerify/mfaGateBlockEnroll/ +// mfaGateOfferAll cases — or that lets one of them fall through — would +// compile and pass TestResolveMFAGate unchanged, but would fail here +// because AccessToken stops being empty. func TestLoginMFAGateTokenWithholding(t *testing.T) { const password = "Password@123" @@ -50,10 +50,25 @@ func TestLoginMFAGateTokenWithholding(t *testing.T) { } // addVerifiedAuthenticator gives the user a completed TOTP authenticator, - // the condition login.go reads as authenticatorVerified=true. + // the condition login.go reads as authenticatorVerified=true. Upserts + // rather than blindly inserting: SignUp itself now runs the same MFA + // gate as Login (Task 7), so signUpUser (below, with cfg.EnableMFA=true) + // already leaves an unverified TOTP row behind via its own + // generateTOTPEnrollment call. StorageProvider.AddAuthenticator no-ops + // when a row already exists for (userID, method), so calling it here + // unconditionally would silently fail to mark that pre-existing row + // verified. addVerifiedAuthenticator := func(t *testing.T, ts *testSetup, ctx context.Context, userID string) { t.Helper() now := time.Now().Unix() + existing, _ := ts.StorageProvider.GetAuthenticatorDetailsByUserId(ctx, userID, constants.EnvKeyTOTPAuthenticator) + if existing != nil { + existing.Secret = "dummy-secret-for-gate-test" + existing.VerifiedAt = &now + _, err := ts.StorageProvider.UpdateAuthenticator(ctx, existing) + require.NoError(t, err) + return + } _, err := ts.StorageProvider.AddAuthenticator(ctx, &schemas.Authenticator{ UserID: userID, Method: constants.EnvKeyTOTPAuthenticator, @@ -163,7 +178,7 @@ func TestLoginMFAGateTokenWithholding(t *testing.T) { assert.NotNil(t, res.AuthenticatorSecret, "block-enroll must hand back a fresh enrollment payload") }) - t.Run("mfaGateOfferSetup issues a token and offers setup", func(t *testing.T) { + t.Run("mfaGateOfferAll withholds the token and offers every available method", func(t *testing.T) { cfg := getTestConfig() cfg.EnableMFA = true cfg.EnableTOTPLogin = true @@ -179,10 +194,9 @@ func TestLoginMFAGateTokenWithholding(t *testing.T) { res, err := ts.GraphQLProvider.Login(ctx, &model.LoginRequest{Email: user.Email, Password: password}) require.NoError(t, err) require.NotNil(t, res) - assert.NotNil(t, res.AccessToken, "optional MFA must not block login") - assert.NotEmpty(t, *res.AccessToken) - assert.True(t, refs.BoolValue(res.ShouldOfferMfaSetup)) - assert.NotNil(t, res.AuthenticatorSecret) + assert.Nil(t, res.AccessToken, "a first-time optional-MFA offer must withhold the token until setup or skip") + assert.True(t, refs.BoolValue(res.ShouldShowTotpScreen)) + assert.NotNil(t, res.AuthenticatorSecret, "offer-all must hand back a fresh TOTP enrollment payload") }) t.Run("mfaGateSkippedSetup issues a token quietly", func(t *testing.T) { @@ -215,7 +229,13 @@ func TestLoginMFAGateTokenWithholding(t *testing.T) { _, ctx := createContext(ts) user := signUpUser(t, ts, ctx) - // IsMultiFactorAuthEnabled left false/unset: the gate is a no-op. + // Signup now defaults IsMultiFactorAuthEnabled to true whenever MFA + // is available server-wide (see signup.go), so this test's actual + // target state - a user for whom MFA is individually off - must be + // set explicitly rather than relying on signup to leave it unset. + user.IsMultiFactorAuthEnabled = refs.NewBoolRef(false) + user, err := ts.StorageProvider.UpdateUser(ctx, user) + require.NoError(t, err) res, err := ts.GraphQLProvider.Login(ctx, &model.LoginRequest{Email: user.Email, Password: password}) require.NoError(t, err) @@ -225,4 +245,59 @@ func TestLoginMFAGateTokenWithholding(t *testing.T) { assert.False(t, refs.BoolValue(res.ShouldShowTotpScreen)) assert.False(t, refs.BoolValue(res.ShouldOfferMfaSetup)) }) + + // Regression guard for finding I1 (final whole-branch review): this + // block used to be guarded by `isMFAEnabled && isTOTPLoginEnabled`. A + // server configured for WebAuthn-only enforced MFA (EnableTOTPLogin + // off, EnableWebauthnMFA on) skipped resolveMFAGate entirely and + // issued a token to an unenrolled password-login user unconditionally + // -- no offer, no enforcement -- even though WebauthnLoginVerify was + // already correctly gated on such a server. The gate must now run + // whenever MFA applies at all, and only the TOTP-specific parts of + // the response should be conditioned on EnableTOTPLogin. + t.Run("mfaGateBlockEnroll offers WebAuthn setup on a WebAuthn-only enforced-MFA server", func(t *testing.T) { + cfg := getTestConfig() + cfg.EnableMFA = true + cfg.EnableTOTPLogin = false + cfg.EnableWebauthnMFA = true + cfg.EnforceMFA = true + ts := initTestSetup(t, cfg) + _, ctx := createContext(ts) + + user := signUpUser(t, ts, ctx) + user.IsMultiFactorAuthEnabled = refs.NewBoolRef(true) + user, err := ts.StorageProvider.UpdateUser(ctx, user) + require.NoError(t, err) + // No TOTP/WebAuthn enrollment. + + res, err := ts.GraphQLProvider.Login(ctx, &model.LoginRequest{Email: user.Email, Password: password}) + require.NoError(t, err) + require.NotNil(t, res) + assert.Nil(t, res.AccessToken, "must not issue a token to an unenrolled user on a WebAuthn-only enforced-MFA server") + assert.True(t, refs.BoolValue(res.ShouldOfferWebauthnMfaSetup)) + assert.False(t, refs.BoolValue(res.ShouldShowTotpScreen), "TOTP login is disabled server-wide; must not offer a screen the user can't complete") + assert.Nil(t, res.AuthenticatorSecret, "must not generate a TOTP enrollment when TOTP login is disabled") + }) + + t.Run("mfaGateOfferAll offers WebAuthn setup on a WebAuthn-only server when MFA isn't enforced", func(t *testing.T) { + cfg := getTestConfig() + cfg.EnableMFA = true + cfg.EnableTOTPLogin = false + cfg.EnableWebauthnMFA = true + ts := initTestSetup(t, cfg) + _, ctx := createContext(ts) + + user := signUpUser(t, ts, ctx) + user.IsMultiFactorAuthEnabled = refs.NewBoolRef(true) + user, err := ts.StorageProvider.UpdateUser(ctx, user) + require.NoError(t, err) + + res, err := ts.GraphQLProvider.Login(ctx, &model.LoginRequest{Email: user.Email, Password: password}) + require.NoError(t, err) + require.NotNil(t, res) + assert.Nil(t, res.AccessToken, "a first-time optional-MFA offer must withhold the token even when TOTP login is unavailable") + assert.True(t, refs.BoolValue(res.ShouldOfferWebauthnMfaSetup)) + assert.False(t, refs.BoolValue(res.ShouldShowTotpScreen)) + assert.Nil(t, res.AuthenticatorSecret) + }) } diff --git a/internal/integration_tests/mfa_gate_missing_otp_factors_test.go b/internal/integration_tests/mfa_gate_missing_otp_factors_test.go new file mode 100644 index 00000000..715ef42c --- /dev/null +++ b/internal/integration_tests/mfa_gate_missing_otp_factors_test.go @@ -0,0 +1,110 @@ +package integration_tests + +import ( + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/authorizerdev/authorizer/internal/constants" + "github.com/authorizerdev/authorizer/internal/graph/model" + "github.com/authorizerdev/authorizer/internal/refs" + "github.com/authorizerdev/authorizer/internal/storage/schemas" +) + +// TestVerifyEmailChallengesEmailOTPFactor is the regression guard for a bug +// found in a final whole-branch review, not by the earlier task-level audit +// of this file: service.VerifyEmail's authenticatorVerified was +// `totpVerified || hasWebauthnCredential`, completely omitting Email-OTP and +// SMS-OTP. A user whose only enrolled factor was Email-OTP or SMS-OTP, with +// HasSkippedMFASetupAt set (reachable: skip while unenrolled, then later +// enroll Email/SMS-OTP via settings without ever re-verifying TOTP/WebAuthn), +// resolved to mfaGateSkippedSetup and got a full token via magic-link login +// or signup email verification with zero MFA challenge - despite the exact +// same account being correctly challenged on a password login. +func TestVerifyEmailChallengesEmailOTPFactor(t *testing.T) { + cfg := getTestConfig() + cfg.EnableMagicLinkLogin = true + cfg.SMTPHost = "localhost" + cfg.SMTPPort = 1025 + cfg.SMTPSenderEmail = "test@authorizer.dev" + cfg.SMTPSenderName = "Test" + cfg.SMTPLocalName = "Test" + cfg.SMTPSkipTLSVerification = true + cfg.IsEmailServiceEnabled = true + cfg.EnableEmailVerification = true + cfg.EnableMFA = true + cfg.EnableEmailOTP = true + ts := initTestSetup(t, cfg) + _, ctx := createContext(ts) + + email := "verify_email_otp_factor_" + uuid.New().String() + "@authorizer.dev" + res, err := ts.GraphQLProvider.MagicLinkLogin(ctx, &model.MagicLinkLoginRequest{Email: email}) + require.NoError(t, err) + require.NotNil(t, res) + + user, err := ts.StorageProvider.GetUserByEmail(ctx, email) + require.NoError(t, err) + + // The user previously skipped MFA setup, then later enrolled Email-OTP + // via settings — both fields set, exactly the reachable combination that + // triggered mfaGateSkippedSetup under the pre-fix authenticatorVerified. + now := time.Now().Unix() + user.HasSkippedMFASetupAt = &now + user, err = ts.StorageProvider.UpdateUser(ctx, user) + require.NoError(t, err) + _, err = ts.StorageProvider.AddAuthenticator(ctx, &schemas.Authenticator{ + UserID: user.ID, + Method: constants.EnvKeyEmailOTPAuthenticator, + VerifiedAt: &now, + }) + require.NoError(t, err) + + verificationRequest, err := ts.StorageProvider.GetVerificationRequestByEmail(ctx, email, constants.VerificationTypeMagicLinkLogin) + require.NoError(t, err) + require.NotNil(t, verificationRequest) + + verifyRes, err := ts.GraphQLProvider.VerifyEmail(ctx, &model.VerifyEmailRequest{Token: verificationRequest.Token}) + require.NoError(t, err) + require.NotNil(t, verifyRes) + assert.Nil(t, verifyRes.AccessToken, "must not issue a token before the enrolled Email-OTP factor is verified") + assert.True(t, refs.BoolValue(verifyRes.ShouldShowEmailOtpScreen), "must challenge the account's enrolled Email-OTP factor") +} + +// TestWebauthnLoginVerifyChallengesEmailOTPFactor is the same bug in +// WebauthnLoginVerify: authenticatorVerified only considered TOTP, so a +// passkey-primary login for a user whose only enrolled second factor was +// Email-OTP or SMS-OTP silently issued a token with zero MFA challenge. +func TestWebauthnLoginVerifyChallengesEmailOTPFactor(t *testing.T) { + cfg := getTestConfig() + cfg.EnableWebauthnMFA = true + cfg.EnableEmailOTP = true + cfg.IsEmailServiceEnabled = true + ts := initTestSetup(t, cfg) + + // Per-user opt-in, not the global cfg.EnableMFA flag - signup itself must + // stay unaffected so registerPasskeyForNewUser's SignUp call still issues + // a token to register the passkey with. WebauthnLoginVerify's own gate + // (below) only requires effectiveMFAEnabled, not cfg.EnableMFA globally. + user, rp, authenticator, credential := registerPasskeyForNewUser(t, ts) + + now := time.Now().Unix() + user.IsMultiFactorAuthEnabled = refs.NewBoolRef(true) + user.HasSkippedMFASetupAt = &now + user, err := ts.StorageProvider.UpdateUser(t.Context(), user) + require.NoError(t, err) + _, err = ts.StorageProvider.AddAuthenticator(t.Context(), &schemas.Authenticator{ + UserID: user.ID, + Method: constants.EnvKeyEmailOTPAuthenticator, + VerifiedAt: &now, + }) + require.NoError(t, err) + + res, err := assertPasskeyLogin(t, ts, rp, authenticator, credential) + require.NoError(t, err) + require.NotNil(t, res) + assert.Nil(t, res.AccessToken, "must not issue a token before the enrolled Email-OTP factor is verified") + assert.True(t, refs.BoolValue(res.ShouldShowEmailOtpScreen), "must challenge the account's enrolled Email-OTP factor") +} 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..c3918c9f --- /dev/null +++ b/internal/integration_tests/otp_mfa_setup_test.go @@ -0,0 +1,457 @@ +package integration_tests + +import ( + "fmt" + "testing" + "time" + + "github.com/google/uuid" + "github.com/pquerna/otp/totp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/authorizerdev/authorizer/internal/constants" + "github.com/authorizerdev/authorizer/internal/crypto" + "github.com/authorizerdev/authorizer/internal/graph/model" + "github.com/authorizerdev/authorizer/internal/refs" + "github.com/authorizerdev/authorizer/internal/storage/schemas" +) + +// TestTOTPMFAEnrollment covers the full enroll-then-use cycle for +// settings-mode TOTP MFA setup (totp_mfa_setup): previously TOTP enrollment +// could only ever be generated inline as a side effect of the login/signup +// gate response - a bearer-token-authenticated user had no way to add TOTP +// from account settings the way EmailOTPMFASetup/SMSOTPMFASetup already +// allow. Mirrors TestEmailOTPMFAEnrollment's flow. +func TestTOTPMFAEnrollment(t *testing.T) { + const password = "Password@123" + + cfg := getTestConfig() + cfg.EnableMFA = true + cfg.EnableTOTPLogin = true + ts := initTestSetup(t, cfg) + req, ctx := createContext(ts) + + email := "totp_mfa_" + uuid.NewString() + "@authorizer.dev" + _, err := ts.GraphQLProvider.SignUp(ctx, &model.SignUpRequest{ + Email: &email, Password: password, ConfirmPassword: password, + IsMultiFactorAuthEnabled: refs.NewBoolRef(true), + }) + require.NoError(t, err) + user, err := ts.StorageProvider.GetUserByEmail(ctx, email) + require.NoError(t, err) + + // First login: nothing enrolled yet -> withheld, offered. + loginRes, err := ts.GraphQLProvider.Login(ctx, &model.LoginRequest{Email: &email, Password: password}) + require.NoError(t, err) + require.Nil(t, loginRes.AccessToken, "no method enrolled yet -> withheld, offer-all") + + // Get a bearer token the realistic way: skip the offer now (settings- + // screen enrollment is a separate, later, already-logged-in action). + mfaSession := latestMfaSessionCookie(ts) + require.NotEmpty(t, mfaSession, "login must have set an mfa session cookie on the response") + req.Header.Set("Cookie", fmt.Sprintf("%s=%s", constants.MfaCookieName+"_session", mfaSession)) + skipRes, err := ts.GraphQLProvider.SkipMFASetup(ctx, &model.SkipMfaSetupRequest{Email: &email}) + require.NoError(t, err) + require.NotNil(t, skipRes.AccessToken) + req.Header.Set("Authorization", "Bearer "+*skipRes.AccessToken) + + // Now, as an already-logged-in user with NO leftover MFA session (skip + // already consumed/deleted the one from login), enroll TOTP. This is + // the realistic settings-screen scenario, and the regression case: + // resolveOTPSetupCaller must re-arm a fresh MFA session itself here, or + // the verify_otp call below fails with "invalid session" even though + // setup itself reported success. + req.Header.Set("Cookie", "") + setupRes, err := ts.GraphQLProvider.TOTPMFASetup(ctx, nil) + require.NoError(t, err) + require.NotNil(t, setupRes) + require.NotEmpty(t, refs.StringValue(setupRes.AuthenticatorSecret)) + require.NotEmpty(t, refs.StringValue(setupRes.AuthenticatorScannerImage)) + require.NotEmpty(t, setupRes.AuthenticatorRecoveryCodes) + + authenticator, err := ts.StorageProvider.GetAuthenticatorDetailsByUserId(ctx, user.ID, constants.EnvKeyTOTPAuthenticator) + require.NoError(t, err) + require.NotNil(t, authenticator) + assert.Nil(t, authenticator.VerifiedAt, "setup alone must not mark the enrollment verified") + + code, err := totp.GenerateCode(refs.StringValue(setupRes.AuthenticatorSecret), time.Now()) + require.NoError(t, err) + + // verify_otp is identified by the mfa session cookie TOTPMFASetup itself + // must have just re-armed - not a synthetic one, proving the fix rather + // than bypassing the exact gap it closes. + verifySession := latestMfaSessionCookie(ts) + require.NotEmpty(t, verifySession, "totp_mfa_setup must re-arm an MFA session for the caller") + req.Header.Set("Cookie", fmt.Sprintf("%s=%s", constants.MfaCookieName+"_session", verifySession)) + verifyRes, err := ts.GraphQLProvider.VerifyOTP(ctx, &model.VerifyOTPRequest{Email: &email, Otp: code, IsTotp: refs.NewBoolRef(true)}) + require.NoError(t, err) + require.NotNil(t, verifyRes) + + authenticator, err = ts.StorageProvider.GetAuthenticatorDetailsByUserId(ctx, user.ID, constants.EnvKeyTOTPAuthenticator) + require.NoError(t, err) + require.NotNil(t, authenticator) + require.NotNil(t, authenticator.VerifiedAt, "verify_otp must mark the pending enrollment verified") + + // A subsequent login now routes through the TOTP challenge branch (not + // mfaGateOfferAll) since the enrollment is verified - authenticatorVerified + // wins over the earlier skip per resolveMFAGate's decision table. + req.Header.Set("Authorization", "") + secondLogin, err := ts.GraphQLProvider.Login(ctx, &model.LoginRequest{Email: &email, Password: password}) + require.NoError(t, err) + require.Nil(t, secondLogin.AccessToken, "an enrolled-and-verified TOTP factor must challenge, not skip, login") + assert.True(t, refs.BoolValue(secondLogin.ShouldShowTotpScreen), "must route through the TOTP challenge branch, not the offer-all screen") +} + +// TestEmailOTPMFAEnrollment covers the full enroll-then-use cycle for +// email-OTP-as-MFA: +// - a first-time, unenrolled login is withheld and offers email OTP setup +// (mfaGateOfferAll's ShouldOfferEmailOtpMfaSetup, wired in login.go). +// - EmailOTPMFASetup (bearer-token authenticated) creates an unverified +// Authenticator row and does not by itself gate login. +// - verify_otp marks that row verified. +// - only after verification does a subsequent login route through the +// retrofitted email-OTP-as-MFA branch instead of the offer-all screen. +// +// Sequencing note: EmailOTPMFASetup requires a bearer token, but the first +// login's token is withheld (mfaGateOfferAll). This test obtains a token via +// skip_mfa_setup first -- the realistic "skip now, add a second factor later +// from account settings" flow -- rather than calling EmailOTPMFASetup with no +// token, which cannot succeed. +func TestEmailOTPMFAEnrollment(t *testing.T) { + const password = "Password@123" + + cfg := getTestConfig() + cfg.EnableMFA = true + cfg.EnableTOTPLogin = true + cfg.EnableEmailOTP = true + cfg.SMTPHost = "localhost" + cfg.SMTPPort = 1025 + cfg.SMTPSenderEmail = "test@authorizer.dev" + cfg.SMTPSenderName = "Test" + cfg.SMTPLocalName = "Test" + cfg.SMTPSkipTLSVerification = true + cfg.IsEmailServiceEnabled = true + ts := initTestSetup(t, cfg) + require.True(t, ts.Config.IsEmailServiceEnabled, "test SMTP fixture must derive IsEmailServiceEnabled=true") + req, ctx := createContext(ts) + + email := "email_otp_mfa_" + uuid.NewString() + "@authorizer.dev" + _, err := ts.GraphQLProvider.SignUp(ctx, &model.SignUpRequest{ + Email: &email, Password: password, ConfirmPassword: password, + IsMultiFactorAuthEnabled: refs.NewBoolRef(true), + }) + require.NoError(t, err) + user, err := ts.StorageProvider.GetUserByEmail(ctx, email) + require.NoError(t, err) + + // First login: nothing enrolled yet -> withheld, offered. + loginRes, err := ts.GraphQLProvider.Login(ctx, &model.LoginRequest{Email: &email, Password: password}) + require.NoError(t, err) + require.Nil(t, loginRes.AccessToken, "no method enrolled yet -> withheld, offer-all") + assert.True(t, refs.BoolValue(loginRes.ShouldOfferEmailOtpMfaSetup)) + + // Get a bearer token the realistic way: skip the offer now (settings- + // screen enrollment is a separate, later, already-logged-in action). + mfaSession := latestMfaSessionCookie(ts) + require.NotEmpty(t, mfaSession, "login must have set an mfa session cookie on the response") + req.Header.Set("Cookie", fmt.Sprintf("%s=%s", constants.MfaCookieName+"_session", mfaSession)) + skipRes, err := ts.GraphQLProvider.SkipMFASetup(ctx, &model.SkipMfaSetupRequest{Email: &email}) + require.NoError(t, err) + require.NotNil(t, skipRes.AccessToken) + req.Header.Set("Authorization", "Bearer "+*skipRes.AccessToken) + + // Now, as an already-logged-in user with NO leftover MFA session (skip + // already consumed/deleted the one from login), enroll a second factor. + // This is the realistic settings-screen scenario, and the regression + // case: resolveOTPSetupCaller must re-arm a fresh MFA session itself + // here, or the verify_otp call below fails with "invalid session" even + // though setup itself reported success. + req.Header.Set("Cookie", "") + setupRes, err := ts.GraphQLProvider.EmailOTPMFASetup(ctx, nil) + require.NoError(t, err) + require.NotNil(t, setupRes) + + authenticator, err := ts.StorageProvider.GetAuthenticatorDetailsByUserId(ctx, user.ID, constants.EnvKeyEmailOTPAuthenticator) + require.NoError(t, err) + require.NotNil(t, authenticator) + assert.Nil(t, authenticator.VerifiedAt, "setup alone must not mark the enrollment verified") + + // Verify the code EmailOTPMFASetup sent. The test can't intercept the + // outgoing email, so overwrite the stored digest with one for a known + // plaintext, mirroring TestVerifyOTP's pattern. + const knownPlainOTP = "654321" + storedOTP, err := ts.StorageProvider.GetOTPByEmail(ctx, email) + require.NoError(t, err) + require.NotNil(t, storedOTP) + storedOTP.Otp = crypto.HashOTP(knownPlainOTP, cfg.JWTSecret) + storedOTP.ExpiresAt = time.Now().Add(5 * time.Minute).Unix() + _, err = ts.StorageProvider.UpsertOTP(ctx, storedOTP) + require.NoError(t, err) + + // verify_otp is identified by the mfa session cookie EmailOTPMFASetup + // itself must have just re-armed - not a synthetic one, proving the fix + // rather than bypassing the exact gap it closes. + verifySession := latestMfaSessionCookie(ts) + require.NotEmpty(t, verifySession, "email_otp_mfa_setup must re-arm an MFA session for the caller") + req.Header.Set("Cookie", fmt.Sprintf("%s=%s", constants.MfaCookieName+"_session", verifySession)) + verifyRes, err := ts.GraphQLProvider.VerifyOTP(ctx, &model.VerifyOTPRequest{Email: &email, Otp: knownPlainOTP}) + require.NoError(t, err) + require.NotNil(t, verifyRes) + + authenticator, err = ts.StorageProvider.GetAuthenticatorDetailsByUserId(ctx, user.ID, constants.EnvKeyEmailOTPAuthenticator) + require.NoError(t, err) + require.NotNil(t, authenticator) + require.NotNil(t, authenticator.VerifiedAt, "verify_otp must mark the pending enrollment verified") + + // A subsequent login now routes through the retrofitted email-OTP-as-MFA + // challenge branch (not mfaGateOfferAll) since the enrollment is verified. + req.Header.Set("Authorization", "") + secondLogin, err := ts.GraphQLProvider.Login(ctx, &model.LoginRequest{Email: &email, Password: password}) + require.NoError(t, err) + require.Nil(t, secondLogin.AccessToken, "an enrolled-and-verified email OTP factor must challenge, not skip, login") + assert.True(t, refs.BoolValue(secondLogin.ShouldShowEmailOtpScreen), "must route through the OTP challenge branch, not the offer-all screen") +} + +// TestEmailOTPMFASetupViaMfaSessionCookie proves the actual gap this fix +// closes: a user offered "set up Email OTP" from the token-withheld +// mfaGateOfferAll screen has NO bearer token, only the MFA session cookie — +// so email_otp_mfa_setup must be reachable in that cookie-only mode, not +// just from an already-logged-in settings screen. Full flow: withheld +// first login -> email_otp_mfa_setup via cookie+email (no Authorization +// header at all) -> verify_otp -> the previously-withheld token is issued. +func TestEmailOTPMFASetupViaMfaSessionCookie(t *testing.T) { + const password = "Password@123" + + cfg := getTestConfig() + cfg.EnableMFA = true + cfg.EnableTOTPLogin = true + cfg.EnableEmailOTP = true + cfg.SMTPHost = "localhost" + cfg.SMTPPort = 1025 + cfg.SMTPSenderEmail = "test@authorizer.dev" + cfg.SMTPSenderName = "Test" + cfg.SMTPLocalName = "Test" + cfg.SMTPSkipTLSVerification = true + cfg.IsEmailServiceEnabled = true + ts := initTestSetup(t, cfg) + req, ctx := createContext(ts) + + email := "cookie_email_otp_mfa_" + uuid.NewString() + "@authorizer.dev" + _, err := ts.GraphQLProvider.SignUp(ctx, &model.SignUpRequest{ + Email: &email, Password: password, ConfirmPassword: password, + IsMultiFactorAuthEnabled: refs.NewBoolRef(true), + }) + require.NoError(t, err) + user, err := ts.StorageProvider.GetUserByEmail(ctx, email) + require.NoError(t, err) + + // First login: nothing enrolled yet -> withheld, offered, no bearer + // token issued. + loginRes, err := ts.GraphQLProvider.Login(ctx, &model.LoginRequest{Email: &email, Password: password}) + require.NoError(t, err) + require.Nil(t, loginRes.AccessToken, "no method enrolled yet -> withheld, offer-all") + assert.True(t, refs.BoolValue(loginRes.ShouldOfferEmailOtpMfaSetup)) + + mfaSession := latestMfaSessionCookie(ts) + require.NotEmpty(t, mfaSession, "login must have set an mfa session cookie on the response") + req.Header.Set("Cookie", fmt.Sprintf("%s=%s", constants.MfaCookieName+"_session", mfaSession)) + // Deliberately no Authorization header — this is the whole point: the + // caller has no bearer token yet, only the cookie + email. + req.Header.Set("Authorization", "") + + setupRes, err := ts.GraphQLProvider.EmailOTPMFASetup(ctx, &model.OtpMfaSetupRequest{Email: &email}) + require.NoError(t, err, "email_otp_mfa_setup must be reachable via cookie+email with no bearer token") + require.NotNil(t, setupRes) + + authenticator, err := ts.StorageProvider.GetAuthenticatorDetailsByUserId(ctx, user.ID, constants.EnvKeyEmailOTPAuthenticator) + require.NoError(t, err) + require.NotNil(t, authenticator) + assert.Nil(t, authenticator.VerifiedAt, "setup alone must not mark the enrollment verified") + + const knownPlainOTP = "482913" + storedOTP, err := ts.StorageProvider.GetOTPByEmail(ctx, email) + require.NoError(t, err) + require.NotNil(t, storedOTP) + storedOTP.Otp = crypto.HashOTP(knownPlainOTP, cfg.JWTSecret) + storedOTP.ExpiresAt = time.Now().Add(5 * time.Minute).Unix() + _, err = ts.StorageProvider.UpsertOTP(ctx, storedOTP) + require.NoError(t, err) + + // Same MFA session cookie is still valid -- verify_otp completes the + // still-in-progress, still-withheld login. + verifyRes, err := ts.GraphQLProvider.VerifyOTP(ctx, &model.VerifyOTPRequest{Email: &email, Otp: knownPlainOTP}) + require.NoError(t, err) + require.NotNil(t, verifyRes) + require.NotNil(t, verifyRes.AccessToken, "verify_otp must issue the token that was withheld at login") + + authenticator, err = ts.StorageProvider.GetAuthenticatorDetailsByUserId(ctx, user.ID, constants.EnvKeyEmailOTPAuthenticator) + require.NoError(t, err) + require.NotNil(t, authenticator) + require.NotNil(t, authenticator.VerifiedAt, "verify_otp must mark the pending enrollment verified") +} + +// TestSMSOTPMFASetupViaMfaSessionCookie is TestEmailOTPMFASetupViaMfaSessionCookie's +// SMS twin -- confirms sms_otp_mfa_setup is reachable the same cookie-only +// way, keyed by phone_number instead of email, and that the full chain +// (withheld login -> cookie-authenticated setup -> verify_otp -> the +// withheld token being issued) closes the same as the email-OTP twin. +func TestSMSOTPMFASetupViaMfaSessionCookie(t *testing.T) { + const password = "Password@123" + + cfg := getTestConfig() + cfg.EnableMFA = true + cfg.EnableSMSOTP = true + cfg.IsSMSServiceEnabled = true + cfg.EnableMobileBasicAuthentication = true + cfg.TwilioAPISecret = "test-twilio-api-secret" + cfg.TwilioAPIKey = "test-twilio-api-key" + cfg.TwilioAccountSID = "test-twilio-account-sid" + cfg.TwilioSender = "test-twilio-sender" + ts := initTestSetup(t, cfg) + req, ctx := createContext(ts) + + mobile := fmt.Sprintf("+1%010d", time.Now().UnixNano()%10000000000) + _, err := ts.GraphQLProvider.SignUp(ctx, &model.SignUpRequest{ + PhoneNumber: &mobile, Password: password, ConfirmPassword: password, + IsMultiFactorAuthEnabled: refs.NewBoolRef(true), + }) + require.NoError(t, err) + user, err := ts.StorageProvider.GetUserByPhoneNumber(ctx, mobile) + require.NoError(t, err) + // Signup's own verification OTP is irrelevant to this test; mark the + // phone verified directly so login reaches the MFA gate instead of the + // phone-verification challenge. + now := time.Now().Unix() + user.PhoneNumberVerifiedAt = &now + _, err = ts.StorageProvider.UpdateUser(ctx, user) + require.NoError(t, err) + + loginRes, err := ts.GraphQLProvider.Login(ctx, &model.LoginRequest{PhoneNumber: &mobile, Password: password}) + require.NoError(t, err) + require.Nil(t, loginRes.AccessToken, "no method enrolled yet -> withheld, offer-all") + assert.True(t, refs.BoolValue(loginRes.ShouldOfferSmsOtpMfaSetup)) + + mfaSession := latestMfaSessionCookie(ts) + require.NotEmpty(t, mfaSession) + req.Header.Set("Cookie", fmt.Sprintf("%s=%s", constants.MfaCookieName+"_session", mfaSession)) + req.Header.Set("Authorization", "") + + setupRes, err := ts.GraphQLProvider.SMSOTPMFASetup(ctx, &model.OtpMfaSetupRequest{PhoneNumber: &mobile}) + require.NoError(t, err, "sms_otp_mfa_setup must be reachable via cookie+phone_number with no bearer token") + require.NotNil(t, setupRes) + + authenticator, err := ts.StorageProvider.GetAuthenticatorDetailsByUserId(ctx, user.ID, constants.EnvKeySMSOTPAuthenticator) + require.NoError(t, err) + require.NotNil(t, authenticator) + assert.Nil(t, authenticator.VerifiedAt, "setup alone must not mark the enrollment verified") + + // Complete the chain: the test can't intercept the outgoing SMS, so + // overwrite the stored digest with one for a known plaintext, same as + // the email-OTP twin. + const knownPlainOTP = "739104" + storedOTP, err := ts.StorageProvider.GetOTPByPhoneNumber(ctx, mobile) + require.NoError(t, err) + require.NotNil(t, storedOTP) + storedOTP.Otp = crypto.HashOTP(knownPlainOTP, cfg.JWTSecret) + storedOTP.ExpiresAt = time.Now().Add(5 * time.Minute).Unix() + _, err = ts.StorageProvider.UpsertOTP(ctx, storedOTP) + require.NoError(t, err) + + // Same MFA session cookie is still valid -- verify_otp completes the + // still-in-progress, still-withheld login. + verifyRes, err := ts.GraphQLProvider.VerifyOTP(ctx, &model.VerifyOTPRequest{PhoneNumber: &mobile, Otp: knownPlainOTP}) + require.NoError(t, err) + require.NotNil(t, verifyRes) + require.NotNil(t, verifyRes.AccessToken, "verify_otp must issue the token that was withheld at login") + + authenticator, err = ts.StorageProvider.GetAuthenticatorDetailsByUserId(ctx, user.ID, constants.EnvKeySMSOTPAuthenticator) + require.NoError(t, err) + require.NotNil(t, authenticator) + require.NotNil(t, authenticator.VerifiedAt, "verify_otp must mark the pending enrollment verified") +} + +// TestOTPMFASetupRejectsUnauthenticatedCaller confirms the new dual-mode +// resolution fails closed: a caller with neither a valid bearer token/session +// NOR a valid MFA session cookie + email/phone_number must be rejected, for +// both email_otp_mfa_setup and sms_otp_mfa_setup. +func TestOTPMFASetupRejectsUnauthenticatedCaller(t *testing.T) { + cfg := getTestConfig() + cfg.EnableEmailOTP = true + cfg.IsEmailServiceEnabled = true + cfg.EnableSMSOTP = true + cfg.IsSMSServiceEnabled = true + ts := initTestSetup(t, cfg) + _, ctx := createContext(ts) + + // No Authorization header, no MFA session cookie, no params at all. + _, err := ts.GraphQLProvider.EmailOTPMFASetup(ctx, nil) + assert.Error(t, err, "no token and no cookie/identity must be rejected") + + _, err = ts.GraphQLProvider.SMSOTPMFASetup(ctx, nil) + assert.Error(t, err, "no token and no cookie/identity must be rejected") + + _, err = ts.GraphQLProvider.TOTPMFASetup(ctx, nil) + assert.Error(t, err, "no token and no cookie/identity must be rejected") + + // email/phone_number supplied but no MFA session cookie set at all -- + // still must be rejected (params alone never authenticate). + someEmail := "no_cookie_" + uuid.NewString() + "@authorizer.dev" + _, err = ts.GraphQLProvider.EmailOTPMFASetup(ctx, &model.OtpMfaSetupRequest{Email: &someEmail}) + assert.Error(t, err, "email/phone_number without a valid mfa session cookie must be rejected") +} + +// TestVerifyOTPDoesNotAutoEnroll ensures verify_otp's new VerifiedAt-marking +// logic only fires when a pending (unverified) Authenticator row already +// exists -- i.e. only for a code sent by EmailOTPMFASetup/SMSOTPMFASetup. A +// routine login-time OTP (e.g. the pre-MFA email-verification challenge) +// that never went through setup must remain a no-op for enrollment purposes: +// it must not silently create/verify an Authenticator row. +func TestVerifyOTPDoesNotAutoEnroll(t *testing.T) { + cfg := getTestConfig() + cfg.EnableEmailOTP = true + cfg.SMTPHost = "localhost" + cfg.SMTPPort = 1025 + cfg.SMTPSenderEmail = "test@authorizer.dev" + cfg.SMTPSenderName = "Test" + cfg.SMTPLocalName = "Test" + cfg.SMTPSkipTLSVerification = true + cfg.IsEmailServiceEnabled = true + ts := initTestSetup(t, cfg) + req, ctx := createContext(ts) + + // A basic-auth user with an unverified email and no pending verification + // request: login.go's (non-MFA) "email not verified -> send OTP" branch + // fires for this, entirely independent of any MFA enrollment. + email := "plain_otp_" + uuid.NewString() + "@authorizer.dev" + user, err := ts.StorageProvider.AddUser(ctx, &schemas.User{ + Email: refs.NewStringRef(email), + SignupMethods: constants.AuthRecipeMethodBasicAuth, + }) + require.NoError(t, err) + + loginRes, err := ts.GraphQLProvider.Login(ctx, &model.LoginRequest{Email: &email, Password: "irrelevant"}) + require.NoError(t, err) + require.Nil(t, loginRes.AccessToken) + require.True(t, refs.BoolValue(loginRes.ShouldShowEmailOtpScreen)) + + const knownPlainOTP = "111222" + storedOTP, err := ts.StorageProvider.GetOTPByEmail(ctx, email) + require.NoError(t, err) + require.NotNil(t, storedOTP) + storedOTP.Otp = crypto.HashOTP(knownPlainOTP, cfg.JWTSecret) + storedOTP.ExpiresAt = time.Now().Add(5 * time.Minute).Unix() + _, err = ts.StorageProvider.UpsertOTP(ctx, storedOTP) + require.NoError(t, err) + + mfaSession := latestMfaSessionCookie(ts) + require.NotEmpty(t, mfaSession) + req.Header.Set("Cookie", fmt.Sprintf("%s=%s", constants.MfaCookieName+"_session", mfaSession)) + verifyRes, err := ts.GraphQLProvider.VerifyOTP(ctx, &model.VerifyOTPRequest{Email: &email, Otp: knownPlainOTP}) + require.NoError(t, err) + require.NotNil(t, verifyRes) + + authenticator, err := ts.StorageProvider.GetAuthenticatorDetailsByUserId(ctx, user.ID, constants.EnvKeyEmailOTPAuthenticator) + assert.Error(t, err, "no Authenticator row should exist for a plain login-time OTP that never went through setup") + assert.Nil(t, authenticator) +} diff --git a/internal/integration_tests/scim_deprovision_test.go b/internal/integration_tests/scim_deprovision_test.go index ffbbc79d..c5c848cf 100644 --- a/internal/integration_tests/scim_deprovision_test.go +++ b/internal/integration_tests/scim_deprovision_test.go @@ -100,3 +100,55 @@ func TestDeprovisionedUserRevocation(t *testing.T) { assert.Equal(t, http.StatusUnauthorized, refresh(), "refresh must be rejected for a revoked user") assert.False(t, introspect(*loginRes.AccessToken), "a revoked user's access token must introspect as inactive") } + +// TestDeprovisionedUserRevocation_AccessTokenBlockedEvenIfSessionDeleteMissed +// covers the third checkpoint the SAME way SCIM active:false actually protects +// it in production: request-serving auth (ValidateAccessToken, used by every +// GraphQL/REST resolver and the gRPC interceptor) re-checks RevokedTimestamp +// as defense-in-depth, so a deprovisioned user's held access token is rejected +// even when the session-store delete that normally invalidates it is skipped +// (e.g. a missed/failed instance in a multi-node deployment). Deliberately +// does NOT call DeleteAllUserSessions, unlike TestDeprovisionedUserRevocation +// above — that isolates this specific defense-in-depth path from the +// session-deletion path already covered there. +func TestDeprovisionedUserRevocation_AccessTokenBlockedEvenIfSessionDeleteMissed(t *testing.T) { + cfg := getTestConfig() + ts := initTestSetup(t, cfg) + req, ctx := createContext(ts) + + email := "scim_deprov_reqauth_" + uuid.New().String() + "@authorizer.dev" + password := "Password@123" + + signupRes, err := ts.GraphQLProvider.SignUp(ctx, &model.SignUpRequest{ + Email: &email, + Password: password, + ConfirmPassword: password, + }) + require.NoError(t, err) + require.NotNil(t, signupRes.AccessToken) + + callProfile := func() error { + authedReq, rErr := http.NewRequest(http.MethodPost, req.URL.String(), nil) + require.NoError(t, rErr) + authedReq.Header.Set("Authorization", "Bearer "+*signupRes.AccessToken) + ts.GinContext.Request = authedReq + _, pErr := ts.GraphQLProvider.Profile(ctx) + return pErr + } + + // Baseline: the freshly issued access token authenticates a real resolver call. + require.NoError(t, callProfile(), "access token should authenticate before deprovision") + + // Deprovision via the RevokedTimestamp field only — no session-store delete — + // to isolate the DB-level defense-in-depth from the session-deletion path. + user, err := ts.StorageProvider.GetUserByID(ctx, signupRes.User.ID) + require.NoError(t, err) + now := time.Now().Unix() + user.RevokedTimestamp = &now + _, err = ts.StorageProvider.UpdateUser(ctx, user) + require.NoError(t, err) + + // The still-live session-store entry alone must not be enough: the same + // access token must now be rejected purely on the RevokedTimestamp check. + assert.Error(t, callProfile(), "a revoked user's access token must stop authenticating requests immediately") +} diff --git a/internal/integration_tests/scim_org_isolation_test.go b/internal/integration_tests/scim_org_isolation_test.go new file mode 100644 index 00000000..a5d7aede --- /dev/null +++ b/internal/integration_tests/scim_org_isolation_test.go @@ -0,0 +1,175 @@ +package integration_tests + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "github.com/authorizerdev/authorizer/internal/graph/model" + scimhttp "github.com/authorizerdev/authorizer/internal/http_handlers/scim" + "github.com/authorizerdev/authorizer/internal/service/scim" +) + +// bootSCIMServer mounts the real inbound SCIM 2.0 handler (org resolved only +// from the bearer token, design §4.4 H6) over the same storage/memory-store +// providers as the rest of the test setup, mirroring cmd/root.go's production +// wiring. Returns the base URL. +func bootSCIMServer(t *testing.T, ts *testSetup) string { + t.Helper() + scimService := scim.New(&scim.Dependencies{ + Log: ts.Logger, + StorageProvider: ts.StorageProvider, + MemoryStoreProvider: ts.MemoryStoreProvider, + }) + scimHandler := scimhttp.New(&scimhttp.Dependencies{ + Log: ts.Logger, + Service: scimService, + }) + gin.SetMode(gin.TestMode) + r := gin.New() + scimHandler.Register(r.Group("/scim/v2")) + srv := httptest.NewServer(r) + t.Cleanup(srv.Close) + return srv.URL +} + +// scimDo issues a raw SCIM HTTP request with the given bearer token and +// decodes the JSON response body (if any). +func scimDo(t *testing.T, base, method, path, bearer string, body any) (*http.Response, map[string]any) { + t.Helper() + var reader io.Reader + if body != nil { + raw, err := json.Marshal(body) + require.NoError(t, err) + reader = bytes.NewReader(raw) + } + req, err := http.NewRequest(method, base+path, reader) + require.NoError(t, err) + if bearer != "" { + req.Header.Set("Authorization", "Bearer "+bearer) + } + req.Header.Set("Content-Type", "application/scim+json") + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + t.Cleanup(func() { _ = resp.Body.Close() }) + raw, err := io.ReadAll(resp.Body) + require.NoError(t, err) + var decoded map[string]any + if len(raw) > 0 { + require.NoError(t, json.Unmarshal(raw, &decoded), "body: %s", string(raw)) + } + return resp, decoded +} + +// TestSCIMOrgIsolation closes the coverage gap flagged in the SCIM design +// review (§4.4 H6): a SCIM bearer token is scoped to exactly one org +// server-side, and org_scoped_admin_test.go's confused-deputy matrix does not +// exercise the inbound SCIM REST surface. This proves org A's SCIM connection +// can never read, enumerate, modify, or deactivate a user who belongs only to +// org B via GET/PATCH/PUT/DELETE /scim/v2/Users/:id or the userName filter +// probe on /scim/v2/Users. +func TestSCIMOrgIsolation(t *testing.T) { + cfg := getTestConfig() + ts := initTestSetup(t, cfg) + _, ctx := createContext(ts) + base := bootSCIMServer(t, ts) + + setAdminCookie(t, ts) + + createOrg := func(prefix string) string { + org, err := ts.GraphQLProvider.CreateOrganization(ctx, &model.CreateOrganizationRequest{ + Name: prefix + "-" + uuid.NewString(), + }) + require.NoError(t, err) + return org.ID + } + + mintSCIMToken := func(orgID string) string { + resp, err := ts.GraphQLProvider.CreateScimEndpoint(ctx, &model.CreateScimEndpointRequest{OrgID: orgID}) + require.NoError(t, err) + require.NotEmpty(t, resp.Token) + return resp.Token + } + + orgA := createOrg("scim-org-a") + orgB := createOrg("scim-org-b") + tokenA := mintSCIMToken(orgA) + tokenB := mintSCIMToken(orgB) + + // Provision one user into each org via that org's own SCIM connection — + // the realistic path (Okta/Entra provisioning), not a backdoor GraphQL + // signup. + provision := func(bearer, userName string) string { + resp, decoded := scimDo(t, base, http.MethodPost, "/scim/v2/Users", bearer, map[string]any{ + "userName": userName, + "active": true, + }) + require.Equal(t, http.StatusCreated, resp.StatusCode) + id, _ := decoded["id"].(string) + require.NotEmpty(t, id) + return id + } + + userAName := "usera-" + uuid.NewString() + "@authorizer.test" + userBName := "userb-" + uuid.NewString() + "@authorizer.test" + _ = provision(tokenA, userAName) + userBID := provision(tokenB, userBName) + + // Sanity: org B's own token can see its own user. + resp, decoded := scimDo(t, base, http.MethodGet, "/scim/v2/Users/"+userBID, tokenB, nil) + require.Equal(t, http.StatusOK, resp.StatusCode) + require.Equal(t, userBName, decoded["userName"]) + + t.Run("GET org-B user via org-A token -> 404, no data leak", func(t *testing.T) { + resp, decoded := scimDo(t, base, http.MethodGet, "/scim/v2/Users/"+userBID, tokenA, nil) + require.Equal(t, http.StatusNotFound, resp.StatusCode) + require.NotEqual(t, userBName, decoded["userName"]) + }) + + t.Run("PUT org-B user via org-A token -> 404, no mutation", func(t *testing.T) { + resp, _ := scimDo(t, base, http.MethodPut, "/scim/v2/Users/"+userBID, tokenA, map[string]any{ + "userName": userBName, + "active": false, + }) + require.Equal(t, http.StatusNotFound, resp.StatusCode) + }) + + t.Run("PATCH (deactivate) org-B user via org-A token -> 404, no mutation", func(t *testing.T) { + resp, _ := scimDo(t, base, http.MethodPatch, "/scim/v2/Users/"+userBID, tokenA, map[string]any{ + "Operations": []map[string]any{ + {"op": "replace", "path": "active", "value": false}, + }, + }) + require.Equal(t, http.StatusNotFound, resp.StatusCode) + }) + + t.Run("DELETE (deprovision) org-B user via org-A token -> 404, no deactivation", func(t *testing.T) { + resp, _ := scimDo(t, base, http.MethodDelete, "/scim/v2/Users/"+userBID, tokenA, nil) + require.Equal(t, http.StatusNotFound, resp.StatusCode) + }) + + t.Run("userName filter probe via org-A token does not enumerate org-B user", func(t *testing.T) { + filter := url.QueryEscape(`userName eq "` + userBName + `"`) + resp, decoded := scimDo(t, base, http.MethodGet, + "/scim/v2/Users?filter="+filter, tokenA, nil) + require.Equal(t, http.StatusOK, resp.StatusCode) + resources, _ := decoded["Resources"].([]any) + require.Empty(t, resources, "org A must not be able to enumerate org B's user by userName") + require.Equal(t, float64(0), decoded["totalResults"]) + }) + + // Confirm org B's user survived every cross-org attempt untouched: still + // active, still fetchable with org B's own token. + resp, decoded = scimDo(t, base, http.MethodGet, "/scim/v2/Users/"+userBID, tokenB, nil) + require.Equal(t, http.StatusOK, resp.StatusCode) + require.Equal(t, userBName, decoded["userName"]) + require.Equal(t, true, decoded["active"], "org B's user must not have been deactivated by org A's cross-org attempts") +} diff --git a/internal/integration_tests/signup_test.go b/internal/integration_tests/signup_test.go index 525a2b7e..55b07dbd 100644 --- a/internal/integration_tests/signup_test.go +++ b/internal/integration_tests/signup_test.go @@ -7,6 +7,7 @@ import ( "github.com/authorizerdev/authorizer/internal/constants" "github.com/authorizerdev/authorizer/internal/graph/model" + "github.com/authorizerdev/authorizer/internal/refs" "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -187,3 +188,73 @@ func TestSignup(t *testing.T) { }) }) } + +// TestSignUpGatesToken verifies that a brand-new signup, like login, has its +// token withheld and is offered the first-time MFA setup screen when MFA is +// available server-wide, and issued a token immediately when it is not. +func TestSignUpGatesToken(t *testing.T) { + const password = "Password@123" + + t.Run("MFA available, no explicit opt-out -> token withheld, offer all", func(t *testing.T) { + cfg := getTestConfig() + cfg.EnableMFA = true + cfg.EnableTOTPLogin = true + ts := initTestSetup(t, cfg) + _, ctx := createContext(ts) + + email := "signup_gate_offer_" + uuid.New().String() + "@authorizer.dev" + res, err := ts.GraphQLProvider.SignUp(ctx, &model.SignUpRequest{ + Email: &email, Password: password, ConfirmPassword: password, + IsMultiFactorAuthEnabled: refs.NewBoolRef(true), + }) + require.NoError(t, err) + require.NotNil(t, res) + assert.Nil(t, res.AccessToken, "a brand-new signup with MFA available must withhold the token, same as login") + assert.True(t, refs.BoolValue(res.ShouldShowTotpScreen)) + assert.NotNil(t, res.AuthenticatorSecret) + }) + + t.Run("MFA not available -> token issued immediately", func(t *testing.T) { + cfg := getTestConfig() + cfg.EnableMFA = false + ts := initTestSetup(t, cfg) + _, ctx := createContext(ts) + + email := "signup_gate_none_" + uuid.New().String() + "@authorizer.dev" + res, err := ts.GraphQLProvider.SignUp(ctx, &model.SignUpRequest{ + Email: &email, Password: password, ConfirmPassword: password, + }) + require.NoError(t, err) + require.NotNil(t, res) + require.NotNil(t, res.AccessToken) + }) + + // Regression guard for finding I1 (final whole-branch review): this + // block used to be guarded by `p.Config.EnableMFA && p.Config.EnableTOTPLogin`, + // mirroring login.go's old guard. A server configured for WebAuthn-only + // enforced MFA (EnableTOTPLogin off, EnableWebauthnMFA on) skipped the + // gate entirely and issued a token to a brand-new signup unconditionally + // -- no offer, no enforcement. The gate must now run whenever MFA + // applies at all, and only the TOTP-specific parts of the response + // should be conditioned on EnableTOTPLogin. + t.Run("WebAuthn-only enforced MFA, unenrolled -> token withheld via mfaGateBlockEnroll, WebAuthn setup offered", func(t *testing.T) { + cfg := getTestConfig() + cfg.EnableMFA = true + cfg.EnableTOTPLogin = false + cfg.EnableWebauthnMFA = true + cfg.EnforceMFA = true + ts := initTestSetup(t, cfg) + _, ctx := createContext(ts) + + email := "signup_gate_webauthn_only_" + uuid.New().String() + "@authorizer.dev" + res, err := ts.GraphQLProvider.SignUp(ctx, &model.SignUpRequest{ + Email: &email, Password: password, ConfirmPassword: password, + }) + require.NoError(t, err) + require.NotNil(t, res) + assert.Nil(t, res.AccessToken, "must not issue a token to a brand-new signup on a WebAuthn-only enforced-MFA server") + assert.True(t, refs.BoolValue(res.ShouldOfferWebauthnMfaSetup)) + assert.False(t, refs.BoolValue(res.ShouldShowTotpScreen), "TOTP login is disabled server-wide; must not offer a screen the user can't complete") + assert.Nil(t, res.AuthenticatorSecret, "must not generate a TOTP enrollment when TOTP login is disabled") + }) +} diff --git a/internal/integration_tests/skip_mfa_setup_test.go b/internal/integration_tests/skip_mfa_setup_test.go index fbfb18dd..08d69c38 100644 --- a/internal/integration_tests/skip_mfa_setup_test.go +++ b/internal/integration_tests/skip_mfa_setup_test.go @@ -7,7 +7,6 @@ import ( "time" "github.com/google/uuid" - "github.com/pquerna/otp/totp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -19,27 +18,22 @@ import ( ) // TestSkipMFASetup covers the security-relevant behaviors of the -// skip_mfa_setup mutation: -// - with a valid token and MFA optional, it records HasSkippedMFASetupAt -// and a subsequent login no longer offers setup (should_offer_mfa_setup -// is false). -// - with EnforceMFA=true it is rejected with KindFailedPrecondition even -// though the caller is authenticated — enforcement is never skippable, -// and this must be re-checked server-side regardless of what the -// client believes the gate state to be. -// - with no credentials at all, it is rejected with KindUnauthenticated in -// both EnforceMFA states — proving authentication is checked before -// EnforceMFA, so the response code never leaks org-wide MFA enforcement -// to an anonymous caller. +// skip_mfa_setup mutation under the withheld-token model: +// - a valid MFA session + matching email, with MFA optional, records +// HasSkippedMFASetupAt and issues the previously-withheld access token. +// - with EnforceMFA=true it is rejected with FailedPrecondition even with +// a valid MFA session — enforcement is never skippable. +// - with no valid MFA session cookie at all, it is rejected with +// Unauthenticated in both EnforceMFA states. func TestSkipMFASetup(t *testing.T) { const password = "Password@123" - t.Run("skips setup when MFA is optional and quiets a later login", func(t *testing.T) { + t.Run("skips setup, issues the withheld token, and quiets a later login", func(t *testing.T) { cfg := getTestConfig() cfg.EnableMFA = true cfg.EnableTOTPLogin = true ts := initTestSetup(t, cfg) - _, ctx := createContext(ts) + req, ctx := createContext(ts) email := "skip_mfa_" + uuid.NewString() + "@authorizer.dev" _, err := ts.GraphQLProvider.SignUp(ctx, &model.SignUpRequest{ @@ -54,42 +48,44 @@ func TestSkipMFASetup(t *testing.T) { loginRes, err := ts.GraphQLProvider.Login(ctx, &model.LoginRequest{Email: &email, Password: password}) require.NoError(t, err) - require.NotNil(t, loginRes.AccessToken) - assert.True(t, refs.BoolValue(loginRes.ShouldOfferMfaSetup), "first login with optional MFA and no prior enrollment/skip must offer setup") + require.Nil(t, loginRes.AccessToken, "first login with optional MFA and no prior enrollment/skip must withhold the token") + require.True(t, refs.BoolValue(loginRes.ShouldShowTotpScreen)) + + // Login withholds the token behind an MFA session cookie set on the + // response (Set-Cookie), not on the request — http.Request cookies + // are not auto-updated from responses in this in-process test setup + // (see latestAppSessionCookie's doc comment). Every other MFA-session + // test in this package (verify_otp_totp_test.go, + // verify_otp_totp_lockout_test.go, webauthn_test.go) copies the + // cookie onto the request by hand for the same reason; mirror that + // here rather than relying on it propagating automatically. + mfaSession := latestMfaSessionCookie(ts) + require.NotEmpty(t, mfaSession, "login must have set an mfa session cookie on the response") + req.Header.Set("Cookie", fmt.Sprintf("%s=%s", constants.MfaCookieName+"_session", mfaSession)) - ts.GinContext.Request.Header.Set("Authorization", "Bearer "+*loginRes.AccessToken) - skipRes, err := ts.GraphQLProvider.SkipMFASetup(ctx) + skipRes, err := ts.GraphQLProvider.SkipMFASetup(ctx, &model.SkipMfaSetupRequest{Email: &email}) require.NoError(t, err) require.NotNil(t, skipRes) - assert.Equal(t, "MFA setup skipped", skipRes.Message) + require.NotNil(t, skipRes.AccessToken, "skip must issue the token that was withheld at login") + assert.NotEmpty(t, *skipRes.AccessToken) updated, err := ts.StorageProvider.GetUserByEmail(ctx, email) require.NoError(t, err) assert.NotNil(t, updated.HasSkippedMFASetupAt, "skip_mfa_setup must persist HasSkippedMFASetupAt") - ts.GinContext.Request.Header.Set("Authorization", "") secondLogin, err := ts.GraphQLProvider.Login(ctx, &model.LoginRequest{Email: &email, Password: password}) require.NoError(t, err) - require.NotNil(t, secondLogin.AccessToken) - assert.False(t, refs.BoolValue(secondLogin.ShouldOfferMfaSetup), "must not nag a user who already skipped setup") + require.NotNil(t, secondLogin.AccessToken, "a user who already skipped setup must log in normally, token issued immediately") }) - t.Run("rejects with FailedPrecondition when MFA is enforced, even with a valid token", func(t *testing.T) { + t.Run("rejects with FailedPrecondition when MFA is enforced, even with a valid mfa session", func(t *testing.T) { cfg := getTestConfig() cfg.EnableMFA = true cfg.EnableTOTPLogin = true cfg.EnforceMFA = true ts := initTestSetup(t, cfg) - require.NotNil(t, ts.AuthenticatorProvider, "TOTP must be enabled for this test") req, ctx := createContext(ts) - // Mint a genuinely valid access token the same way a real enforced-MFA - // user would end up with one: complete TOTP enrollment and verify it - // via the real VerifyOTP path (mirrors - // TestVerifyOTPTOTPThroughService), rather than fabricating a token. - // This proves the EnforceMFA rejection below is a true server-side - // re-check on an authenticated caller, not an artifact of a missing - // or invalid token. email := "skip_mfa_enforced_" + uuid.NewString() + "@authorizer.dev" now := time.Now().Unix() user, err := ts.StorageProvider.AddUser(ctx, &schemas.User{ @@ -100,27 +96,11 @@ func TestSkipMFASetup(t *testing.T) { }) require.NoError(t, err) - authConfig, err := ts.AuthenticatorProvider.Generate(ctx, user.ID) - require.NoError(t, err) - require.NotEmpty(t, authConfig.Secret) - mfaSession := uuid.NewString() - require.NoError(t, ts.MemoryStoreProvider.SetMfaSession(user.ID, mfaSession, time.Now().Add(5*time.Minute).Unix())) + require.NoError(t, ts.MemoryStoreProvider.SetMfaSession(user.ID, mfaSession, constants.MFASessionPurposeVerified, time.Now().Add(5*time.Minute).Unix())) req.Header.Set("Cookie", fmt.Sprintf("%s=%s", constants.MfaCookieName+"_session", mfaSession)) - code, err := totp.GenerateCode(authConfig.Secret, time.Now()) - require.NoError(t, err) - verifyRes, err := ts.GraphQLProvider.VerifyOTP(ctx, &model.VerifyOTPRequest{ - Email: &email, - Otp: code, - IsTotp: refs.NewBoolRef(true), - }) - require.NoError(t, err) - require.NotNil(t, verifyRes) - require.NotEmpty(t, verifyRes.AccessToken, "a valid TOTP passcode must mint a real access token") - - ts.GinContext.Request.Header.Set("Authorization", "Bearer "+*verifyRes.AccessToken) - skipRes, err := ts.GraphQLProvider.SkipMFASetup(ctx) + skipRes, err := ts.GraphQLProvider.SkipMFASetup(ctx, &model.SkipMfaSetupRequest{Email: &email}) require.Error(t, err) assert.Nil(t, skipRes) @@ -129,30 +109,134 @@ func TestSkipMFASetup(t *testing.T) { assert.Equal(t, service.KindFailedPrecondition, svcErr.Kind, "EnforceMFA must reject with FailedPrecondition, not Unauthenticated or any other kind") }) - // An unauthenticated caller (no Authorization header, no session cookie) - // must get Unauthenticated regardless of EnforceMFA. Authentication is - // checked before EnforceMFA in SkipMFASetup precisely so the response - // code never leaks whether MFA is org-enforced to a caller who hasn't - // proven who they are. Covering both EnforceMFA states proves the - // enforced case no longer leaks FailedPrecondition to an anonymous caller. for _, enforceMFA := range []bool{false, true} { - t.Run(fmt.Sprintf("rejects with Unauthenticated when caller has no credentials (EnforceMFA=%v)", enforceMFA), func(t *testing.T) { + t.Run(fmt.Sprintf("rejects with Unauthenticated when caller has no valid mfa session (EnforceMFA=%v)", enforceMFA), func(t *testing.T) { cfg := getTestConfig() cfg.EnableMFA = true cfg.EnableTOTPLogin = true cfg.EnforceMFA = enforceMFA ts := initTestSetup(t, cfg) - // createContext builds a fresh request with no Authorization - // header and no cookies set — a genuinely credential-less caller. _, ctx := createContext(ts) - skipRes, err := ts.GraphQLProvider.SkipMFASetup(ctx) + email := "skip_mfa_nosession_" + uuid.NewString() + "@authorizer.dev" + now := time.Now().Unix() + _, err := ts.StorageProvider.AddUser(ctx, &schemas.User{ + Email: refs.NewStringRef(email), + EmailVerifiedAt: &now, + SignupMethods: constants.AuthRecipeMethodBasicAuth, + IsMultiFactorAuthEnabled: refs.NewBoolRef(true), + }) + require.NoError(t, err) + + skipRes, err := ts.GraphQLProvider.SkipMFASetup(ctx, &model.SkipMfaSetupRequest{Email: &email}) require.Error(t, err) assert.Nil(t, skipRes) var svcErr *service.Error require.True(t, errors.As(err, &svcErr), "expected a *service.Error, got %T: %v", err, err) - assert.Equal(t, service.KindUnauthenticated, svcErr.Kind, "a caller with no credentials must get Unauthenticated regardless of EnforceMFA") + assert.Equal(t, service.KindUnauthenticated, svcErr.Kind, "a caller with no valid mfa session must get Unauthenticated regardless of EnforceMFA") }) } + + t.Run("rejects with Unauthenticated when no identifier is given and the session cookie does not resolve", func(t *testing.T) { + cfg := getTestConfig() + cfg.EnableMFA = true + cfg.EnableTOTPLogin = true + ts := initTestSetup(t, cfg) + req, ctx := createContext(ts) + + // With no email/phone_number, SkipMFASetup now falls back to resolving + // the account from the session cookie alone (the OAuth-return + // continuation path — GetMfaSessionOwner). A cookie that resolves to no + // stored session therefore fails with Unauthenticated, not + // InvalidArgument: there is no longer an "identifier required" path here. + req.Header.Set("Cookie", fmt.Sprintf("%s=%s", constants.MfaCookieName+"_session", uuid.NewString())) + + skipRes, err := ts.GraphQLProvider.SkipMFASetup(ctx, &model.SkipMfaSetupRequest{}) + require.Error(t, err) + assert.Nil(t, skipRes) + + var svcErr *service.Error + require.True(t, errors.As(err, &svcErr)) + assert.Equal(t, service.KindUnauthenticated, svcErr.Kind) + }) + + t.Run("rejects a Challenge session (ResendOTP/ForgotPassword) with Unauthenticated", func(t *testing.T) { + cfg := getTestConfig() + cfg.EnableMFA = true + cfg.EnableTOTPLogin = true + ts := initTestSetup(t, cfg) + req, ctx := createContext(ts) + + email := "skip_mfa_challenge_" + uuid.NewString() + "@authorizer.dev" + now := time.Now().Unix() + user, err := ts.StorageProvider.AddUser(ctx, &schemas.User{ + Email: refs.NewStringRef(email), + EmailVerifiedAt: &now, + SignupMethods: constants.AuthRecipeMethodBasicAuth, + IsMultiFactorAuthEnabled: refs.NewBoolRef(true), + }) + require.NoError(t, err) + + // A Challenge session is what ResendOTP / ForgotPassword mint for a + // caller who only supplied an email/phone number — no first factor. It + // must never be tradeable for a token, and must fail with the SAME shape + // as a missing session so the two cases are indistinguishable. + mfaSession := uuid.NewString() + require.NoError(t, ts.MemoryStoreProvider.SetMfaSession(user.ID, mfaSession, constants.MFASessionPurposeChallenge, time.Now().Add(5*time.Minute).Unix())) + req.Header.Set("Cookie", fmt.Sprintf("%s=%s", constants.MfaCookieName+"_session", mfaSession)) + + skipRes, err := ts.GraphQLProvider.SkipMFASetup(ctx, &model.SkipMfaSetupRequest{Email: &email}) + require.Error(t, err) + assert.Nil(t, skipRes) + + var svcErr *service.Error + require.True(t, errors.As(err, &svcErr), "expected a *service.Error, got %T: %v", err, err) + assert.Equal(t, service.KindUnauthenticated, svcErr.Kind, "a Challenge session must be rejected like a missing session") + + unchanged, err := ts.StorageProvider.GetUserByEmail(ctx, email) + require.NoError(t, err) + assert.Nil(t, unchanged.HasSkippedMFASetupAt, "a rejected Challenge session must not have recorded a skip") + }) + + t.Run("rejects with FailedPrecondition when the user already has a verified authenticator", func(t *testing.T) { + cfg := getTestConfig() + cfg.EnableMFA = true + cfg.EnableTOTPLogin = true + ts := initTestSetup(t, cfg) + req, ctx := createContext(ts) + + email := "skip_mfa_verified_" + uuid.NewString() + "@authorizer.dev" + now := time.Now().Unix() + user, err := ts.StorageProvider.AddUser(ctx, &schemas.User{ + Email: refs.NewStringRef(email), + EmailVerifiedAt: &now, + SignupMethods: constants.AuthRecipeMethodBasicAuth, + IsMultiFactorAuthEnabled: refs.NewBoolRef(true), + }) + require.NoError(t, err) + + // A verified TOTP authenticator puts the user in mfaGateBlockVerify — + // their own opted-in second factor. Even with a genuine Verified + // session, skip_mfa_setup must not let them bypass it. + _, err = ts.StorageProvider.AddAuthenticator(ctx, &schemas.Authenticator{ + UserID: user.ID, + Method: constants.EnvKeyTOTPAuthenticator, + Secret: "test-secret", + VerifiedAt: &now, + }) + require.NoError(t, err) + + mfaSession := uuid.NewString() + require.NoError(t, ts.MemoryStoreProvider.SetMfaSession(user.ID, mfaSession, constants.MFASessionPurposeVerified, time.Now().Add(5*time.Minute).Unix())) + req.Header.Set("Cookie", fmt.Sprintf("%s=%s", constants.MfaCookieName+"_session", mfaSession)) + + skipRes, err := ts.GraphQLProvider.SkipMFASetup(ctx, &model.SkipMfaSetupRequest{Email: &email}) + require.Error(t, err) + assert.Nil(t, skipRes) + + var svcErr *service.Error + require.True(t, errors.As(err, &svcErr), "expected a *service.Error, got %T: %v", err, err) + assert.Equal(t, service.KindFailedPrecondition, svcErr.Kind, "a user with a verified second factor must not be able to skip it") + }) } diff --git a/internal/integration_tests/test_helper.go b/internal/integration_tests/test_helper.go index c1bcab83..ed8eb8dd 100644 --- a/internal/integration_tests/test_helper.go +++ b/internal/integration_tests/test_helper.go @@ -282,6 +282,7 @@ func initTestSetup(t *testing.T, cfg *config.Config) *testSetup { tokenProvider, err := token.New(cfg, &token.Dependencies{ Log: &logger, MemoryStoreProvider: memoryStoreProvider, + StorageProvider: storageProvider, }) require.NoError(t, err) @@ -365,6 +366,7 @@ func initTestSetup(t *testing.T, cfg *config.Config) *testSetup { r.Use(httpProvider.LoggerMiddleware()) r.POST("/graphql", httpProvider.GraphqlHandler()) + r.GET("/verify_email", httpProvider.VerifyEmailHandler()) server := httptest.NewServer(r) @@ -424,3 +426,26 @@ func latestAppSessionCookie(s *testSetup) string { } return latest } + +// latestMfaSessionCookie returns the most recent value of the +// MfaCookieName+"_session" cookie written to the gin response writer in this +// test — same rationale as latestAppSessionCookie: a token-withheld MFA +// offer (e.g. Login) sets this cookie only on the response, and +// http.Request cookies are not auto-updated from responses in this +// in-process test setup, so a caller that needs to act on that session (e.g. +// skip_mfa_setup) must copy it onto the next request by hand. +func latestMfaSessionCookie(s *testSetup) string { + prefix := constants.MfaCookieName + "_session=" + latest := "" + for _, h := range s.GinContext.Writer.Header().Values("Set-Cookie") { + first := h + if i := strings.IndexByte(h, ';'); i >= 0 { + first = h[:i] + } + first = strings.TrimSpace(first) + if strings.HasPrefix(first, prefix) { + latest = strings.TrimPrefix(first, prefix) + } + } + return latest +} diff --git a/internal/integration_tests/token_exchange_test.go b/internal/integration_tests/token_exchange_test.go index f79943a6..a50cc053 100644 --- a/internal/integration_tests/token_exchange_test.go +++ b/internal/integration_tests/token_exchange_test.go @@ -38,6 +38,26 @@ func decodeJWTPayload(t *testing.T, tok string) map[string]interface{} { return claims } +// claimScope extracts a delegated/machine token's scope claim as []string. +// The claim is encoded as a JSON array (token.DelegationTokenConfig.Scope / +// AuthTokenConfig.Scope are both []string), so it decodes through +// encoding/json as []interface{}, NOT a space-separated string - a +// `claims["scope"].(string)` assertion fails silently (empty string, no +// panic), which previously made a widening regression on this exact claim +// undetectable by the test that specifically exists to catch one. +func claimScope(t *testing.T, claims map[string]interface{}) []string { + t.Helper() + raw, ok := claims["scope"].([]interface{}) + require.True(t, ok, "scope claim must be a JSON array") + out := make([]string, 0, len(raw)) + for _, v := range raw { + s, ok := v.(string) + require.True(t, ok, "scope claim entries must be strings") + out = append(out, s) + } + return out +} + // newDelegationAgent creates an active service_account client (the agent) with the // given scope ceiling, returning its client_id and plaintext secret. func newDelegationAgent(t *testing.T, ts *testSetup, ceiling string) (string, string) { @@ -88,6 +108,109 @@ func postTokenExchange(ts *testSetup, router http.Handler, form url.Values, clie return w } +// TestTokenExchangeMultiHopDelegation exercises the act-chain nesting and +// scope attenuation the map/design docs describe (app > agent > sub-agent) +// through REAL multiple JWT round-trips - not just reading actChainDepth's +// logic, since a JWT's claims (including the nested `act` object) go +// through a JSON marshal/parse round-trip at every hop, exactly the kind of +// path where a nested-map type assertion could silently truncate the chain +// without a runtime error. Only single-hop delegation had test coverage +// before this. +func TestTokenExchangeMultiHopDelegation(t *testing.T) { + cfg := getTestConfig() + ts := initTestSetup(t, cfg) + router := gin.New() + router.POST("/oauth/token", ts.HttpProvider.TokenHandler()) + + // Each hop's agent has a progressively narrower scope ceiling, so a + // widening bug would show up as a scope surviving past the hop that + // should have dropped it, not just as an empty-vs-nonempty check. Scoped + // to openid/email/profile - the standard OIDC scopes testAccessToken's + // plain signup actually carries (attenuation only ever narrows what the + // subject already has, so a ceiling of custom scopes the user's own + // token never had would just intersect to empty at hop 1). + agent1ID, agent1Secret := newDelegationAgent(t, ts, "openid,email,profile") + agent2ID, agent2Secret := newDelegationAgent(t, ts, "openid,email") + agent3ID, agent3Secret := newDelegationAgent(t, ts, "openid") + agent4ID, agent4Secret := newDelegationAgent(t, ts, "openid") + agent5ID, agent5Secret := newDelegationAgent(t, ts, "openid") + + exchange := func(t *testing.T, subjectToken, agentID, agentSecret string) *httptest.ResponseRecorder { + t.Helper() + actor := agentAccessToken(t, ts, router, agentID, agentSecret) + form := url.Values{} + form.Set("grant_type", tokenExchangeGrant) + form.Set("subject_token", subjectToken) + form.Set("subject_token_type", accessTokenType) + form.Set("actor_token", actor) + form.Set("actor_token_type", accessTokenType) + form.Set("resource", "https://api.example.com/v1") + return postTokenExchange(ts, router, form, agentID, agentSecret) + } + + userToken := testAccessToken(t, ts) + + w1 := exchange(t, userToken, agent1ID, agent1Secret) + require.Equal(t, http.StatusOK, w1.Code, "hop 1: %s", w1.Body.String()) + var resp1 map[string]interface{} + require.NoError(t, json.Unmarshal(w1.Body.Bytes(), &resp1)) + tok1, _ := resp1["access_token"].(string) + claims1 := decodeJWTPayload(t, tok1) + assert.ElementsMatch(t, []string{"openid", "email", "profile"}, claimScope(t, claims1), "hop 1 scope = agent1 ceiling ∩ user's own scope") + act1, ok := claims1["act"].(map[string]interface{}) + require.True(t, ok) + assert.Equal(t, agent1ID, act1["sub"]) + assert.Nil(t, act1["act"], "hop 1 has no prior actor") + + w2 := exchange(t, tok1, agent2ID, agent2Secret) + require.Equal(t, http.StatusOK, w2.Code, "hop 2: %s", w2.Body.String()) + var resp2 map[string]interface{} + require.NoError(t, json.Unmarshal(w2.Body.Bytes(), &resp2)) + tok2, _ := resp2["access_token"].(string) + claims2 := decodeJWTPayload(t, tok2) + assert.ElementsMatch(t, []string{"openid", "email"}, claimScope(t, claims2), "hop 2 narrows to agent2's ceiling (profile dropped)") + act2, ok := claims2["act"].(map[string]interface{}) + require.True(t, ok) + assert.Equal(t, agent2ID, act2["sub"], "immediate actor is the hop-2 agent") + act2prior, ok := act2["act"].(map[string]interface{}) + require.True(t, ok, "hop 1's actor must survive nested under hop 2 through the real JWT round-trip") + assert.Equal(t, agent1ID, act2prior["sub"], "hop 1's agent is preserved as the nested prior actor") + + w3 := exchange(t, tok2, agent3ID, agent3Secret) + require.Equal(t, http.StatusOK, w3.Code, "hop 3: %s", w3.Body.String()) + var resp3 map[string]interface{} + require.NoError(t, json.Unmarshal(w3.Body.Bytes(), &resp3)) + tok3, _ := resp3["access_token"].(string) + claims3 := decodeJWTPayload(t, tok3) + assert.ElementsMatch(t, []string{"openid"}, claimScope(t, claims3), "hop 3 narrows further to agent3's ceiling (email dropped)") + + w4 := exchange(t, tok3, agent4ID, agent4Secret) + require.Equal(t, http.StatusOK, w4.Code, "hop 4 (at the depth cap) must succeed: %s", w4.Body.String()) + var resp4 map[string]interface{} + require.NoError(t, json.Unmarshal(w4.Body.Bytes(), &resp4)) + tok4, _ := resp4["access_token"].(string) + claims4 := decodeJWTPayload(t, tok4) + // Walk the full 4-level nested act chain end to end - proves depth + // counting and nesting both survived four real JWT round-trips intact. + act4, ok := claims4["act"].(map[string]interface{}) + require.True(t, ok) + assert.Equal(t, agent4ID, act4["sub"]) + act4p1, ok := act4["act"].(map[string]interface{}) + require.True(t, ok, "depth 2 missing") + assert.Equal(t, agent3ID, act4p1["sub"]) + act4p2, ok := act4p1["act"].(map[string]interface{}) + require.True(t, ok, "depth 3 missing") + assert.Equal(t, agent2ID, act4p2["sub"]) + act4p3, ok := act4p2["act"].(map[string]interface{}) + require.True(t, ok, "depth 4 missing") + assert.Equal(t, agent1ID, act4p3["sub"]) + assert.Nil(t, act4p3["act"], "chain must be exactly 4 deep, no further nesting") + + w5 := exchange(t, tok4, agent5ID, agent5Secret) + assert.Equal(t, http.StatusBadRequest, w5.Code, "hop 5 exceeds maxActChainDepth and must be rejected: %s", w5.Body.String()) + assert.Contains(t, w5.Body.String(), "maximum allowed depth") +} + func TestTokenExchangeDelegation(t *testing.T) { cfg := getTestConfig() ts := initTestSetup(t, cfg) @@ -176,11 +299,41 @@ func TestTokenExchangeDelegation(t *testing.T) { tok, _ := resp["access_token"].(string) require.NotEmpty(t, tok) claims := decodeJWTPayload(t, tok) - scope, _ := claims["scope"].(string) - assert.NotContains(t, strings.Fields(scope), "admin", + assert.NotContains(t, claimScope(t, claims), "admin", "a scope outside the agent's ceiling must never be granted (non-widening)") }) + t.Run("deactivated_service_account_subject_cannot_seed_delegation", func(t *testing.T) { + // The subject here is a DIFFERENT service account than the agent - + // simulates a multi-hop chain where an upstream agent's own token is + // being re-exchanged as the subject_token. Deactivate it AFTER + // minting its token but before the exchange: the token is still + // cryptographically valid (unexpired, correctly signed) - proving + // this rejection actually comes from a live active-status check, not + // from token validation catching something else. + subjectClientID, subjectSecret := newDelegationAgent(t, ts, "openid,profile,email") + subjectToken := agentAccessToken(t, ts, router, subjectClientID, subjectSecret) + + subjectClient, err := ts.StorageProvider.GetClientByClientID(context.Background(), subjectClientID) + require.NoError(t, err) + subjectClient.IsActive = false + _, err = ts.StorageProvider.UpdateClient(context.Background(), subjectClient) + require.NoError(t, err) + + clientID, secret := newDelegationAgent(t, ts, "openid,profile,email") + actor := agentAccessToken(t, ts, router, clientID, secret) + form := url.Values{} + form.Set("grant_type", tokenExchangeGrant) + form.Set("subject_token", subjectToken) + form.Set("subject_token_type", accessTokenType) + form.Set("actor_token", actor) + form.Set("actor_token_type", accessTokenType) + form.Set("resource", "https://api.example.com/v1") + w := postTokenExchange(ts, router, form, clientID, secret) + assert.Equal(t, http.StatusBadRequest, w.Code, "a deactivated service-account subject must not seed a delegation: %s", w.Body.String()) + assert.Contains(t, w.Body.String(), "no longer active") + }) + t.Run("actor_token_must_belong_to_authenticated_agent", func(t *testing.T) { // A valid token that is NOT the agent's own (here a user token, whose // client_id != the agent) must be rejected as the actor (RFC 8693 §1.1). diff --git a/internal/integration_tests/verify_email_test.go b/internal/integration_tests/verify_email_test.go index 178dbe08..3c9f94b3 100644 --- a/internal/integration_tests/verify_email_test.go +++ b/internal/integration_tests/verify_email_test.go @@ -1,6 +1,8 @@ package integration_tests import ( + "net/http" + "net/url" "testing" "time" @@ -121,3 +123,109 @@ func TestVerifyEmail(t *testing.T) { assert.NotEmpty(t, verificationRes.AccessToken) }) } + +// TestVerifyEmailRESTEndpointMFAGate guards GET /verify_email - the actual +// URL the "verify your email" / magic-link-login email sends users to. It is +// a separate implementation from the GraphQL verify_email mutation (which +// TestVerifyEmail and TestMagicLinkLoginMFAGate exercise), and previously +// issued a full session unconditionally with no MFA gate check and no +// RevokedTimestamp check at all - a user could complete signup email +// verification or magic-link login and be handed working tokens regardless +// of MFA enrollment status or account revocation, entirely bypassing +// resolveMFAGate. Exercises the real HTTP handler, not the service layer +// directly, since that's exactly the boundary the bug lived at. +func TestVerifyEmailRESTEndpointMFAGate(t *testing.T) { + cfg := getTestConfig() + cfg.IsEmailServiceEnabled = true + cfg.EnableEmailVerification = true + cfg.EnableMFA = true + ts := initTestSetup(t, cfg) + _, ctx := createContext(ts) + password := "Password@123" + + httpClient := &http.Client{ + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + }, + } + + signupAndGetToken := func(t *testing.T, email string) string { + t.Helper() + _, err := ts.GraphQLProvider.SignUp(ctx, &model.SignUpRequest{ + Email: &email, + Password: password, + ConfirmPassword: password, + }) + require.NoError(t, err) + vreq, err := ts.StorageProvider.GetVerificationRequestByEmail(ctx, email, constants.VerificationTypeBasicAuthSignup) + require.NoError(t, err) + require.NotNil(t, vreq) + return vreq.Token + } + + hitVerifyEmail := func(t *testing.T, token string) *http.Response { + t.Helper() + reqURL := ts.HttpServer.URL + "/verify_email?token=" + url.QueryEscape(token) + + "&redirect_uri=" + url.QueryEscape("http://localhost:3000/callback") + resp, err := httpClient.Get(reqURL) + require.NoError(t, err) + t.Cleanup(func() { _ = resp.Body.Close() }) + return resp + } + + t.Run("MFA gate withholds tokens, matching the GraphQL mutation", func(t *testing.T) { + email := "verify_email_rest_offer_" + uuid.New().String() + "@authorizer.dev" + resp := hitVerifyEmail(t, signupAndGetToken(t, email)) + + require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode) + location := resp.Header.Get("Location") + assert.NotContains(t, location, "access_token=") + assert.Contains(t, location, "mfa_required=1") + assert.Contains(t, location, "mfa_methods=") + }) + + t.Run("revoked user is rejected, not issued a session", func(t *testing.T) { + email := "verify_email_rest_revoked_" + uuid.New().String() + "@authorizer.dev" + token := signupAndGetToken(t, email) + + user, err := ts.StorageProvider.GetUserByEmail(ctx, email) + require.NoError(t, err) + now := time.Now().Unix() + user.RevokedTimestamp = &now + _, err = ts.StorageProvider.UpdateUser(ctx, user) + require.NoError(t, err) + + resp := hitVerifyEmail(t, token) + require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode) + location := resp.Header.Get("Location") + assert.NotContains(t, location, "access_token=") + assert.Contains(t, location, "error=") + }) + + t.Run("no MFA configured still completes normally with real tokens", func(t *testing.T) { + cfgNoMFA := getTestConfig() + cfgNoMFA.IsEmailServiceEnabled = true + cfgNoMFA.EnableEmailVerification = true + tsNoMFA := initTestSetup(t, cfgNoMFA) + _, ctxNoMFA := createContext(tsNoMFA) + + email := "verify_email_rest_nomfa_" + uuid.New().String() + "@authorizer.dev" + _, err := tsNoMFA.GraphQLProvider.SignUp(ctxNoMFA, &model.SignUpRequest{ + Email: &email, + Password: password, + ConfirmPassword: password, + }) + require.NoError(t, err) + vreq, err := tsNoMFA.StorageProvider.GetVerificationRequestByEmail(ctxNoMFA, email, constants.VerificationTypeBasicAuthSignup) + require.NoError(t, err) + + reqURL := tsNoMFA.HttpServer.URL + "/verify_email?token=" + url.QueryEscape(vreq.Token) + + "&redirect_uri=" + url.QueryEscape("http://localhost:3000/callback") + resp, err := httpClient.Get(reqURL) + require.NoError(t, err) + defer resp.Body.Close() + + require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode) + assert.Contains(t, resp.Header.Get("Location"), "access_token=") + }) +} diff --git a/internal/integration_tests/verify_otp_test.go b/internal/integration_tests/verify_otp_test.go index d99178bd..9022bc76 100644 --- a/internal/integration_tests/verify_otp_test.go +++ b/internal/integration_tests/verify_otp_test.go @@ -196,6 +196,7 @@ func TestVerifyOTPNoRecord(t *testing.T) { // itself rather than reaching the no-OTP-record path this test guards. mfaSession := uuid.New().String() require.NoError(t, ts.MemoryStoreProvider.SetMfaSession(signupRes.User.ID, mfaSession, + constants.MFASessionPurposeVerified, time.Now().Add(5*time.Minute).Unix())) req.Header.Set("Cookie", fmt.Sprintf("%s=%s", constants.MfaCookieName+"_session", mfaSession)) diff --git a/internal/integration_tests/verify_otp_totp_lockout_test.go b/internal/integration_tests/verify_otp_totp_lockout_test.go index bbdd182a..b3dd420b 100644 --- a/internal/integration_tests/verify_otp_totp_lockout_test.go +++ b/internal/integration_tests/verify_otp_totp_lockout_test.go @@ -56,6 +56,7 @@ func TestVerifyOTPTOTPLockout(t *testing.T) { armMfaSession := func(userID string) { mfaSession := uuid.NewString() require.NoError(t, ts.MemoryStoreProvider.SetMfaSession(userID, mfaSession, + constants.MFASessionPurposeVerified, time.Now().Add(5*time.Minute).Unix())) req.Header.Set("Cookie", fmt.Sprintf("%s=%s", constants.MfaCookieName+"_session", mfaSession)) } diff --git a/internal/integration_tests/verify_otp_totp_test.go b/internal/integration_tests/verify_otp_totp_test.go index 1ed75c4b..20f2ade8 100644 --- a/internal/integration_tests/verify_otp_totp_test.go +++ b/internal/integration_tests/verify_otp_totp_test.go @@ -51,6 +51,7 @@ func TestVerifyOTPTOTPThroughService(t *testing.T) { armMfaSession := func() { mfaSession := uuid.NewString() require.NoError(t, ts.MemoryStoreProvider.SetMfaSession(user.ID, mfaSession, + constants.MFASessionPurposeVerified, time.Now().Add(5*time.Minute).Unix())) req.Header.Set("Cookie", fmt.Sprintf("%s=%s", constants.MfaCookieName+"_session", mfaSession)) } diff --git a/internal/integration_tests/webauthn_enforce_mfa_test.go b/internal/integration_tests/webauthn_enforce_mfa_test.go index 9f7cf31f..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/server/http_routes.go b/internal/server/http_routes.go index d9f16e16..e028d8e1 100644 --- a/internal/server/http_routes.go +++ b/internal/server/http_routes.go @@ -13,15 +13,25 @@ import ( ) // spaBuildCacheMiddleware sets cache headers for SPA build assets: -// - "index.js" / "main.css" (unhashed entry points the shell HTML loads -// by name) → no-cache, so browsers always pick up new chunk references -// after a deploy. +// - the unhashed entry points the shell HTML loads by name → no-cache, so +// browsers always pick up new chunk references after a deploy. Shared +// across web/app and web/dashboard, whose Vite configs disagree on the +// entry CSS filename: web/dashboard emits main.css, web/app emits +// index.css (its assetFileNames names the CSS after the entry chunk, +// "index", not a fixed "main") — both must be listed, or the app whose +// name doesn't match falls into the immutable branch below and its CSS +// gets cached for a year past any style fix. // - everything else (content-hashed chunks, immutable assets) → long-lived // immutable cache, since a content change produces a new filename. func spaBuildCacheMiddleware() gin.HandlerFunc { + noCacheEntryFiles := map[string]bool{ + "index.js": true, + "index.css": true, + "main.css": true, + } return func(c *gin.Context) { base := path.Base(c.Request.URL.Path) - if base == "index.js" || base == "main.css" { + if noCacheEntryFiles[base] { c.Header("Cache-Control", "no-cache, must-revalidate") } else { c.Header("Cache-Control", "public, max-age=31536000, immutable") diff --git a/internal/server/http_routes_test.go b/internal/server/http_routes_test.go new file mode 100644 index 00000000..3a556b64 --- /dev/null +++ b/internal/server/http_routes_test.go @@ -0,0 +1,52 @@ +package server + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" +) + +// TestSpaBuildCacheMiddleware guards the entry-file allowlist that decides +// whether a build asset gets a revalidate-on-every-load header or a +// year-long immutable cache. web/app and web/dashboard's Vite configs +// disagree on the entry CSS filename (index.css vs main.css) - a request +// path whose base name isn't in the allowlist silently falls into the +// immutable branch, so a real deploy fixing a style bug would leave any +// browser that already cached the old file broken for up to a year. +func TestSpaBuildCacheMiddleware(t *testing.T) { + gin.SetMode(gin.TestMode) + + cases := []struct { + path string + wantNoCache bool + }{ + {"/app/build/index.js", true}, + {"/app/build/index.css", true}, + {"/dashboard/build/index.js", true}, + {"/dashboard/build/main.css", true}, + {"/app/build/chunk-login-abc123.js", false}, + {"/app/build/assets/logo-def456.png", false}, + } + + for _, tc := range cases { + t.Run(tc.path, func(t *testing.T) { + r := gin.New() + r.Use(spaBuildCacheMiddleware()) + r.GET("/*path", func(c *gin.Context) { c.Status(http.StatusOK) }) + + req := httptest.NewRequest(http.MethodGet, tc.path, nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + cacheControl := w.Header().Get("Cache-Control") + if tc.wantNoCache { + assert.Equal(t, "no-cache, must-revalidate", cacheControl) + } else { + assert.Equal(t, "public, max-age=31536000, immutable", cacheControl) + } + }) + } +} diff --git a/internal/service/admin_users.go b/internal/service/admin_users.go index 525089d6..910e7c3f 100644 --- a/internal/service/admin_users.go +++ b/internal/service/admin_users.go @@ -30,7 +30,8 @@ import ( // MFA state that login would be unable to honor. func (p *provider) isMFAServiceAvailable() bool { c := p.Config - return c.EnableMFA && ((c.EnableEmailOTP && c.IsEmailServiceEnabled) || + return c.EnableMFA && (c.EnableWebauthnMFA || + (c.EnableEmailOTP && c.IsEmailServiceEnabled) || (c.EnableSMSOTP && c.IsSMSServiceEnabled) || c.EnableTOTPLogin) } @@ -124,6 +125,7 @@ func (p *provider) UpdateUser(ctx context.Context, meta RequestMetadata, params params.PhoneNumber == nil && params.Roles == nil && params.IsMultiFactorAuthEnabled == nil && + params.ResetMfa == nil && params.AppData == nil { log.Debug().Msg("please enter atleast one param to update") return nil, nil, fmt.Errorf("please enter atleast one param to update") @@ -180,6 +182,13 @@ func (p *provider) UpdateUser(ctx context.Context, meta RequestMetadata, params log.Debug().Msg("cannot enable multi factor authentication as no mfa method is available") return nil, nil, errors.New("cannot enable MFA: no MFA method is available on this server — ensure TOTP is enabled (do not set --disable-totp-login) or configure an email (SMTP) or SMS (Twilio) provider for OTP") } + // EnforceMFA is absolute: an admin must not be able to persist an opt-out + // while the org enforces MFA (same guard self-service update_profile.go + // already applies). + if p.Config.EnforceMFA && !refs.BoolValue(params.IsMultiFactorAuthEnabled) { + log.Debug().Msg("cannot disable multi factor authentication as it is enforced by organization") + return nil, nil, errors.New("cannot disable multi factor authentication as it is enforced by organization") + } user.IsMultiFactorAuthEnabled = params.IsMultiFactorAuthEnabled } @@ -308,6 +317,28 @@ func (p *provider) UpdateUser(ctx context.Context, meta RequestMetadata, params if rolesToSave != "" { user.Roles = rolesToSave } + + if refs.BoolValue(params.ResetMfa) { + user.MFALockedAt = nil + user.IsMultiFactorAuthEnabled = nil + user.HasSkippedMFASetupAt = nil + if err := p.StorageProvider.DeleteAuthenticatorsByUserID(ctx, user.ID); err != nil { + log.Debug().Err(err).Msg("failed to delete authenticators during MFA reset") + return nil, nil, err + } + creds, err := p.StorageProvider.ListWebauthnCredentialsByUserID(ctx, user.ID) + if err != nil { + log.Debug().Err(err).Msg("failed to list webauthn credentials during MFA reset") + return nil, nil, err + } + for _, c := range creds { + if err := p.StorageProvider.DeleteWebauthnCredential(ctx, c); err != nil { + log.Debug().Err(err).Msg("failed to delete webauthn credential during MFA reset") + return nil, nil, err + } + } + } + user, err = p.StorageProvider.UpdateUser(ctx, user) if err != nil { log.Debug().Err(err).Msg("failed UpdateUser") diff --git a/internal/service/clientauth/jwks_cache_test.go b/internal/service/clientauth/jwks_cache_test.go new file mode 100644 index 00000000..42ab432a --- /dev/null +++ b/internal/service/clientauth/jwks_cache_test.go @@ -0,0 +1,124 @@ +package clientauth + +// Tests for loadJWKS's caching contract (client_assertion.go): the H7 +// guarantee that two TrustedIssuer rows never share a cached JWKS even when +// they point at the same URL, that a cache hit avoids a second fetch, and +// that an expired/evicted entry triggers a refetch. The rest of this +// package's tests cover assertion-verification logic but never assert +// anything about the cache itself. + +import ( + "context" + "testing" + + "github.com/rs/zerolog" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/authorizerdev/authorizer/internal/config" + "github.com/authorizerdev/authorizer/internal/constants" + "github.com/authorizerdev/authorizer/internal/storage/schemas" +) + +// countingFetchURL wraps a fixed JWKS payload in a fetchURL stub that counts +// every invocation, so a test can distinguish a cache hit from a real fetch. +func countingFetchURL(jwks []byte) (fn func(ctx context.Context, rawURL string) ([]byte, error), calls *int) { + n := 0 + return func(_ context.Context, _ string) ([]byte, error) { + n++ + return jwks, nil + }, &n +} + +// jwksProvider builds a bare clientauth provider wired only with the two +// fields loadJWKS touches: MemoryStoreProvider and fetchURL. No +// StorageProvider is needed — loadJWKS never calls it. +func jwksProvider(t *testing.T, fetchURL func(ctx context.Context, rawURL string) ([]byte, error)) *provider { + t.Helper() + logger := zerolog.Nop() + p := New( + &config.Config{ClientID: "reserved", ClientSecret: "reserved-secret"}, + &Dependencies{Log: &logger, MemoryStoreProvider: newFakeMemStore()}, + ).(*provider) + p.fetchURL = fetchURL + return p +} + +func staticIssuer(id, jwksURL string) *schemas.TrustedIssuer { + return &schemas.TrustedIssuer{ + ID: id, + KeySourceType: constants.KeySourceStaticJWKSURL, + JWKSUrl: refString(jwksURL), + } +} + +func TestJWKSCache_PerIssuerIsolation(t *testing.T) { + key := genKey(t) + jwks := jwksBytes(t, &key.PublicKey, testKID) + fetchURL, calls := countingFetchURL(jwks) + p := jwksProvider(t, fetchURL) + + const sharedURL = "https://issuer.test.example.com/jwks.json" + issuerA := staticIssuer("issuer-a", sharedURL) + issuerB := staticIssuer("issuer-b", sharedURL) + + _, err := p.loadJWKS(context.Background(), issuerA) + require.NoError(t, err) + _, err = p.loadJWKS(context.Background(), issuerB) + require.NoError(t, err) + + // A cache key derived from the URL alone would let issuerB's lookup hit + // issuerA's cache entry (an H7 violation). Two rows sharing a JWKS URL must + // each fetch on their own first load. + assert.Equal(t, 2, *calls, "two distinct TrustedIssuer rows sharing a JWKS URL must each trigger their own fetch") + + store := p.MemoryStoreProvider.(*fakeMemStore) + store.mu.Lock() + _, hasA := store.cache["jwks_cache:issuer-a"] + _, hasB := store.cache["jwks_cache:issuer-b"] + store.mu.Unlock() + assert.True(t, hasA, "issuer-a must have its own cache entry") + assert.True(t, hasB, "issuer-b must have its own cache entry") +} + +func TestJWKSCache_HitAvoidsRefetch(t *testing.T) { + key := genKey(t) + jwks := jwksBytes(t, &key.PublicKey, testKID) + fetchURL, calls := countingFetchURL(jwks) + p := jwksProvider(t, fetchURL) + + issuer := staticIssuer("issuer-a", "https://issuer.test.example.com/jwks.json") + + _, err := p.loadJWKS(context.Background(), issuer) + require.NoError(t, err) + _, err = p.loadJWKS(context.Background(), issuer) + require.NoError(t, err) + + assert.Equal(t, 1, *calls, "a second load within the TTL must be served from cache, not refetched") +} + +func TestJWKSCache_RefetchesAfterEviction(t *testing.T) { + key := genKey(t) + jwks := jwksBytes(t, &key.PublicKey, testKID) + fetchURL, calls := countingFetchURL(jwks) + p := jwksProvider(t, fetchURL) + + issuer := staticIssuer("issuer-a", "https://issuer.test.example.com/jwks.json") + + _, err := p.loadJWKS(context.Background(), issuer) + require.NoError(t, err) + require.Equal(t, 1, *calls) + + // jwksCacheTTLSeconds is a hardcoded package const with no test seam to + // shorten it (finding, not a bug — see task report). Expiry is simulated by + // evicting the entry directly from the fake store, exactly what a real TTL + // store does once the deadline passes. + store := p.MemoryStoreProvider.(*fakeMemStore) + store.mu.Lock() + delete(store.cache, "jwks_cache:issuer-a") + store.mu.Unlock() + + _, err = p.loadJWKS(context.Background(), issuer) + require.NoError(t, err) + assert.Equal(t, 2, *calls, "loadJWKS must refetch once the cache entry has expired/evicted") +} diff --git a/internal/service/forgot_password.go b/internal/service/forgot_password.go index 55065df1..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..905007b7 --- /dev/null +++ b/internal/service/oauth_mfa_gate.go @@ -0,0 +1,95 @@ +// internal/service/oauth_mfa_gate.go +package service + +import ( + "context" + "net/url" + "strings" + "time" + + "github.com/authorizerdev/authorizer/internal/constants" + "github.com/authorizerdev/authorizer/internal/storage/schemas" +) + +// EvaluateMFAGateForOAuth is oauth_callback.go's entry point into the same +// gate Login/SignUp/WebauthnLoginVerify use. See interface doc comment on +// Provider.EvaluateMFAGateForOAuth. Also used by the REST VerifyEmailHandler +// (magic-link/email-verification click-through), which needs the same +// gate-then-redirect-with-mfa_required shape - name is a historical +// carryover from its original caller, not OAuth-specific. +// +// Like WebauthnLoginVerify, an OAuth/social login is only one factor +// (something you have — the provider's own session) and does not itself +// satisfy an MFA requirement. Unlike login.go, OAuth has no +// isEmailLogin/isMobileLogin concept to short-circuit into an inline +// "send the OTP now" branch, so a verified Email/SMS-OTP authenticator is +// folded directly into authenticatorVerified here — same enrollment check +// (GetAuthenticatorDetailsByUserId + VerifiedAt) login.go already uses to +// decide whether to take its own email/SMS branches. The frontend resolves +// a mfaGateBlockVerify+email_otp/sms_otp hint via ResendOTP, which sends +// the code and sets the MFA session cookie for the verify step. +func (p *provider) EvaluateMFAGateForOAuth(ctx context.Context, meta RequestMetadata, side *ResponseSideEffects, user *schemas.User) (bool, string, error) { + if user.MFALockedAt != nil { + return false, "", FailedPrecondition("your account's multi-factor authentication is locked; contact your administrator to regain access") + } + + webauthnCreds, _ := p.StorageProvider.ListWebauthnCredentialsByUserID(ctx, user.ID) + totpAuthenticator, _ := p.StorageProvider.GetAuthenticatorDetailsByUserId(ctx, user.ID, constants.EnvKeyTOTPAuthenticator) + totpVerified := totpAuthenticator != nil && totpAuthenticator.VerifiedAt != nil + emailOTPAuthenticator, _ := p.StorageProvider.GetAuthenticatorDetailsByUserId(ctx, user.ID, constants.EnvKeyEmailOTPAuthenticator) + emailOTPVerified := emailOTPAuthenticator != nil && emailOTPAuthenticator.VerifiedAt != nil + smsOTPAuthenticator, _ := p.StorageProvider.GetAuthenticatorDetailsByUserId(ctx, user.ID, constants.EnvKeySMSOTPAuthenticator) + smsOTPVerified := smsOTPAuthenticator != nil && smsOTPAuthenticator.VerifiedAt != nil + authenticatorVerified := totpVerified || len(webauthnCreds) > 0 || emailOTPVerified || smsOTPVerified + + gate := resolveMFAGate(effectiveMFAEnabled(p.Config, user), p.Config.EnforceMFA, authenticatorVerified, user.HasSkippedMFASetupAt != nil) + switch gate { + case mfaGateNone, mfaGateSkippedSetup: + return false, "", nil + } + + expiresAt := time.Now().Add(3 * time.Minute).Unix() + if err := p.setMFASession(meta, side, user.ID, expiresAt); err != nil { + return false, "", err + } + + methods := []string{} + switch gate { + case mfaGateBlockVerify: + if totpVerified { + methods = append(methods, constants.EnvKeyTOTPAuthenticator) + } + if len(webauthnCreds) > 0 { + methods = append(methods, constants.AuthRecipeMethodWebauthn) + } + if emailOTPVerified { + methods = append(methods, constants.EnvKeyEmailOTPAuthenticator) + } + if smsOTPVerified { + methods = append(methods, constants.EnvKeySMSOTPAuthenticator) + } + case mfaGateBlockEnroll, mfaGateOfferAll: + if p.Config.EnableTOTPLogin { + methods = append(methods, constants.EnvKeyTOTPAuthenticator) + } + if p.Config.EnableWebauthnMFA { + methods = append(methods, constants.AuthRecipeMethodWebauthn) + } + if p.Config.EnableEmailOTP && p.Config.IsEmailServiceEnabled { + methods = append(methods, constants.EnvKeyEmailOTPAuthenticator) + } + if p.Config.EnableSMSOTP && p.Config.IsSMSServiceEnabled { + methods = append(methods, constants.EnvKeySMSOTPAuthenticator) + } + } + q := url.Values{} + q.Set("mfa_required", "1") + q.Set("mfa_methods", strings.Join(methods, ",")) + // Deliberately no email/phone_number here: OAuth's continuation calls + // (verify_otp/skip_mfa_setup/lock_mfa) resolve the account from the MFA + // session cookie alone when no identifier is supplied — see + // MemoryStoreProvider.GetMfaSessionOwner. Putting PII in a redirect URL + // risks referrer leakage to third-party scripts, proxy/CDN access logs, + // and browser history — avoided entirely by not needing it here. + return true, q.Encode(), nil +} diff --git a/internal/service/otp_mfa_setup.go b/internal/service/otp_mfa_setup.go new file mode 100644 index 00000000..c774f175 --- /dev/null +++ b/internal/service/otp_mfa_setup.go @@ -0,0 +1,314 @@ +// internal/service/otp_mfa_setup.go +package service + +import ( + "context" + "strings" + "time" + + "github.com/gin-gonic/gin" + + "github.com/authorizerdev/authorizer/internal/audit" + "github.com/authorizerdev/authorizer/internal/constants" + "github.com/authorizerdev/authorizer/internal/cookie" + "github.com/authorizerdev/authorizer/internal/crypto" + "github.com/authorizerdev/authorizer/internal/graph/model" + "github.com/authorizerdev/authorizer/internal/refs" + "github.com/authorizerdev/authorizer/internal/storage/schemas" + "github.com/authorizerdev/authorizer/internal/utils" +) + +// resolveOTPSetupCaller resolves the caller for EmailOTPMFASetup / +// SMSOTPMFASetup / TOTPMFASetup under either of two auth modes: +// +// 1. Bearer token / session (unchanged, existing behavior) — an +// already-logged-in user adding a second factor from account settings. +// Any email/phone_number param is ignored; the token already identifies +// the user. +// 2. MFA session cookie — a caller in the token-withheld first-time-offer +// state (mfaGateOfferAll) has no bearer token yet. Falls back to the +// same cookie + email/phone_number identity-resolution pattern already +// used by SkipMFASetup/LockMFA: resolve the user by the given +// email/phone_number, then validate the MFA session cookie is actually +// theirs. +// +// Either way, re-arms a fresh MFA session before returning: verify_otp +// requires a valid MFA session cookie unconditionally, and a bearer-token +// caller (the realistic settings-screen path) has no MFA session at all +// yet — without this, setup would succeed but the immediately-following +// verify_otp call would fail with "invalid session" for anyone who isn't +// still holding a live session from a recent login-time MFA gate. A +// cookie-mode caller already has a valid session, but re-arming it too +// keeps both modes uniform and refreshes a session that may be close to +// its original expiry. +// +// Returns Unauthenticated if neither mode resolves a caller. +func (p *provider) resolveOTPSetupCaller(ctx context.Context, meta RequestMetadata, side *ResponseSideEffects, params *model.OtpMfaSetupRequest) (*schemas.User, error) { + user, err := p.resolveOTPSetupCallerIdentity(ctx, meta, params) + if err != nil { + return nil, err + } + expiresAt := time.Now().Add(3 * time.Minute).Unix() + if err := p.setMFASession(meta, side, user.ID, expiresAt); err != nil { + return nil, err + } + return user, nil +} + +// resolveOTPSetupCallerIdentity is resolveOTPSetupCaller's identity-only +// half, split out so the MFA-session re-arming above has a single call site +// rather than being duplicated across every return branch below. +func (p *provider) resolveOTPSetupCallerIdentity(ctx context.Context, meta RequestMetadata, params *model.OtpMfaSetupRequest) (*schemas.User, error) { + if tokenData, err := p.callerTokenData(ctx, meta); err == nil && tokenData != nil && tokenData.UserID != "" { + return p.StorageProvider.GetUserByID(ctx, tokenData.UserID) + } + + gc := &gin.Context{Request: meta.Request} + mfaSession, err := cookie.GetMfaSession(gc) + if err != nil { + return nil, Unauthenticated(`unauthorized`) + } + + var email, phoneNumber string + if params != nil { + email = strings.TrimSpace(refs.StringValue(params.Email)) + phoneNumber = strings.TrimSpace(refs.StringValue(params.PhoneNumber)) + } + if email == "" && phoneNumber == "" { + // No identifier supplied (OAuth-return first-time-offer): resolve the + // account from the session cookie alone. + ownerID, _, oErr := p.MemoryStoreProvider.GetMfaSessionOwner(mfaSession) + if oErr != nil { + return nil, Unauthenticated(`unauthorized`) + } + user, uErr := p.StorageProvider.GetUserByID(ctx, ownerID) + if user == nil || uErr != nil { + return nil, Unauthenticated(`unauthorized`) + } + return user, nil + } + + var user *schemas.User + if email != "" { + user, err = p.StorageProvider.GetUserByEmail(ctx, email) + } else { + user, err = p.StorageProvider.GetUserByPhoneNumber(ctx, phoneNumber) + } + if user == nil || err != nil { + return nil, Unauthenticated(`unauthorized`) + } + + if _, err := p.MemoryStoreProvider.GetMfaSession(user.ID, mfaSession); err != nil { + return nil, Unauthenticated(`unauthorized`) + } + + return user, nil +} + +// EmailOTPMFASetup sends a one-time code to the caller's own email and +// creates (or refreshes) an unverified email-OTP Authenticator row. +// Permissions: authenticated caller (bearer token) — the settings-screen +// "add a second factor" action — OR, absent a token, the MFA session cookie +// plus params.email/phone_number for a caller in the withheld first-time- +// offer state. See resolveOTPSetupCaller. +func (p *provider) EmailOTPMFASetup(ctx context.Context, meta RequestMetadata, params *model.OtpMfaSetupRequest) (*model.Response, *ResponseSideEffects, error) { + log := p.Log.With().Str("func", "EmailOTPMFASetup").Logger() + side := &ResponseSideEffects{} + + user, err := p.resolveOTPSetupCaller(ctx, meta, side, params) + if err != nil { + log.Debug().Err(err).Msg("Failed to resolve caller") + return nil, nil, err + } + + if !p.Config.EnableEmailOTP || !p.Config.IsEmailServiceEnabled { + return nil, nil, FailedPrecondition("email OTP is not available on this server") + } + + email := strings.TrimSpace(refs.StringValue(user.Email)) + if email == "" { + return nil, nil, FailedPrecondition("account has no email address to send an OTP to") + } + + expiresAt := time.Now().Add(1 * time.Minute).Unix() + otpData, err := p.generateAndStoreOTP(ctx, user, expiresAt) + if err != nil { + log.Debug().Err(err).Msg("Failed to generate otp") + return nil, nil, err + } + + if err := p.upsertUnverifiedAuthenticator(ctx, user.ID, constants.EnvKeyEmailOTPAuthenticator); err != nil { + log.Debug().Err(err).Msg("Failed to record pending enrollment") + return nil, nil, err + } + + go func() { + if err := p.EmailProvider.SendEmail([]string{email}, constants.VerificationTypeOTP, map[string]any{ + "user": user.ToMap(), + "organization": utils.GetOrganization(p.Config), + "otp": otpData.Otp, + }); err != nil { + log.Debug().Msg("Failed to send otp email") + } + }() + + p.AuditProvider.LogEvent(audit.Event{ + Action: constants.AuditMFAEnabledEvent, + Protocol: meta.Protocol, ActorID: user.ID, + ActorType: constants.AuditActorTypeUser, + ActorEmail: email, + ResourceType: constants.AuditResourceTypeUser, + ResourceID: user.ID, + IPAddress: meta.IPAddress, + UserAgent: meta.UserAgent, + }) + + return &model.Response{Message: "Check your email for the verification code"}, side, nil +} + +// SMSOTPMFASetup is EmailOTPMFASetup's SMS twin. +func (p *provider) SMSOTPMFASetup(ctx context.Context, meta RequestMetadata, params *model.OtpMfaSetupRequest) (*model.Response, *ResponseSideEffects, error) { + log := p.Log.With().Str("func", "SMSOTPMFASetup").Logger() + side := &ResponseSideEffects{} + + user, err := p.resolveOTPSetupCaller(ctx, meta, side, params) + if err != nil { + log.Debug().Err(err).Msg("Failed to resolve caller") + return nil, nil, err + } + + if !p.Config.EnableSMSOTP || !p.Config.IsSMSServiceEnabled { + return nil, nil, FailedPrecondition("SMS OTP is not available on this server") + } + + phone := strings.TrimSpace(refs.StringValue(user.PhoneNumber)) + if phone == "" { + return nil, nil, FailedPrecondition("account has no phone number to send an OTP to") + } + + expiresAt := time.Now().Add(1 * time.Minute).Unix() + otpData, err := p.generateAndStoreOTP(ctx, user, expiresAt) + if err != nil { + log.Debug().Err(err).Msg("Failed to generate otp") + return nil, nil, err + } + + if err := p.upsertUnverifiedAuthenticator(ctx, user.ID, constants.EnvKeySMSOTPAuthenticator); err != nil { + log.Debug().Err(err).Msg("Failed to record pending enrollment") + return nil, nil, err + } + + go func() { + smsBody := strings.Builder{} + smsBody.WriteString("Your verification code is: ") + smsBody.WriteString(otpData.Otp) + if err := p.SMSProvider.SendSMS(phone, smsBody.String()); err != nil { + log.Debug().Msg("Failed to send sms") + } + }() + + p.AuditProvider.LogEvent(audit.Event{ + Action: constants.AuditMFAEnabledEvent, + Protocol: meta.Protocol, ActorID: user.ID, + ActorType: constants.AuditActorTypeUser, + ActorEmail: refs.StringValue(user.Email), + ResourceType: constants.AuditResourceTypeUser, + ResourceID: user.ID, + IPAddress: meta.IPAddress, + UserAgent: meta.UserAgent, + }) + + return &model.Response{Message: "Check your phone for the verification code"}, side, nil +} + +// TOTPMFASetup generates a fresh TOTP secret/QR/recovery-codes for the +// caller to enroll as an MFA method. Unlike EmailOTPMFASetup/SMSOTPMFASetup, +// nothing is sent anywhere - generateTOTPEnrollment (login.go) already +// persists the unverified Authenticator row itself (via +// AuthenticatorProvider.Generate), so the enrollment payload is simply +// returned to the caller to scan/enter, then completed via +// VerifyOTP(is_totp: true) same as the login-gate TOTP flow. +// Permissions: same dual mode as EmailOTPMFASetup/SMSOTPMFASetup - see +// resolveOTPSetupCaller. +func (p *provider) TOTPMFASetup(ctx context.Context, meta RequestMetadata, params *model.OtpMfaSetupRequest) (*model.AuthResponse, *ResponseSideEffects, error) { + log := p.Log.With().Str("func", "TOTPMFASetup").Logger() + side := &ResponseSideEffects{} + + user, err := p.resolveOTPSetupCaller(ctx, meta, side, params) + if err != nil { + log.Debug().Err(err).Msg("Failed to resolve caller") + return nil, nil, err + } + + if !p.Config.EnableTOTPLogin { + return nil, nil, FailedPrecondition("authenticator app MFA is not available on this server") + } + + enrollment, err := p.generateTOTPEnrollment(ctx, user.ID) + if err != nil { + log.Debug().Err(err).Msg("Failed to generate totp enrollment") + return nil, nil, err + } + + p.AuditProvider.LogEvent(audit.Event{ + Action: constants.AuditMFAEnabledEvent, + Protocol: meta.Protocol, ActorID: user.ID, + ActorType: constants.AuditActorTypeUser, + ActorEmail: refs.StringValue(user.Email), + ResourceType: constants.AuditResourceTypeUser, + ResourceID: user.ID, + IPAddress: meta.IPAddress, + UserAgent: meta.UserAgent, + }) + + return &model.AuthResponse{ + Message: `Proceed to totp verification screen`, + ShouldShowTotpScreen: refs.NewBoolRef(true), + AuthenticatorScannerImage: refs.NewStringRef(enrollment.ScannerImage), + AuthenticatorSecret: refs.NewStringRef(enrollment.Secret), + AuthenticatorRecoveryCodes: enrollment.RecoveryCodes, + }, side, nil +} + +// generateAndStoreOTP is the single OTP-generation implementation shared by +// login.go's email/SMS-verification and TOTP-alternative branches, +// resend_otp.go, and this file's Email/SMS-OTP-as-MFA setup: generates a +// plaintext OTP, persists its HMAC digest via UpsertOTP (keyed by the user's +// email/phone), and returns the plaintext on the returned struct for the +// caller's email/SMS body. Callers log their own error context at the call +// site rather than this method logging internally. +func (p *provider) generateAndStoreOTP(ctx context.Context, user *schemas.User, expiresAt int64) (*schemas.OTP, error) { + otp, err := utils.GenerateOTP() + if err != nil { + return nil, err + } + otpData, err := p.StorageProvider.UpsertOTP(ctx, &schemas.OTP{ + Email: refs.StringValue(user.Email), + PhoneNumber: refs.StringValue(user.PhoneNumber), + Otp: crypto.HashOTP(otp, p.Config.JWTSecret), + ExpiresAt: expiresAt, + }) + if err != nil { + return nil, err + } + otpData.Otp = otp + return otpData, nil +} + +// upsertUnverifiedAuthenticator creates the (user, method) Authenticator row +// if absent, or leaves an existing unverified one in place (a fresh OTP was +// just sent for it — the row's Secret field is unused for OTP methods, only +// VerifiedAt matters). Never touches an already-verified row: re-running +// setup after enrollment is a no-op enrollment-wise, only the send-a-code +// side effect repeats. +func (p *provider) upsertUnverifiedAuthenticator(ctx context.Context, userID, method string) error { + existing, err := p.StorageProvider.GetAuthenticatorDetailsByUserId(ctx, userID, method) + if err == nil && existing != nil { + return nil // already exists (verified or not) — nothing to create + } + _, err = p.StorageProvider.AddAuthenticator(ctx, &schemas.Authenticator{ + UserID: userID, + Method: method, + }) + return err +} diff --git a/internal/service/provider.go b/internal/service/provider.go index 60d81804..1da8f3b5 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,34 @@ type Provider interface { // and drops all of their sessions. Requires auth. DeactivateAccount(ctx context.Context, meta RequestMetadata) (*model.Response, *ResponseSideEffects, error) - // SkipMFASetup records that the authenticated caller declined optional - // MFA setup. Fails if MFA is org-enforced. - SkipMFASetup(ctx context.Context, meta RequestMetadata) (*model.Response, *ResponseSideEffects, error) + // SkipMFASetup completes a token-withheld first-time MFA offer by + // recording the decline and issuing the previously-withheld token. + // Identified via the MFA session cookie, not a bearer token — none + // exists yet at this point in the flow. + SkipMFASetup(ctx context.Context, meta RequestMetadata, params *model.SkipMfaSetupRequest) (*model.AuthResponse, *ResponseSideEffects, error) + + // LockMFA records that the authenticated-in-progress caller lost access + // to their only MFA factor(s). Requires no verified Email/SMS OTP + // fallback exists for the user — otherwise that should be used instead. + // Does not issue a token. + LockMFA(ctx context.Context, meta RequestMetadata, params *model.LockMfaRequest) (*model.Response, *ResponseSideEffects, error) + + // EmailOTPMFASetup sends a one-time code to the caller's own email and + // begins an email-OTP MFA enrollment. Verified via VerifyOTP. Dual-mode: + // an authenticated caller (bearer token, params ignored) — the + // settings-screen action — OR, absent a token, the MFA session cookie + // plus params.email/phone_number for a caller in the withheld + // first-time-offer state. + EmailOTPMFASetup(ctx context.Context, meta RequestMetadata, params *model.OtpMfaSetupRequest) (*model.Response, *ResponseSideEffects, error) + // SMSOTPMFASetup is EmailOTPMFASetup's SMS twin. + SMSOTPMFASetup(ctx context.Context, meta RequestMetadata, params *model.OtpMfaSetupRequest) (*model.Response, *ResponseSideEffects, error) + // TOTPMFASetup generates a fresh TOTP secret/QR/recovery-codes for the + // caller to enroll as an MFA method, same dual-mode permissions as + // EmailOTPMFASetup/SMSOTPMFASetup. Unlike those, nothing is sent + // anywhere - the enrollment payload is returned directly (same shape + // login.go's gate response uses) so the caller scans the QR/enters the + // code, then completes enrollment via VerifyOTP(is_totp: true). + TOTPMFASetup(ctx context.Context, meta RequestMetadata, params *model.OtpMfaSetupRequest) (*model.AuthResponse, *ResponseSideEffects, error) // ResendVerifyEmail re-issues a pending email-verification link. Public — // response is generic to avoid account enumeration. @@ -160,6 +186,17 @@ type Provider interface { // WebauthnDeleteCredential deletes one of the authenticated caller's own // passkeys. Requires a session. Public (self-service). WebauthnDeleteCredential(ctx context.Context, meta RequestMetadata, id string) (*model.Response, error) + + // EvaluateMFAGateForOAuth runs the same MFA gate Login/SignUp/ + // WebauthnLoginVerify use, for a user who just completed an OAuth/ + // social-provider callback - or, via VerifyEmailHandler, a magic-link/ + // email-verification click-through, which needs the identical + // gate-then-redirect shape. On a withhold-group outcome it sets the MFA + // session cookie via side and returns (true, redirectSuffix) where + // redirectSuffix is the query string to append instead of the normal + // state/code params. On mfaGateNone/mfaGateSkippedSetup it returns + // (false, "") and the caller proceeds with cookie.SetSession as today. + EvaluateMFAGateForOAuth(ctx context.Context, meta RequestMetadata, side *ResponseSideEffects, user *schemas.User) (withheld bool, redirectSuffix string, err error) } // New constructs a new service provider. diff --git a/internal/service/resend_otp.go b/internal/service/resend_otp.go index 6bfd6400..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..d471284d 100644 --- a/internal/service/verify_email.go +++ b/internal/service/verify_email.go @@ -15,6 +15,7 @@ import ( "github.com/authorizerdev/authorizer/internal/refs" "github.com/authorizerdev/authorizer/internal/storage/schemas" "github.com/authorizerdev/authorizer/internal/token" + "github.com/authorizerdev/authorizer/internal/utils" ) // VerifyEmail verifies a user's email using a verification token, completing @@ -64,58 +65,151 @@ func (p *provider) VerifyEmail(ctx context.Context, meta RequestMetadata, params return nil, nil, FailedPrecondition("user access has been revoked") } - isMFAEnabled := p.Config.EnableMFA + // A single check protecting every MFA branch below, mirroring login.go — + // lockout is set only by explicit user action (lock_mfa), never inferred + // here, and must block magic-link/email-verify completion exactly like + // it blocks password login. + if user.MFALockedAt != nil { + log.Debug().Msg("User's MFA is locked, refusing login") + return nil, nil, FailedPrecondition("your account's multi-factor authentication is locked; contact your administrator to regain access") + } + + loginMethod := constants.AuthRecipeMethodBasicAuth + if verificationRequest.Identifier == constants.VerificationTypeMagicLinkLogin { + loginMethod = constants.AuthRecipeMethodMagicLinkLogin + } + isTOTPLoginEnabled := p.Config.EnableTOTPLogin + isMFAEnabled := p.Config.EnableMFA - setOTPMFaSession := func(expiresAt int64) error { - mfaSession := uuid.NewString() - err = p.MemoryStoreProvider.SetMfaSession(user.ID, mfaSession, expiresAt) + // A verified Email-OTP second factor is challenged on enrollment alone, + // mirroring login.go's identical early branch — ported here because this + // endpoint used to fall straight into the TOTP/WebAuthn-only gate below + // with no way to ever challenge an email/SMS-OTP factor at all. + emailOTPAuthenticator, _ := p.StorageProvider.GetAuthenticatorDetailsByUserId(ctx, user.ID, constants.EnvKeyEmailOTPAuthenticator) + emailOTPEnrolled := emailOTPAuthenticator != nil && emailOTPAuthenticator.VerifiedAt != nil + if effectiveMFAEnabled(p.Config, user) && isMFAEnabled && p.Config.EnableEmailOTP && p.Config.IsEmailServiceEnabled && emailOTPEnrolled { + expiresAt := time.Now().Add(1 * time.Minute).Unix() + otpData, err := p.generateAndStoreOTP(ctx, user, expiresAt) if err != nil { - log.Debug().Err(err).Msg("Failed to set mfa session") - return err + log.Debug().Err(err).Msg("Failed to generate otp") + return nil, nil, err } - for _, c := range cookie.BuildMfaSessionCookies(hostname, mfaSession, p.Config.AppCookieSecure) { - side.AddCookie(c) + if err := p.setMFASession(meta, side, user.ID, expiresAt); err != nil { + log.Debug().Err(err).Msg("Failed to set mfa session") + return nil, nil, err } - return nil + go func() { + ctx := context.WithoutCancel(ctx) + if err := p.EmailProvider.SendEmail([]string{refs.StringValue(user.Email)}, constants.VerificationTypeOTP, map[string]any{ + "user": user.ToMap(), + "organization": utils.GetOrganization(p.Config), + "otp": otpData.Otp, + }); err != nil { + log.Debug().Err(err).Msg("Failed to send otp email") + } + _ = p.EventsProvider.RegisterEvent(ctx, constants.UserLoginWebhookEvent, loginMethod, user) + }() + return &model.AuthResponse{ + Message: "Please check email inbox for the OTP", + ShouldShowEmailOtpScreen: refs.NewBoolRef(true), + }, side, nil } - - // If mfa enabled and also totp enabled - if refs.BoolValue(user.IsMultiFactorAuthEnabled) && isMFAEnabled && isTOTPLoginEnabled { - expiresAt := time.Now().Add(3 * time.Minute).Unix() - if err := setOTPMFaSession(expiresAt); err != nil { + // SMS-OTP twin of the email branch above. + smsOTPAuthenticator, _ := p.StorageProvider.GetAuthenticatorDetailsByUserId(ctx, user.ID, constants.EnvKeySMSOTPAuthenticator) + smsOTPEnrolled := smsOTPAuthenticator != nil && smsOTPAuthenticator.VerifiedAt != nil + if effectiveMFAEnabled(p.Config, user) && isMFAEnabled && p.Config.EnableSMSOTP && p.Config.IsSMSServiceEnabled && smsOTPEnrolled { + expiresAt := time.Now().Add(1 * time.Minute).Unix() + otpData, err := p.generateAndStoreOTP(ctx, user, expiresAt) + if err != nil { + log.Debug().Err(err).Msg("Failed to generate otp") + return nil, nil, err + } + if err := p.setMFASession(meta, side, user.ID, expiresAt); err != nil { log.Debug().Err(err).Msg("Failed to set mfa session") return nil, nil, err } - authenticator, err := p.StorageProvider.GetAuthenticatorDetailsByUserId(ctx, user.ID, constants.EnvKeyTOTPAuthenticator) - if err != nil || authenticator == nil || authenticator.VerifiedAt == nil { - // generate totp - // Generate a base64 URL and initiate the registration for TOTP - authConfig, err := p.AuthenticatorProvider.Generate(ctx, user.ID) - if err != nil { - log.Debug().Err(err).Msg("Failed to generate totp") + go func() { + ctx := context.WithoutCancel(ctx) + smsBody := strings.Builder{} + smsBody.WriteString("Your verification code is: ") + smsBody.WriteString(otpData.Otp) + _ = p.EventsProvider.RegisterEvent(ctx, constants.UserLoginWebhookEvent, loginMethod, user) + if err := p.SMSProvider.SendSMS(refs.StringValue(user.PhoneNumber), smsBody.String()); err != nil { + log.Debug().Err(err).Msg("Failed to send sms") + } + }() + return &model.AuthResponse{ + Message: "Please check text message for the OTP", + ShouldShowMobileOtpScreen: refs.NewBoolRef(true), + }, side, nil + } + + // Gate runs whenever MFA applies at all, exactly like login.go/signup.go — + // this used to be an ad-hoc TOTP-only check (refs.BoolValue(user.IsMultiFactorAuthEnabled) + // && isMFAEnabled && isTOTPLoginEnabled) that silently skipped WebAuthn, + // email/SMS-OTP-as-MFA, EnforceMFA, and HasSkippedMFASetupAt entirely — a + // user whose only configured factor was WebAuthn or email/SMS-OTP (or + // whose account required first-time MFA setup/enforcement) could + // complete a magic-link login or signup-email-verification with zero MFA + // challenge. Replaced with the same resolveMFAGate call every other + // entry point uses. Reaching this point means neither email-OTP nor + // SMS-OTP is the user's enrolled factor (those returned above), so + // totpVerified/hasWebauthnCredential is the correct authenticatorVerified + // set here — mirrors login.go's identical structure and reasoning. + if isMFAEnabled { + totpAuthenticator, totpErr := p.StorageProvider.GetAuthenticatorDetailsByUserId(ctx, user.ID, constants.EnvKeyTOTPAuthenticator) + totpVerified := totpErr == nil && totpAuthenticator != nil && totpAuthenticator.VerifiedAt != nil + webauthnCreds, _ := p.StorageProvider.ListWebauthnCredentialsByUserID(ctx, user.ID) + hasWebauthnCredential := len(webauthnCreds) > 0 + authenticatorVerified := totpVerified || hasWebauthnCredential + gate := resolveMFAGate( + effectiveMFAEnabled(p.Config, user), + p.Config.EnforceMFA, + authenticatorVerified, + user.HasSkippedMFASetupAt != nil, + ) + switch gate { + case mfaGateBlockVerify: + expiresAt := time.Now().Add(3 * time.Minute).Unix() + if err := p.setMFASession(meta, side, user.ID, expiresAt); err != nil { + log.Debug().Err(err).Msg("Failed to set mfa session") return nil, nil, err } - recoveryCodes := []*string{} - for _, code := range authConfig.RecoveryCodes { - recoveryCodes = append(recoveryCodes, refs.NewStringRef(code)) + res := &model.AuthResponse{Message: `Proceed to mfa verification`} + if totpVerified && isTOTPLoginEnabled { + res.ShouldShowTotpScreen = refs.NewBoolRef(true) } - // when user is first time registering for totp - res := &model.AuthResponse{ - Message: `Proceed to totp verification screen`, - ShouldShowTotpScreen: refs.NewBoolRef(true), - AuthenticatorScannerImage: refs.NewStringRef(authConfig.ScannerImage), - AuthenticatorSecret: refs.NewStringRef(authConfig.Secret), - AuthenticatorRecoveryCodes: recoveryCodes, + if hasWebauthnCredential { + res.ShouldOfferWebauthnMfaVerify = refs.NewBoolRef(true) } return res, side, nil - } else { - // when user is already register for totp + case mfaGateBlockEnroll, mfaGateOfferAll: + expiresAt := time.Now().Add(3 * time.Minute).Unix() + if err := p.setMFASession(meta, side, user.ID, expiresAt); err != nil { + log.Debug().Err(err).Msg("Failed to set mfa session") + return nil, nil, err + } res := &model.AuthResponse{ - Message: `Proceed to totp screen`, - ShouldShowTotpScreen: refs.NewBoolRef(true), + Message: `Proceed to mfa setup`, + ShouldOfferWebauthnMfaSetup: refs.NewBoolRef(p.Config.EnableWebauthnMFA), + ShouldOfferEmailOtpMfaSetup: refs.NewBoolRef(p.Config.EnableEmailOTP && p.Config.IsEmailServiceEnabled), + ShouldOfferSmsOtpMfaSetup: refs.NewBoolRef(p.Config.EnableSMSOTP && p.Config.IsSMSServiceEnabled), + } + if isTOTPLoginEnabled { + enrollment, err := p.generateTOTPEnrollment(ctx, user.ID) + if err != nil { + log.Debug().Err(err).Msg("Failed to generate totp") + return nil, nil, err + } + res.ShouldShowTotpScreen = refs.NewBoolRef(true) + res.AuthenticatorScannerImage = refs.NewStringRef(enrollment.ScannerImage) + res.AuthenticatorSecret = refs.NewStringRef(enrollment.Secret) + res.AuthenticatorRecoveryCodes = enrollment.RecoveryCodes } return res, side, nil + case mfaGateSkippedSetup, mfaGateNone: + // fall through, nothing to do } } @@ -138,11 +232,6 @@ func (p *provider) VerifyEmail(ctx context.Context, meta RequestMetadata, params return nil, nil, err } - loginMethod := constants.AuthRecipeMethodBasicAuth - if verificationRequest.Identifier == constants.VerificationTypeMagicLinkLogin { - loginMethod = constants.AuthRecipeMethodMagicLinkLogin - } - roles := strings.Split(user.Roles, ",") scope := []string{"openid", "email", "profile"} code := "" diff --git a/internal/service/verify_otp.go b/internal/service/verify_otp.go index ddc43a6d..f84f72fa 100644 --- a/internal/service/verify_otp.go +++ b/internal/service/verify_otp.go @@ -55,16 +55,34 @@ func (p *provider) VerifyOTP(ctx context.Context, meta RequestMetadata, params * email := strings.TrimSpace(refs.StringValue(params.Email)) phoneNumber := strings.TrimSpace(refs.StringValue(params.PhoneNumber)) - if email == "" && phoneNumber == "" { - log.Debug().Msg("Email or phone number is required") - return nil, nil, InvalidArgument(`email or phone number is required`) - } isEmailVerification := email != "" isMobileVerification := phoneNumber != "" log = log.With().Str("email", email).Str("phone_number", phoneNumber).Logger() // Get user by email or phone number var user *schemas.User - if isEmailVerification { + // sessionResolved is true when the caller supplied no identifier and the + // account was resolved from the MFA session cookie alone (OAuth-return MFA + // continuation, where the frontend never learns the account's email/phone). + // Session ownership is then already proven by GetMfaSessionOwner, so the + // later GetMfaSession re-check is skipped for this path. + sessionResolved := false + if email == "" && phoneNumber == "" { + ownerID, _, oErr := p.MemoryStoreProvider.GetMfaSessionOwner(mfaSession) + if oErr != nil { + log.Debug().Err(oErr).Msg("Failed to resolve mfa session owner") + return nil, nil, Unauthenticated(`invalid session`) + } + user, err = p.StorageProvider.GetUserByID(ctx, ownerID) + if user == nil || err != nil { + log.Debug().Err(err).Msg("Failed to resolve user from mfa session") + return nil, nil, Unauthenticated(`invalid session`) + } + email = strings.TrimSpace(refs.StringValue(user.Email)) + phoneNumber = strings.TrimSpace(refs.StringValue(user.PhoneNumber)) + isEmailVerification = email != "" + isMobileVerification = !isEmailVerification && phoneNumber != "" + sessionResolved = true + } else if isEmailVerification { user, err = p.StorageProvider.GetUserByEmail(ctx, refs.StringValue(params.Email)) if err != nil { log.Debug().Err(err).Msg("Failed to get user by email") @@ -92,9 +110,11 @@ func (p *provider) VerifyOTP(ctx context.Context, meta RequestMetadata, params * // attacker who only knows a victim's email - with no valid session - is // rejected before they can touch (and so exhaust) the victim's lockout // counter, closing an unauthenticated account-lockout DoS. - if _, err := p.MemoryStoreProvider.GetMfaSession(user.ID, mfaSession); err != nil { - log.Debug().Err(err).Msg("Failed to get mfa session") - return nil, nil, Unauthenticated(`invalid session`) + if !sessionResolved { + if _, err := p.MemoryStoreProvider.GetMfaSession(user.ID, mfaSession); err != nil { + log.Debug().Err(err).Msg("Failed to get mfa session") + return nil, nil, Unauthenticated(`invalid session`) + } } // Verify OTP based on TOPT or OTP @@ -151,12 +171,12 @@ func (p *provider) VerifyOTP(ctx context.Context, meta RequestMetadata, params * } else { var otp *schemas.OTP if isEmailVerification { - otp, err = p.StorageProvider.GetOTPByEmail(ctx, refs.StringValue(params.Email)) + otp, err = p.StorageProvider.GetOTPByEmail(ctx, email) if err != nil { log.Debug().Err(err).Msg("Failed to get otp request for email") } } else { - otp, err = p.StorageProvider.GetOTPByPhoneNumber(ctx, refs.StringValue(params.PhoneNumber)) + otp, err = p.StorageProvider.GetOTPByPhoneNumber(ctx, phoneNumber) if err != nil { log.Debug().Err(err).Msg("Failed to get otp request for phone number") } @@ -181,6 +201,26 @@ func (p *provider) VerifyOTP(ctx context.Context, meta RequestMetadata, params * if err := p.StorageProvider.DeleteOTP(ctx, otp); err != nil { log.Debug().Err(err).Msg("Failed to delete otp") } + + // Mark the corresponding email/SMS-OTP MFA enrollment verified, but + // ONLY when a pending (unverified) Authenticator row already exists + // for this method — i.e. the caller went through + // EmailOTPMFASetup/SMSOTPMFASetup first. A plain login-time OTP + // send/verify (login.go's pre-enrollment challenge, or a signup + // email/phone verification) never created that row, so this is a + // no-op for those: routine login-time OTP must not silently + // "enroll" anyone as MFA. + method := constants.EnvKeyEmailOTPAuthenticator + if isMobileVerification { + method = constants.EnvKeySMSOTPAuthenticator + } + if authenticator, aErr := p.StorageProvider.GetAuthenticatorDetailsByUserId(ctx, user.ID, method); aErr == nil && authenticator != nil && authenticator.VerifiedAt == nil { + now := time.Now().Unix() + authenticator.VerifiedAt = &now + if _, err := p.StorageProvider.UpdateAuthenticator(ctx, authenticator); err != nil { + log.Debug().Err(err).Msg("Failed to mark otp authenticator verified") + } + } } isSignUp := false @@ -229,6 +269,10 @@ func (p *provider) VerifyOTP(ctx context.Context, meta RequestMetadata, params * }) } + // Single-use: the OTP is verified, so drop the session to prevent replay of + // a captured cookie within its remaining TTL. + _ = p.MemoryStoreProvider.DeleteMfaSession(user.ID, mfaSession) + res, err := p.issueAuthResponse(ctx, meta, side, user, loginMethod, `OTP verified successfully.`, params.State, isSignUp) if err != nil { return nil, nil, err diff --git a/internal/service/webauthn.go b/internal/service/webauthn.go index 6c01b00a..917e17b9 100644 --- a/internal/service/webauthn.go +++ b/internal/service/webauthn.go @@ -10,6 +10,7 @@ import ( "github.com/authorizerdev/authorizer/internal/cookie" "github.com/authorizerdev/authorizer/internal/graph/model" "github.com/authorizerdev/authorizer/internal/refs" + "github.com/authorizerdev/authorizer/internal/utils" "github.com/gin-gonic/gin" ) @@ -155,28 +156,120 @@ 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 verified Email-OTP second factor is challenged on enrollment alone, + // mirroring login.go's/verify_email.go's identical early branch — ported + // here because this endpoint used to fall straight into the TOTP-only + // gate below with no way to ever challenge an email/SMS-OTP factor at + // all, silently issuing a token for a user whose only enrolled factor + // was email or SMS-OTP. + emailOTPAuthenticator, _ := p.StorageProvider.GetAuthenticatorDetailsByUserId(ctx, user.ID, constants.EnvKeyEmailOTPAuthenticator) + emailOTPEnrolled := emailOTPAuthenticator != nil && emailOTPAuthenticator.VerifiedAt != nil + if effectiveMFAEnabled(p.Config, user) && p.Config.EnableEmailOTP && p.Config.IsEmailServiceEnabled && emailOTPEnrolled { + expiresAt := time.Now().Add(1 * time.Minute).Unix() + otpData, err := p.generateAndStoreOTP(ctx, user, expiresAt) + if err != nil { + log.Debug().Err(err).Msg("Failed to generate otp") + return nil, nil, err + } + if err := p.setMFASession(meta, side, user.ID, expiresAt); err != nil { + log.Debug().Msg("Failed to set mfa session") + return nil, nil, err + } + go func() { + ctx := context.WithoutCancel(ctx) + if err := p.EmailProvider.SendEmail([]string{refs.StringValue(user.Email)}, constants.VerificationTypeOTP, map[string]any{ + "user": user.ToMap(), + "organization": utils.GetOrganization(p.Config), + "otp": otpData.Otp, + }); err != nil { + log.Debug().Err(err).Msg("Failed to send otp email") + } + _ = p.EventsProvider.RegisterEvent(ctx, constants.UserLoginWebhookEvent, constants.AuthRecipeMethodWebauthn, user) + }() + return &model.AuthResponse{ + Message: "Please check email inbox for the OTP", + ShouldShowEmailOtpScreen: refs.NewBoolRef(true), + }, side, nil + } + // SMS-OTP twin of the email branch above. + smsOTPAuthenticator, _ := p.StorageProvider.GetAuthenticatorDetailsByUserId(ctx, user.ID, constants.EnvKeySMSOTPAuthenticator) + smsOTPEnrolled := smsOTPAuthenticator != nil && smsOTPAuthenticator.VerifiedAt != nil + if effectiveMFAEnabled(p.Config, user) && p.Config.EnableSMSOTP && p.Config.IsSMSServiceEnabled && smsOTPEnrolled { + expiresAt := time.Now().Add(1 * time.Minute).Unix() + otpData, err := p.generateAndStoreOTP(ctx, user, expiresAt) + if err != nil { + log.Debug().Err(err).Msg("Failed to generate otp") + return nil, nil, err + } + if err := p.setMFASession(meta, side, user.ID, expiresAt); err != nil { + log.Debug().Msg("Failed to set mfa session") + return nil, nil, err + } + go func() { + ctx := context.WithoutCancel(ctx) + smsBody := strings.Builder{} + smsBody.WriteString("Your verification code is: ") + smsBody.WriteString(otpData.Otp) + _ = p.EventsProvider.RegisterEvent(ctx, constants.UserLoginWebhookEvent, constants.AuthRecipeMethodWebauthn, user) + if err := p.SMSProvider.SendSMS(refs.StringValue(user.PhoneNumber), smsBody.String()); err != nil { + log.Debug().Err(err).Msg("Failed to send sms") + } + }() + return &model.AuthResponse{ + Message: "Please check text message for the OTP", + ShouldShowMobileOtpScreen: refs.NewBoolRef(true), + }, side, nil + } + + // 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 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 excludes WebAuthn for a passkey-primary + // login. Reaching this point means neither email-OTP nor SMS-OTP is the + // user's enrolled factor (those returned above), so TOTP is the only + // remaining factor to check here. + 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 +283,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/mongodb/provider.go b/internal/storage/db/mongodb/provider.go index 5733dc9a..d8c4eeed 100644 --- a/internal/storage/db/mongodb/provider.go +++ b/internal/storage/db/mongodb/provider.go @@ -66,12 +66,27 @@ func NewProvider(config *config.Config, deps *Dependencies) (*provider, error) { }, options.CreateIndexes()) _ = mongodb.CreateCollection(ctx, schemas.Collections.VerificationRequest, options.CreateCollection()) verificationRequestCollection := mongodb.Collection(schemas.Collections.VerificationRequest, options.Collection()) - _, _ = verificationRequestCollection.Indexes().CreateMany(ctx, []mongo.IndexModel{ - { - Keys: bson.M{"email": 1, "identifier": 1}, + if _, err := verificationRequestCollection.Indexes().CreateMany(ctx, []mongo.IndexModel{ + { + // Compound index keys MUST use the ordered bson.D — the driver + // rejects a multi-key bson.M ("multi-key map passed in for ordered + // parameter keys"), so this unique constraint was silently never + // created; same bug already found and fixed for the authenticator + // index below. + Keys: bson.D{{Key: "email", Value: 1}, {Key: "identifier", Value: 1}}, Options: options.Index().SetUnique(true).SetSparse(true), }, - }, options.CreateIndexes()) + }, options.CreateIndexes()); err != nil { + // Unlike the rest of this file's index creation, this one is worth a + // loud warning rather than a silent discard: a database that already + // accumulated duplicate (email, identifier) rows from the bson.M bug + // this replaces will fail this unique-index build and stay + // unprotected until an operator dedupes and retries - swallowing the + // error would hide that a fresh install still needs attention. + if deps != nil && deps.Log != nil { + deps.Log.Warn().Err(err).Msg("failed to create unique index on verification_requests(email, identifier) - if this database has pre-existing duplicates, dedupe them and restart") + } + } _, _ = verificationRequestCollection.Indexes().CreateMany(ctx, []mongo.IndexModel{ { Keys: bson.M{"token": 1}, @@ -151,7 +166,8 @@ func NewProvider(config *config.Config, deps *Dependencies) (*provider, error) { sessionTokenCollection := mongodb.Collection(schemas.Collections.SessionToken, options.Collection()) _, _ = sessionTokenCollection.Indexes().CreateMany(ctx, []mongo.IndexModel{ { - Keys: bson.M{"user_id": 1, "key_name": 1}, + // bson.D, not bson.M - see the verification-request index comment above. + Keys: bson.D{{Key: "user_id", Value: 1}, {Key: "key_name", Value: 1}}, Options: options.Index().SetSparse(true), }, { @@ -165,7 +181,8 @@ func NewProvider(config *config.Config, deps *Dependencies) (*provider, error) { mfaSessionCollection := mongodb.Collection(schemas.Collections.MFASession, options.Collection()) _, _ = mfaSessionCollection.Indexes().CreateMany(ctx, []mongo.IndexModel{ { - Keys: bson.M{"user_id": 1, "key_name": 1}, + // bson.D, not bson.M - see the verification-request index comment above. + Keys: bson.D{{Key: "user_id", Value: 1}, {Key: "key_name", Value: 1}}, Options: options.Index().SetSparse(true), }, { diff --git a/internal/storage/db/provider_template/authenticator.go b/internal/storage/db/provider_template/authenticator.go index 5bc5702e..d0f3ef61 100644 --- a/internal/storage/db/provider_template/authenticator.go +++ b/internal/storage/db/provider_template/authenticator.go @@ -32,3 +32,9 @@ func (p *provider) GetAuthenticatorDetailsByUserId(ctx context.Context, userId s var authenticators *schemas.Authenticator return authenticators, nil } + +// DeleteAuthenticatorsByUserID removes every authenticator row for a user. +// Used by admin MFA reset. +func (p *provider) DeleteAuthenticatorsByUserID(ctx context.Context, userID string) error { + return nil +} diff --git a/internal/storage/db/sql/authenticator.go b/internal/storage/db/sql/authenticator.go index b1f71aac..1b221a78 100644 --- a/internal/storage/db/sql/authenticator.go +++ b/internal/storage/db/sql/authenticator.go @@ -54,3 +54,9 @@ func (p *provider) GetAuthenticatorDetailsByUserId(ctx context.Context, userId s } return &authenticators, nil } + +// DeleteAuthenticatorsByUserID removes every authenticator row for a user. +// Used by admin MFA reset. +func (p *provider) DeleteAuthenticatorsByUserID(ctx context.Context, userID string) error { + return p.db.Where("user_id = ?", userID).Delete(&schemas.Authenticator{}).Error +} diff --git a/internal/storage/db/sql/provider_migration_test.go b/internal/storage/db/sql/provider_migration_test.go index cc23d834..bfc621d6 100644 --- a/internal/storage/db/sql/provider_migration_test.go +++ b/internal/storage/db/sql/provider_migration_test.go @@ -263,3 +263,23 @@ func TestStaleUniqueConstraintMigration(t *testing.T) { }) } } + +// TestOAuthStateTableName asserts the SQL OAuthState table is named +// authorizer_oauth_states, not the authorizer_o_auth_states GORM's naming +// strategy would otherwise derive from the struct name (it splits "OAuth" +// into "O"+"Auth"). Every other storage provider already uses +// authorizer_oauth_states; schemas.OAuthState.TableName() keeps SQL in sync. +func TestOAuthStateTableName(t *testing.T) { + for _, dbType := range sqlMigrationTestDBTypes() { + t.Run(dbType, func(t *testing.T) { + cfg := sqlMigrationTestConfig(t, dbType) + p, err := NewProvider(cfg, sqlTestDeps(t)) + require.NoError(t, err) + + assert.True(t, p.db.Migrator().HasTable(schemas.Collections.OAuthState), + "authorizer_oauth_states should exist") + assert.False(t, p.db.Migrator().HasTable(schemas.Prefix+"o_auth_states"), + "mangled authorizer_o_auth_states should not exist") + }) + } +} diff --git a/internal/storage/provider.go b/internal/storage/provider.go index b00af17a..dc5505ea 100644 --- a/internal/storage/provider.go +++ b/internal/storage/provider.go @@ -130,6 +130,9 @@ type Provider interface { // GetAuthenticatorDetailsByUserId retrieves details of an authenticator document based on user ID and authenticator type. // If found, the authenticator document is returned, or an error if not found or an error occurs during the retrieval. GetAuthenticatorDetailsByUserId(ctx context.Context, userId string, authenticatorType string) (*schemas.Authenticator, error) + // DeleteAuthenticatorsByUserID removes every authenticator row (TOTP, + // email OTP, SMS OTP) for a user. Used by admin MFA reset. + DeleteAuthenticatorsByUserID(ctx context.Context, userID string) error // Session Token methods (for database-backed memory store) // AddSessionToken adds a session token to the database diff --git a/internal/storage/provider_test.go b/internal/storage/provider_test.go index b5f9c686..3fffd326 100644 --- a/internal/storage/provider_test.go +++ b/internal/storage/provider_test.go @@ -161,7 +161,7 @@ func TestStorageProvider(t *testing.T) { }) t.Run("Verification Request Operations", func(t *testing.T) { - testVerificationRequestOperations(t, ctx, provider) + testVerificationRequestOperations(t, ctx, provider, dbType) }) t.Run("Webhook Operations", func(t *testing.T) { @@ -570,7 +570,7 @@ func testUserOperations(t *testing.T, ctx context.Context, provider Provider, db } -func testVerificationRequestOperations(t *testing.T, ctx context.Context, provider Provider) { +func testVerificationRequestOperations(t *testing.T, ctx context.Context, provider Provider, dbType string) { vr := &schemas.VerificationRequest{ Token: uuid.New().String(), Email: "test_" + uuid.New().String() + "@test.com", @@ -621,6 +621,42 @@ func testVerificationRequestOperations(t *testing.T, ctx context.Context, provid assert.Equal(t, vr.RedirectURI, listed.RedirectURI) assert.Equal(t, vr.ExpiresAt, listed.ExpiresAt) + // A second AddVerificationRequest for the same (email, identifier) - the + // normal resend-verification-email flow always deletes the old request + // first (see resend_verify_email.go), so this exercises what happens if + // that invariant is ever violated (e.g. a caller bug, or a race between + // two concurrent resends). Backend behavior intentionally differs here: + // SQL upserts on conflict (clause.OnConflict...DoUpdates, so the second + // call succeeds and replaces the pending request in place); Mongo has no + // such upsert logic, so its unique index must hard-reject instead, or a + // second silent row would accumulate. + dupToken := uuid.New().String() + dup, dupErr := provider.AddVerificationRequest(ctx, &schemas.VerificationRequest{ + Token: dupToken, + Email: vr.Email, + ExpiresAt: time.Now().Add(24 * time.Hour).Unix(), + Identifier: vr.Identifier, + Nonce: uuid.New().String(), + RedirectURI: "https://app.example.com/callback", + }) + if isSQLTestDB(dbType) { + assert.NoError(t, dupErr, "SQL upserts a duplicate (email, identifier) request rather than erroring") + refetched, err := provider.GetVerificationRequestByEmail(ctx, vr.Email, vr.Identifier) + require.NoError(t, err) + assert.Equal(t, dupToken, refetched.Token, "upsert must replace the pending request, not add a second one") + } else if dbType == constants.DbTypeMongoDB { + // Regression guard: this compound index used a multi-key bson.M, which + // the driver silently rejects at CreateIndexes time - the constraint + // was never actually created, so this duplicate would have been + // accepted, leaving two pending requests for the same identity. + assert.Error(t, dupErr, "duplicate (email, identifier) verification request must be rejected") + } else if dupErr == nil { + // Cassandra/DynamoDB/ArangoDB/Couchbase: neither upsert nor a unique + // constraint exist yet for this pair - known gap, not fixed here. + // Clean up the extra row so it doesn't leak into other subtests. + _ = provider.DeleteVerificationRequest(ctx, dup) + } + // Test DeleteVerificationRequest err = provider.DeleteVerificationRequest(ctx, vr) assert.NoError(t, err) diff --git a/internal/storage/schemas/oauth_state.go b/internal/storage/schemas/oauth_state.go index 3eec5e71..7265465d 100644 --- a/internal/storage/schemas/oauth_state.go +++ b/internal/storage/schemas/oauth_state.go @@ -1,5 +1,13 @@ package schemas +// TableName pins the SQL table name to authorizer_oauth_states. Without this, +// GORM's naming strategy derives "o_auth_states" from the struct name (a known +// GORM quirk splitting "OAuth" into "O"+"Auth"), diverging from the +// authorizer_oauth_states name every other storage provider uses. +func (OAuthState) TableName() string { + return Collections.OAuthState +} + // OAuthState model for storing OAuth state in database // This replaces the in-memory storage for OAuth state when Redis is not configured type OAuthState struct { diff --git a/internal/storage/schemas/user.go b/internal/storage/schemas/user.go index 554c4364..e8f464de 100644 --- a/internal/storage/schemas/user.go +++ b/internal/storage/schemas/user.go @@ -37,10 +37,15 @@ type User struct { // HasSkippedMFASetupAt is set the moment a user explicitly skips the // optional MFA setup prompt shown at login (never set when EnforceMFA is // on — skip is not offered in that mode). Nil means "never skipped." - HasSkippedMFASetupAt *int64 `json:"has_skipped_mfa_setup_at" bson:"has_skipped_mfa_setup_at" cql:"has_skipped_mfa_setup_at" dynamo:"has_skipped_mfa_setup_at"` - UpdatedAt int64 `json:"updated_at" bson:"updated_at" cql:"updated_at" dynamo:"updated_at"` - CreatedAt int64 `json:"created_at" bson:"created_at" cql:"created_at" dynamo:"created_at"` - AppData *string `json:"app_data" bson:"app_data" cql:"app_data" dynamo:"app_data"` + HasSkippedMFASetupAt *int64 `json:"has_skipped_mfa_setup_at" bson:"has_skipped_mfa_setup_at" cql:"has_skipped_mfa_setup_at" dynamo:"has_skipped_mfa_setup_at"` + // MFALockedAt is set when the user explicitly reports losing access to + // their only MFA factor(s) (no verified Email/SMS OTP fallback + // enrolled) via lock_mfa. Nil means not locked. Only an admin clearing + // it via _update_user{reset_mfa: true} removes it — see admin_users.go. + MFALockedAt *int64 `json:"mfa_locked_at" bson:"mfa_locked_at" cql:"mfa_locked_at" dynamo:"mfa_locked_at"` + UpdatedAt int64 `json:"updated_at" bson:"updated_at" cql:"updated_at" dynamo:"updated_at"` + CreatedAt int64 `json:"created_at" bson:"created_at" cql:"created_at" dynamo:"created_at"` + AppData *string `json:"app_data" bson:"app_data" cql:"app_data" dynamo:"app_data"` // ExternalID is the stable key an external IdP (SCIM/SSO) assigns to this // user. It is nullable — only IdP-provisioned users carry one. For SCIM it @@ -85,6 +90,7 @@ func (user *User) AsAPIUser() *model.User { RevokedTimestamp: user.RevokedTimestamp, IsMultiFactorAuthEnabled: user.IsMultiFactorAuthEnabled, HasSkippedMfaSetupAt: user.HasSkippedMFASetupAt, + MfaLockedAt: user.MFALockedAt, CreatedAt: refs.NewInt64Ref(user.CreatedAt), UpdatedAt: refs.NewInt64Ref(user.UpdatedAt), AppData: appDataMap, diff --git a/internal/token/auth_token.go b/internal/token/auth_token.go index dbf3fb94..9242b65b 100644 --- a/internal/token/auth_token.go +++ b/internal/token/auth_token.go @@ -383,6 +383,11 @@ func (p *provider) ValidateAccessToken(gc *gin.Context, accessToken string) (map return res, fmt.Errorf(`unauthorized`) } + if p.userIsRevoked(gc, userID) { + p.dependencies.Log.Debug().Str("user_id", userID).Msg("access token rejected: user revoked") + return res, fmt.Errorf(`unauthorized: user revoked`) + } + hostname := parsers.GetHost(gc) if ok, err := p.ValidateJWTClaims(res, &AuthTokenConfig{ HostName: hostname, @@ -484,9 +489,35 @@ func (p *provider) ValidateBrowserSession(gc *gin.Context, encryptedSession stri return nil, fmt.Errorf(`unauthorized: token expired`) } + if p.userIsRevoked(gc, res.Subject) { + p.dependencies.Log.Debug().Str("user_id", res.Subject).Msg("browser session rejected: user revoked") + return nil, fmt.Errorf(`unauthorized: user revoked`) + } + return &res, nil } +// userIsRevoked re-checks the DB RevokedTimestamp for a user resolved from an +// already-issued access token or browser session. This is defense-in-depth: +// the session-store deletion SCIM deactivate() (and account deactivation) +// perform is the primary revocation mechanism for these stateful tokens, but +// if that delete was missed or failed on this instance, a held token would +// otherwise keep authenticating requests until its natural exp. Mirrors the +// same demote-only pattern used by introspect.go/token.go/login.go: a lookup +// failure never blocks a request that otherwise validated (fail open on DB +// errors so a transient storage blip can't take down every authenticated +// request), only a confirmed RevokedTimestamp does. +func (p *provider) userIsRevoked(gc *gin.Context, userID string) bool { + if p.dependencies.StorageProvider == nil || userID == "" { + return false + } + user, err := p.dependencies.StorageProvider.GetUserByID(gc, userID) + if err != nil || user == nil { + return false + } + return user.RevokedTimestamp != nil +} + // CreateIDToken util to create the OIDC ID token JWT, based on user // information, roles config and CUSTOM_ACCESS_TOKEN_SCRIPT. // See the in-function block comment for the at_hash / c_hash / nonce diff --git a/internal/token/provider.go b/internal/token/provider.go index 1064d2ae..d32213e1 100644 --- a/internal/token/provider.go +++ b/internal/token/provider.go @@ -10,12 +10,19 @@ import ( "github.com/authorizerdev/authorizer/internal/config" "github.com/authorizerdev/authorizer/internal/memory_store" + "github.com/authorizerdev/authorizer/internal/storage" ) // Dependencies struct for token provider type Dependencies struct { Log *zerolog.Logger MemoryStoreProvider memory_store.Provider + // StorageProvider backs the revocation re-check in ValidateAccessToken / + // ValidateBrowserSession: defense-in-depth so a deprovisioned user + // (RevokedTimestamp set — SCIM active:false, account deactivation) loses + // request-serving access even if the session-store delete that normally + // invalidates the token was missed or failed on this instance. + StorageProvider storage.Provider } type provider struct { diff --git a/proto/authorizer/v1/admin.proto b/proto/authorizer/v1/admin.proto index 638121c7..cc829ce7 100644 --- a/proto/authorizer/v1/admin.proto +++ b/proto/authorizer/v1/admin.proto @@ -501,6 +501,11 @@ message UpdateUserRequest { repeated string roles = 13; optional bool is_multi_factor_auth_enabled = 14; AppData app_data = 15; + // Mirrors UpdateUserRequest.reset_mfa in GraphQL — see schema.graphqls' + // doc comment on that field for exact semantics (clears mfa_locked_at, + // is_multi_factor_auth_enabled, has_skipped_mfa_setup_at, deletes all + // authenticators/passkeys). + optional bool reset_mfa = 16; } message UpdateUserResponse { User user = 1; diff --git a/proto/authorizer/v1/authorizer.proto b/proto/authorizer/v1/authorizer.proto index 8ad913b7..bfafe41f 100644 --- a/proto/authorizer/v1/authorizer.proto +++ b/proto/authorizer/v1/authorizer.proto @@ -2,7 +2,8 @@ // public API. Method names match the GraphQL operation names 1:1 // (snake_case in GraphQL → PascalCase in proto): Signup, Login, // MagicLinkLogin, VerifyEmail, ResendVerifyEmail, ForgotPassword, -// ResetPassword, VerifyOtp, ResendOtp, UpdateProfile, DeactivateAccount, +// ResetPassword, VerifyOtp, ResendOtp, SkipMfaSetup, LockMfa, +// EmailOtpMfaSetup, SmsOtpMfaSetup, UpdateProfile, DeactivateAccount, // Revoke, Meta, Session, Profile, ValidateJwtToken, ValidateSession, // CheckPermissions, ListPermissions, Logout. // @@ -107,6 +108,65 @@ service AuthorizerService { option (authorizer.v1.public) = true; } + // SkipMfaSetup completes an in-progress, token-withheld MFA offer by + // recording that the caller explicitly declined it, then issues the + // access token that was withheld. Identified by the MFA session cookie + // (set when the offer screen was returned) plus email/phone_number to + // resolve the pending user — same identification pattern as VerifyOtp. + // Fails with FAILED_PRECONDITION if MFA is organization-enforced + // (enforce-mfa) — enforcement is never skippable. + rpc SkipMfaSetup(SkipMfaSetupRequest) returns (AuthResponse) { + option (google.api.http) = { + post: "/v1/skip_mfa_setup" + body: "*" + }; + option (authorizer.v1.public) = true; + option (authorizer.v1.audit_log) = true; + } + + // LockMfa records that the caller lost access to their only MFA + // factor(s). Only allowed when the caller has NO verified Email/SMS OTP + // fallback enrolled — if one exists, use it instead of locking. Does not + // issue a token; the account requires admin recovery afterward. + rpc LockMfa(LockMfaRequest) returns (LockMfaResponse) { + option (google.api.http) = { + post: "/v1/lock_mfa" + body: "*" + }; + option (authorizer.v1.public) = true; + option (authorizer.v1.audit_log) = true; + } + + // EmailOtpMfaSetup sends a one-time code to the caller's own email and + // creates an unverified email-OTP MFA enrollment. Dual-mode permissions: + // (a) an authenticated caller (bearer token) — the settings-screen action + // for an ALREADY-logged-in user adding a second factor; the request body is + // unused in this mode. (b) a caller in the withheld first-time-offer + // state, with no bearer token yet — identified by the MFA session cookie + // plus email/phone_number, same pattern as SkipMfaSetup. Either mode + // reuses the same underlying Authenticator row once VerifyOtp marks it + // verified. + rpc EmailOtpMfaSetup(EmailOtpMfaSetupRequest) returns (EmailOtpMfaSetupResponse) { + option (google.api.http) = { + post: "/v1/email_otp_mfa_setup" + body: "*" + }; + option (authorizer.v1.public) = true; + option (authorizer.v1.audit_log) = true; + } + + // SmsOtpMfaSetup sends a one-time code to the caller's own phone number + // and creates an unverified SMS-OTP MFA enrollment. Same dual-mode + // permissions and relationship to VerifyOtp as EmailOtpMfaSetup. + rpc SmsOtpMfaSetup(SmsOtpMfaSetupRequest) returns (SmsOtpMfaSetupResponse) { + option (google.api.http) = { + post: "/v1/sms_otp_mfa_setup" + body: "*" + }; + option (authorizer.v1.public) = true; + option (authorizer.v1.audit_log) = true; + } + // === Password lifecycle === rpc ForgotPassword(ForgotPasswordRequest) returns (ForgotPasswordResponse) { @@ -328,6 +388,43 @@ message ResendOtpResponse { string message = 1; } +message SkipMfaSetupRequest { + // Either email or phone_number is required, to resolve which user's MFA + // session cookie this is. + string email = 1 [(buf.validate.field).string.max_len = 320]; + string phone_number = 2 [(buf.validate.field).string.max_len = 32]; + string state = 3; +} +message LockMfaRequest { + // Either email or phone_number is required, to resolve which user's MFA + // session cookie this is — same pattern as SkipMfaSetupRequest. + string email = 1 [(buf.validate.field).string.max_len = 320]; + string phone_number = 2 [(buf.validate.field).string.max_len = 32]; +} +message LockMfaResponse { + string message = 1; +} +message EmailOtpMfaSetupRequest { + // Only used in the MFA-session-cookie mode (a caller in the withheld + // first-time-offer state, with no bearer token yet) to resolve which + // user's MFA session cookie this is — same pattern as SkipMfaSetupRequest + // / LockMfaRequest. Ignored when the caller has a valid bearer + // token/session, which already identifies the user. + string email = 1 [(buf.validate.field).string.max_len = 320]; + string phone_number = 2 [(buf.validate.field).string.max_len = 32]; +} +message EmailOtpMfaSetupResponse { + string message = 1; +} +message SmsOtpMfaSetupRequest { + // Same dual-mode identification semantics as EmailOtpMfaSetupRequest. + string email = 1 [(buf.validate.field).string.max_len = 320]; + string phone_number = 2 [(buf.validate.field).string.max_len = 32]; +} +message SmsOtpMfaSetupResponse { + string message = 1; +} + message ForgotPasswordRequest { string email = 1 [(buf.validate.field).string.max_len = 320]; string phone_number = 2 [(buf.validate.field).string.max_len = 32]; diff --git a/proto/authorizer/v1/types.proto b/proto/authorizer/v1/types.proto index 2af9a995..f4f7fc0d 100644 --- a/proto/authorizer/v1/types.proto +++ b/proto/authorizer/v1/types.proto @@ -32,6 +32,10 @@ message User { bool is_multi_factor_auth_enabled = 19; // Free-form key/value bag — same as GraphQL `app_data: Map`. AppData app_data = 20; + // Mirrors User.has_skipped_mfa_setup_at in GraphQL. 0 means never skipped. + int64 has_skipped_mfa_setup_at = 21; + // Mirrors User.mfa_locked_at in GraphQL. 0 means not locked. + int64 mfa_locked_at = 22; } // AuthResponse mirrors the GraphQL AuthResponse type. Returned (wrapped) by @@ -52,6 +56,14 @@ message AuthResponse { string authenticator_scanner_image = 10; string authenticator_secret = 11; repeated string authenticator_recovery_codes = 12; + // Mirrors AuthResponse.should_offer_webauthn_mfa_verify in GraphQL. + bool should_offer_webauthn_mfa_verify = 13; + // Mirrors AuthResponse.should_offer_webauthn_mfa_setup in GraphQL. + bool should_offer_webauthn_mfa_setup = 14; + // Mirrors AuthResponse.should_offer_email_otp_mfa_setup in GraphQL. + bool should_offer_email_otp_mfa_setup = 15; + // Mirrors AuthResponse.should_offer_sms_otp_mfa_setup in GraphQL. + bool should_offer_sms_otp_mfa_setup = 16; } // Permission is one (object, relation) pair the subject holds: "subject has @@ -123,4 +135,6 @@ message Meta { bool is_email_otp_mfa_enabled = 23; bool is_sms_otp_mfa_enabled = 24; bool is_webauthn_enabled = 25; + // Mirrors Meta.is_mfa_enforced in GraphQL. + bool is_mfa_enforced = 26; } diff --git a/web/app/package.json b/web/app/package.json index 02e11a91..f8f31283 100644 --- a/web/app/package.json +++ b/web/app/package.json @@ -13,7 +13,7 @@ "author": "Lakhan Samani", "license": "Apache-2.0", "dependencies": { - "@authorizerdev/authorizer-react": "2.2.0-rc.0", + "@authorizerdev/authorizer-react": "github:authorizerdev/authorizer-react#main", "react": "^18.3.1", "react-dom": "^18.3.1", "react-is": "^18.3.1", diff --git a/web/app/src/Root.tsx b/web/app/src/Root.tsx index 6f919dfa..d1a59967 100644 --- a/web/app/src/Root.tsx +++ b/web/app/src/Root.tsx @@ -1,6 +1,7 @@ import { useEffect, lazy, Suspense } from 'react'; import { Routes, Route, Navigate } from 'react-router-dom'; -import { useAuthorizer } from '@authorizerdev/authorizer-react'; +import { AuthorizerMFASetup, useAuthorizer } from '@authorizerdev/authorizer-react'; +import { parseMfaRedirectParams } from '@authorizerdev/authorizer-js'; import SetupPassword from './pages/setup-password'; import { hasWindow, createRandomString } from './utils/common'; @@ -38,7 +39,16 @@ export default function Root({ }: { globalState: Record; }) { - const { token, loading, config } = useAuthorizer(); + const { token, loading, config, setAuthData } = useAuthorizer(); + + // The server redirects here with these params, instead of issuing a + // token, when its MFA gate withholds one - not just for the OAuth + // /authorize flow this originated for, but also the magic-link-login and + // signup-email-verification click-through URLs (GET /verify_email), + // which redirect to the same place with the same params. + const mfaRedirect = hasWindow() + ? parseMfaRedirectParams(window.location.href) + : null; const combinedParams = getCombinedParams(); const getParam = (key: string): string => combinedParams.get(key) || ''; @@ -109,6 +119,29 @@ export default function Root({ if (loading) { return

Loading...

; } + if (mfaRedirect) { + return ( + { + setAuthData({ + user: data?.user || null, + token: data, + config, + loading: false, + }); + }, + }} + /> + ); + } if (token) { return ( }>