From 7fb42b6a0added2a10438e54bc43996298326490 Mon Sep 17 00:00:00 2001 From: Lakhan Samani Date: Tue, 14 Jul 2026 15:54:06 +0530 Subject: [PATCH 01/28] fix(mfa): default new users into MFA when it's available server-wide signup.go only set IsMultiFactorAuthEnabled when EnforceMFA was on or the caller passed it explicitly - a bare signup on a server with MFA available but not enforced left the flag unset, so resolveMFAGate's first condition (userMFAEnabled) was always false and the optional- setup-with-skip offer never triggered for anyone. Now defaults to true whenever EnableMFA is true (TOTP, or email/SMS OTP with its provider configured) and the caller didn't explicitly opt in or out. EnforceMFA and an explicit caller-supplied value both still take precedence, matching existing behavior. --- .../integration_tests/mfa_gate_login_test.go | 8 +- internal/integration_tests/signup_test.go | 89 +++++++++++++++++++ internal/service/signup.go | 7 ++ 3 files changed, 103 insertions(+), 1 deletion(-) diff --git a/internal/integration_tests/mfa_gate_login_test.go b/internal/integration_tests/mfa_gate_login_test.go index 146c6d44f..2b508e6e3 100644 --- a/internal/integration_tests/mfa_gate_login_test.go +++ b/internal/integration_tests/mfa_gate_login_test.go @@ -215,7 +215,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) diff --git a/internal/integration_tests/signup_test.go b/internal/integration_tests/signup_test.go index 525a2b7e6..2557a6cbd 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,91 @@ func TestSignup(t *testing.T) { }) }) } + +// TestSignupDefaultsMultiFactorAuthEnabled guards the regression where a new +// user's IsMultiFactorAuthEnabled stayed false by default even when MFA is +// available server-wide (EnableMFA) and not explicitly disabled - meaning the +// optional-MFA-with-skip offer flow (resolveMFAGate) never had anything to +// offer for the common, non-enforced case. +func TestSignupDefaultsMultiFactorAuthEnabled(t *testing.T) { + t.Run("MFA available, not enforced, no explicit param - defaults to enabled", func(t *testing.T) { + cfg := getTestConfig() + cfg.EnableMFA = true + cfg.EnableTOTPLogin = true + cfg.EnforceMFA = false + ts := initTestSetup(t, cfg) + _, ctx := createContext(ts) + + email := "signup_mfa_default_" + uuid.New().String() + "@authorizer.dev" + password := "Password@123" + res, err := ts.GraphQLProvider.SignUp(ctx, &model.SignUpRequest{ + Email: &email, Password: password, ConfirmPassword: password, + }) + require.NoError(t, err) + require.NotNil(t, res) + + user, err := ts.StorageProvider.GetUserByEmail(ctx, email) + require.NoError(t, err) + assert.True(t, refs.BoolValue(user.IsMultiFactorAuthEnabled), "a new user must default into MFA when it's available and not disabled, so the optional-setup-with-skip flow has something to offer") + }) + + t.Run("MFA not available server-wide - new user does not default to enabled", func(t *testing.T) { + // signup.go reads the single already-derived EnableMFA flag, not + // DisableMFA directly - in production, Finalize() forces EnableMFA + // false whenever DisableMFA is set, before signup.go ever runs. + cfg := getTestConfig() + cfg.EnableMFA = false + ts := initTestSetup(t, cfg) + _, ctx := createContext(ts) + + email := "signup_mfa_killswitch_" + uuid.New().String() + "@authorizer.dev" + password := "Password@123" + _, err := ts.GraphQLProvider.SignUp(ctx, &model.SignUpRequest{ + Email: &email, Password: password, ConfirmPassword: password, + }) + require.NoError(t, err) + + user, err := ts.StorageProvider.GetUserByEmail(ctx, email) + require.NoError(t, err) + assert.False(t, refs.BoolValue(user.IsMultiFactorAuthEnabled)) + }) + + t.Run("explicit opt-out is respected over the new default", func(t *testing.T) { + cfg := getTestConfig() + ts := initTestSetup(t, cfg) + _, ctx := createContext(ts) + + email := "signup_mfa_explicit_opt_out_" + uuid.New().String() + "@authorizer.dev" + password := "Password@123" + explicit := false + _, err := ts.GraphQLProvider.SignUp(ctx, &model.SignUpRequest{ + Email: &email, Password: password, ConfirmPassword: password, + IsMultiFactorAuthEnabled: &explicit, + }) + require.NoError(t, err) + + user, err := ts.StorageProvider.GetUserByEmail(ctx, email) + require.NoError(t, err) + assert.False(t, refs.BoolValue(user.IsMultiFactorAuthEnabled), "explicit opt-out must still be respected") + }) + + t.Run("EnforceMFA still forces enabled regardless of an explicit opt-out", func(t *testing.T) { + cfg := getTestConfig() + cfg.EnforceMFA = true + ts := initTestSetup(t, cfg) + _, ctx := createContext(ts) + + email := "signup_mfa_enforced_" + uuid.New().String() + "@authorizer.dev" + password := "Password@123" + explicit := false + _, err := ts.GraphQLProvider.SignUp(ctx, &model.SignUpRequest{ + Email: &email, Password: password, ConfirmPassword: password, + IsMultiFactorAuthEnabled: &explicit, + }) + require.NoError(t, err) + + user, err := ts.StorageProvider.GetUserByEmail(ctx, email) + require.NoError(t, err) + assert.True(t, refs.BoolValue(user.IsMultiFactorAuthEnabled), "EnforceMFA must override even an explicit opt-out") + }) +} diff --git a/internal/service/signup.go b/internal/service/signup.go index 419ac227d..b72731be0 100644 --- a/internal/service/signup.go +++ b/internal/service/signup.go @@ -157,6 +157,13 @@ func (p *provider) SignUp(ctx context.Context, meta RequestMetadata, params *mod if params.IsMultiFactorAuthEnabled != nil { user.IsMultiFactorAuthEnabled = params.IsMultiFactorAuthEnabled + } else if p.Config.EnableMFA { + // MFA is available on this server and the caller didn't explicitly + // opt in or out - default new users into it so the optional-setup- + // with-skip flow (resolveMFAGate) has something to offer instead of + // silently never triggering. EnforceMFA below still applies on top + // of this regardless. + user.IsMultiFactorAuthEnabled = refs.NewBoolRef(true) } isMFAEnforced := p.Config.EnforceMFA From ce5914e3b86ed7c9ab03d2de3a6ada5704a553e3 Mon Sep 17 00:00:00 2001 From: Lakhan Samani Date: Tue, 14 Jul 2026 16:20:21 +0530 Subject: [PATCH 02/28] feat(mfa): lazily backfill existing users into the MFA default on login signup.go's default only applies at account creation, so users who signed up before it shipped (or before MFA was configured at all) were permanently stuck with IsMultiFactorAuthEnabled unset - the offer-with-skip flow silently never triggered for them, forever. Added ensureMFADefaultSet, called from both login.go's password path and webauthn.go's passkey primary-login path, right before either one reads the flag: if it was never explicitly set and MFA is available server-wide, default it to true and persist it on that login. Same end state as a fresh signup, triggered by the user's own next login instead of a bulk migration across every DB backend. An explicit false (opt-out) and EnforceMFA's own forcing are both untouched. --- .../mfa_default_backfill_test.go | 125 ++++++++++++++++++ internal/service/login.go | 24 ++++ internal/service/webauthn.go | 9 ++ 3 files changed, 158 insertions(+) create mode 100644 internal/integration_tests/mfa_default_backfill_test.go diff --git a/internal/integration_tests/mfa_default_backfill_test.go b/internal/integration_tests/mfa_default_backfill_test.go new file mode 100644 index 000000000..aa85022ef --- /dev/null +++ b/internal/integration_tests/mfa_default_backfill_test.go @@ -0,0 +1,125 @@ +package integration_tests + +import ( + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/authorizerdev/authorizer/internal/graph/model" + "github.com/authorizerdev/authorizer/internal/refs" +) + +// TestMFADefaultBackfillOnLogin guards the lazy migration for accounts that +// existed before the MFA-default-on-signup behavior shipped (or before MFA +// was ever configured on this server): the first time such a user +// authenticates, IsMultiFactorAuthEnabled - if never explicitly set - is +// backfilled to match EnableMFA and persisted, so existing accounts converge +// to the same offer-with-skip behavior a new signup already gets. +func TestMFADefaultBackfillOnLogin(t *testing.T) { + const password = "Password@123" + + t.Run("password login backfills an existing user with MFA available", func(t *testing.T) { + cfg := getTestConfig() + cfg.EnableMFA = true + cfg.EnableTOTPLogin = true + ts := initTestSetup(t, cfg) + _, ctx := createContext(ts) + + email := "mfa_backfill_login_" + uuid.New().String() + "@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) + // Simulate an account that predates the signup default: force the + // flag back to nil, as if it were created before this feature shipped. + user.IsMultiFactorAuthEnabled = nil + user, err = ts.StorageProvider.UpdateUser(ctx, user) + require.NoError(t, err) + require.Nil(t, user.IsMultiFactorAuthEnabled) + + 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 on the backfilling login") + assert.True(t, refs.BoolValue(res.ShouldOfferMfaSetup), "the newly-backfilled user should be offered setup, same as a fresh signup") + + reloaded, err := ts.StorageProvider.GetUserByEmail(ctx, email) + require.NoError(t, err) + assert.True(t, refs.BoolValue(reloaded.IsMultiFactorAuthEnabled), "the backfill must be persisted, not just applied in-memory for this one response") + }) + + t.Run("password login does not backfill when MFA is unavailable", func(t *testing.T) { + cfg := getTestConfig() + cfg.EnableMFA = false + ts := initTestSetup(t, cfg) + _, ctx := createContext(ts) + + email := "mfa_backfill_none_" + uuid.New().String() + "@authorizer.dev" + _, err := ts.GraphQLProvider.SignUp(ctx, &model.SignUpRequest{ + Email: &email, Password: password, ConfirmPassword: password, + }) + require.NoError(t, err) + + res, err := ts.GraphQLProvider.Login(ctx, &model.LoginRequest{Email: &email, Password: password}) + require.NoError(t, err) + require.NotNil(t, res) + + reloaded, err := ts.StorageProvider.GetUserByEmail(ctx, email) + require.NoError(t, err) + assert.Nil(t, reloaded.IsMultiFactorAuthEnabled, "must not backfill when MFA isn't available at all") + }) + + t.Run("does not override an explicit false", func(t *testing.T) { + cfg := getTestConfig() + cfg.EnableMFA = true + cfg.EnableTOTPLogin = true + ts := initTestSetup(t, cfg) + _, ctx := createContext(ts) + + email := "mfa_backfill_explicit_false_" + uuid.New().String() + "@authorizer.dev" + explicit := false + _, err := ts.GraphQLProvider.SignUp(ctx, &model.SignUpRequest{ + Email: &email, Password: password, ConfirmPassword: password, + IsMultiFactorAuthEnabled: &explicit, + }) + require.NoError(t, err) + + res, err := ts.GraphQLProvider.Login(ctx, &model.LoginRequest{Email: &email, Password: password}) + require.NoError(t, err) + require.NotNil(t, res) + assert.False(t, refs.BoolValue(res.ShouldOfferMfaSetup)) + + reloaded, err := ts.StorageProvider.GetUserByEmail(ctx, email) + require.NoError(t, err) + assert.False(t, refs.BoolValue(reloaded.IsMultiFactorAuthEnabled), "an explicit false must never be silently overridden") + }) +} + +// TestMFADefaultBackfillOnPasskeyLogin covers the same lazy-backfill guard +// through the passkey primary-login entry point, so a user who authenticates +// exclusively via passkey converges to the same default as a password-login +// user rather than being permanently excluded from the offer. +func TestMFADefaultBackfillOnPasskeyLogin(t *testing.T) { + t.Run("passkey primary login backfills an existing user with MFA available", func(t *testing.T) { + cfg := getTestConfig() + cfg.EnableMFA = true + cfg.EnableTOTPLogin = true + ts := initTestSetup(t, cfg) + user, rp, authenticator, credential := registerPasskeyForNewUser(t, ts) + user.IsMultiFactorAuthEnabled = nil + _, err := ts.StorageProvider.UpdateUser(t.Context(), user) + require.NoError(t, err) + + authRes, err := assertPasskeyLogin(t, ts, rp, authenticator, credential) + require.NoError(t, err) + require.NotNil(t, authRes.AccessToken, "EnforceMFA is off, so passkey login must still succeed directly") + + reloaded, err := ts.StorageProvider.GetUserByID(t.Context(), user.ID) + require.NoError(t, err) + assert.True(t, refs.BoolValue(reloaded.IsMultiFactorAuthEnabled)) + }) +} diff --git a/internal/service/login.go b/internal/service/login.go index b19eb2373..7c3514da9 100644 --- a/internal/service/login.go +++ b/internal/service/login.go @@ -47,6 +47,21 @@ func (p *provider) setMFASession(meta RequestMetadata, side *ResponseSideEffects return nil } +// ensureMFADefaultSet lazily backfills IsMultiFactorAuthEnabled for users who +// authenticated before this default existed (or before MFA was available on +// this server): when the flag was never explicitly set and MFA is available +// server-wide, default it to true and persist it on this login/verification, +// so existing accounts converge to the same MFA-available -> offered +// behavior new signups already get (see signup.go). No-op - returns the user +// unchanged - if the flag is already set either way, or MFA isn't available. +func (p *provider) ensureMFADefaultSet(ctx context.Context, user *schemas.User) (*schemas.User, error) { + if user.IsMultiFactorAuthEnabled != nil || !p.Config.EnableMFA { + return user, nil + } + user.IsMultiFactorAuthEnabled = refs.NewBoolRef(true) + return p.StorageProvider.UpdateUser(ctx, user) +} + // loginDummyBcryptHash is a precomputed bcrypt hash used to equalise the // response time of the user-not-found path with the real password verification // path. Without this, an attacker can distinguish "no such user" from "wrong @@ -317,6 +332,15 @@ func (p *provider) Login(ctx context.Context, meta RequestMetadata, params *mode scope = params.Scope } + // Backfill accounts that predate the MFA-default-on-signup behavior (or + // predate MFA being configured at all on this server) before any branch + // below reads IsMultiFactorAuthEnabled. + user, err = p.ensureMFADefaultSet(ctx, user) + if err != nil { + log.Debug().Err(err).Msg("Failed to backfill MFA default") + return nil, nil, err + } + isMFAEnabled := p.Config.EnableMFA isTOTPLoginEnabled := p.Config.EnableTOTPLogin isMailOTPEnabled := p.Config.EnableEmailOTP diff --git a/internal/service/webauthn.go b/internal/service/webauthn.go index 6c01b00a0..e34c594b8 100644 --- a/internal/service/webauthn.go +++ b/internal/service/webauthn.go @@ -155,6 +155,15 @@ 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") } + // Backfill accounts that predate the MFA-default-on-signup behavior (or + // predate MFA being configured at all) before the EnforceMFA gate below + // reads IsMultiFactorAuthEnabled - same lazy migration login.go applies. + user, err = p.ensureMFADefaultSet(ctx, user) + if err != nil { + log.Debug().Err(err).Msg("Failed to backfill MFA default") + return nil, nil, err + } + // 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 From 1ba788647053981749b25d12715e77d0f7730b64 Mon Sep 17 00:00:00 2001 From: Lakhan Samani Date: Tue, 14 Jul 2026 17:46:40 +0530 Subject: [PATCH 03/28] feat(mfa): add --disable-webauthn-mfa, fold WebAuthn into EnableMFA derivation --- cmd/root.go | 1 + internal/config/config.go | 16 +++- internal/config/config_finalize_test.go | 101 +++++++++++++++--------- internal/service/admin_users.go | 3 +- 4 files changed, 80 insertions(+), 41 deletions(-) diff --git a/cmd/root.go b/cmd/root.go index 998df2d34..df506fd65 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -188,6 +188,7 @@ 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") diff --git a/internal/config/config.go b/internal/config/config.go index 5681512b2..e9fc4247b 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,12 +400,14 @@ 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) diff --git a/internal/config/config_finalize_test.go b/internal/config/config_finalize_test.go index a67014e74..b06bf49ce 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/service/admin_users.go b/internal/service/admin_users.go index 525089d66..9498c7caf 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) } From af05e8aac8b44ff4ef5d188f05b225727ef6072e Mon Sep 17 00:00:00 2001 From: Lakhan Samani Date: Tue, 14 Jul 2026 17:51:49 +0530 Subject: [PATCH 04/28] docs(mfa): clarify --disable-mfa's effect on WebAuthn's MFA-factor role --- cmd/root.go | 2 +- internal/config/config.go | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/cmd/root.go b/cmd/root.go index df506fd65..ece2412f9 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -191,7 +191,7 @@ func init() { f.BoolVar(&rootArgs.config.DisableWebauthnMFA, "disable-webauthn-mfa", false, "Disable WebAuthn/passkey as an MFA factor (enabled by default); does not affect WebAuthn/passkey as a primary login method") f.BoolVar(&rootArgs.config.DisableEmailOTP, "disable-email-otp", false, "Disable email OTP MFA (enabled by default when email service is configured)") f.BoolVar(&rootArgs.config.DisableSMSOTP, "disable-sms-otp", false, "Disable SMS OTP MFA (enabled by default when SMS service is configured)") - f.BoolVar(&rootArgs.config.DisableMFA, "disable-mfa", false, "Globally disable MFA (TOTP/email/SMS OTP), overriding the per-method flags; does not affect WebAuthn/passkey") + f.BoolVar(&rootArgs.config.DisableMFA, "disable-mfa", false, "Globally disable MFA (TOTP/email/SMS OTP), overriding the per-method flags; does not affect WebAuthn/passkey as a primary login method") f.BoolVar(&rootArgs.config.EnableSignup, "enable-signup", true, "Enable signup") // Cookies flags diff --git a/internal/config/config.go b/internal/config/config.go index e9fc4247b..f018ab578 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -413,7 +413,8 @@ func (c *Config) Finalize() { // 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 From 68718a707b1d697bf6f004d6b3ae1767cdb6e7cf Mon Sep 17 00:00:00 2001 From: Lakhan Samani Date: Tue, 14 Jul 2026 17:59:21 +0530 Subject: [PATCH 05/28] revert(mfa): drop persisted MFA defaults, add pure effectiveMFAEnabled Reverts 7fb42b6a and ce5914e3: signup no longer writes a default IsMultiFactorAuthEnabled, and login/webauthn no longer backfill one on existing users. Effective MFA state is now always computed from live config, never persisted as an inferred default. --- .../mfa_default_backfill_test.go | 125 ------------------ internal/integration_tests/signup_test.go | 89 ------------- internal/service/login.go | 24 ---- internal/service/mfa_gate.go | 19 +++ internal/service/mfa_gate_test.go | 33 ++++- internal/service/signup.go | 7 - internal/service/webauthn.go | 9 -- 7 files changed, 51 insertions(+), 255 deletions(-) delete mode 100644 internal/integration_tests/mfa_default_backfill_test.go diff --git a/internal/integration_tests/mfa_default_backfill_test.go b/internal/integration_tests/mfa_default_backfill_test.go deleted file mode 100644 index aa85022ef..000000000 --- a/internal/integration_tests/mfa_default_backfill_test.go +++ /dev/null @@ -1,125 +0,0 @@ -package integration_tests - -import ( - "testing" - - "github.com/google/uuid" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/authorizerdev/authorizer/internal/graph/model" - "github.com/authorizerdev/authorizer/internal/refs" -) - -// TestMFADefaultBackfillOnLogin guards the lazy migration for accounts that -// existed before the MFA-default-on-signup behavior shipped (or before MFA -// was ever configured on this server): the first time such a user -// authenticates, IsMultiFactorAuthEnabled - if never explicitly set - is -// backfilled to match EnableMFA and persisted, so existing accounts converge -// to the same offer-with-skip behavior a new signup already gets. -func TestMFADefaultBackfillOnLogin(t *testing.T) { - const password = "Password@123" - - t.Run("password login backfills an existing user with MFA available", func(t *testing.T) { - cfg := getTestConfig() - cfg.EnableMFA = true - cfg.EnableTOTPLogin = true - ts := initTestSetup(t, cfg) - _, ctx := createContext(ts) - - email := "mfa_backfill_login_" + uuid.New().String() + "@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) - // Simulate an account that predates the signup default: force the - // flag back to nil, as if it were created before this feature shipped. - user.IsMultiFactorAuthEnabled = nil - user, err = ts.StorageProvider.UpdateUser(ctx, user) - require.NoError(t, err) - require.Nil(t, user.IsMultiFactorAuthEnabled) - - 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 on the backfilling login") - assert.True(t, refs.BoolValue(res.ShouldOfferMfaSetup), "the newly-backfilled user should be offered setup, same as a fresh signup") - - reloaded, err := ts.StorageProvider.GetUserByEmail(ctx, email) - require.NoError(t, err) - assert.True(t, refs.BoolValue(reloaded.IsMultiFactorAuthEnabled), "the backfill must be persisted, not just applied in-memory for this one response") - }) - - t.Run("password login does not backfill when MFA is unavailable", func(t *testing.T) { - cfg := getTestConfig() - cfg.EnableMFA = false - ts := initTestSetup(t, cfg) - _, ctx := createContext(ts) - - email := "mfa_backfill_none_" + uuid.New().String() + "@authorizer.dev" - _, err := ts.GraphQLProvider.SignUp(ctx, &model.SignUpRequest{ - Email: &email, Password: password, ConfirmPassword: password, - }) - require.NoError(t, err) - - res, err := ts.GraphQLProvider.Login(ctx, &model.LoginRequest{Email: &email, Password: password}) - require.NoError(t, err) - require.NotNil(t, res) - - reloaded, err := ts.StorageProvider.GetUserByEmail(ctx, email) - require.NoError(t, err) - assert.Nil(t, reloaded.IsMultiFactorAuthEnabled, "must not backfill when MFA isn't available at all") - }) - - t.Run("does not override an explicit false", func(t *testing.T) { - cfg := getTestConfig() - cfg.EnableMFA = true - cfg.EnableTOTPLogin = true - ts := initTestSetup(t, cfg) - _, ctx := createContext(ts) - - email := "mfa_backfill_explicit_false_" + uuid.New().String() + "@authorizer.dev" - explicit := false - _, err := ts.GraphQLProvider.SignUp(ctx, &model.SignUpRequest{ - Email: &email, Password: password, ConfirmPassword: password, - IsMultiFactorAuthEnabled: &explicit, - }) - require.NoError(t, err) - - res, err := ts.GraphQLProvider.Login(ctx, &model.LoginRequest{Email: &email, Password: password}) - require.NoError(t, err) - require.NotNil(t, res) - assert.False(t, refs.BoolValue(res.ShouldOfferMfaSetup)) - - reloaded, err := ts.StorageProvider.GetUserByEmail(ctx, email) - require.NoError(t, err) - assert.False(t, refs.BoolValue(reloaded.IsMultiFactorAuthEnabled), "an explicit false must never be silently overridden") - }) -} - -// TestMFADefaultBackfillOnPasskeyLogin covers the same lazy-backfill guard -// through the passkey primary-login entry point, so a user who authenticates -// exclusively via passkey converges to the same default as a password-login -// user rather than being permanently excluded from the offer. -func TestMFADefaultBackfillOnPasskeyLogin(t *testing.T) { - t.Run("passkey primary login backfills an existing user with MFA available", func(t *testing.T) { - cfg := getTestConfig() - cfg.EnableMFA = true - cfg.EnableTOTPLogin = true - ts := initTestSetup(t, cfg) - user, rp, authenticator, credential := registerPasskeyForNewUser(t, ts) - user.IsMultiFactorAuthEnabled = nil - _, err := ts.StorageProvider.UpdateUser(t.Context(), user) - require.NoError(t, err) - - authRes, err := assertPasskeyLogin(t, ts, rp, authenticator, credential) - require.NoError(t, err) - require.NotNil(t, authRes.AccessToken, "EnforceMFA is off, so passkey login must still succeed directly") - - reloaded, err := ts.StorageProvider.GetUserByID(t.Context(), user.ID) - require.NoError(t, err) - assert.True(t, refs.BoolValue(reloaded.IsMultiFactorAuthEnabled)) - }) -} diff --git a/internal/integration_tests/signup_test.go b/internal/integration_tests/signup_test.go index 2557a6cbd..525a2b7e6 100644 --- a/internal/integration_tests/signup_test.go +++ b/internal/integration_tests/signup_test.go @@ -7,7 +7,6 @@ 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" @@ -188,91 +187,3 @@ func TestSignup(t *testing.T) { }) }) } - -// TestSignupDefaultsMultiFactorAuthEnabled guards the regression where a new -// user's IsMultiFactorAuthEnabled stayed false by default even when MFA is -// available server-wide (EnableMFA) and not explicitly disabled - meaning the -// optional-MFA-with-skip offer flow (resolveMFAGate) never had anything to -// offer for the common, non-enforced case. -func TestSignupDefaultsMultiFactorAuthEnabled(t *testing.T) { - t.Run("MFA available, not enforced, no explicit param - defaults to enabled", func(t *testing.T) { - cfg := getTestConfig() - cfg.EnableMFA = true - cfg.EnableTOTPLogin = true - cfg.EnforceMFA = false - ts := initTestSetup(t, cfg) - _, ctx := createContext(ts) - - email := "signup_mfa_default_" + uuid.New().String() + "@authorizer.dev" - password := "Password@123" - res, err := ts.GraphQLProvider.SignUp(ctx, &model.SignUpRequest{ - Email: &email, Password: password, ConfirmPassword: password, - }) - require.NoError(t, err) - require.NotNil(t, res) - - user, err := ts.StorageProvider.GetUserByEmail(ctx, email) - require.NoError(t, err) - assert.True(t, refs.BoolValue(user.IsMultiFactorAuthEnabled), "a new user must default into MFA when it's available and not disabled, so the optional-setup-with-skip flow has something to offer") - }) - - t.Run("MFA not available server-wide - new user does not default to enabled", func(t *testing.T) { - // signup.go reads the single already-derived EnableMFA flag, not - // DisableMFA directly - in production, Finalize() forces EnableMFA - // false whenever DisableMFA is set, before signup.go ever runs. - cfg := getTestConfig() - cfg.EnableMFA = false - ts := initTestSetup(t, cfg) - _, ctx := createContext(ts) - - email := "signup_mfa_killswitch_" + uuid.New().String() + "@authorizer.dev" - password := "Password@123" - _, err := ts.GraphQLProvider.SignUp(ctx, &model.SignUpRequest{ - Email: &email, Password: password, ConfirmPassword: password, - }) - require.NoError(t, err) - - user, err := ts.StorageProvider.GetUserByEmail(ctx, email) - require.NoError(t, err) - assert.False(t, refs.BoolValue(user.IsMultiFactorAuthEnabled)) - }) - - t.Run("explicit opt-out is respected over the new default", func(t *testing.T) { - cfg := getTestConfig() - ts := initTestSetup(t, cfg) - _, ctx := createContext(ts) - - email := "signup_mfa_explicit_opt_out_" + uuid.New().String() + "@authorizer.dev" - password := "Password@123" - explicit := false - _, err := ts.GraphQLProvider.SignUp(ctx, &model.SignUpRequest{ - Email: &email, Password: password, ConfirmPassword: password, - IsMultiFactorAuthEnabled: &explicit, - }) - require.NoError(t, err) - - user, err := ts.StorageProvider.GetUserByEmail(ctx, email) - require.NoError(t, err) - assert.False(t, refs.BoolValue(user.IsMultiFactorAuthEnabled), "explicit opt-out must still be respected") - }) - - t.Run("EnforceMFA still forces enabled regardless of an explicit opt-out", func(t *testing.T) { - cfg := getTestConfig() - cfg.EnforceMFA = true - ts := initTestSetup(t, cfg) - _, ctx := createContext(ts) - - email := "signup_mfa_enforced_" + uuid.New().String() + "@authorizer.dev" - password := "Password@123" - explicit := false - _, err := ts.GraphQLProvider.SignUp(ctx, &model.SignUpRequest{ - Email: &email, Password: password, ConfirmPassword: password, - IsMultiFactorAuthEnabled: &explicit, - }) - require.NoError(t, err) - - user, err := ts.StorageProvider.GetUserByEmail(ctx, email) - require.NoError(t, err) - assert.True(t, refs.BoolValue(user.IsMultiFactorAuthEnabled), "EnforceMFA must override even an explicit opt-out") - }) -} diff --git a/internal/service/login.go b/internal/service/login.go index 7c3514da9..b19eb2373 100644 --- a/internal/service/login.go +++ b/internal/service/login.go @@ -47,21 +47,6 @@ func (p *provider) setMFASession(meta RequestMetadata, side *ResponseSideEffects return nil } -// ensureMFADefaultSet lazily backfills IsMultiFactorAuthEnabled for users who -// authenticated before this default existed (or before MFA was available on -// this server): when the flag was never explicitly set and MFA is available -// server-wide, default it to true and persist it on this login/verification, -// so existing accounts converge to the same MFA-available -> offered -// behavior new signups already get (see signup.go). No-op - returns the user -// unchanged - if the flag is already set either way, or MFA isn't available. -func (p *provider) ensureMFADefaultSet(ctx context.Context, user *schemas.User) (*schemas.User, error) { - if user.IsMultiFactorAuthEnabled != nil || !p.Config.EnableMFA { - return user, nil - } - user.IsMultiFactorAuthEnabled = refs.NewBoolRef(true) - return p.StorageProvider.UpdateUser(ctx, user) -} - // loginDummyBcryptHash is a precomputed bcrypt hash used to equalise the // response time of the user-not-found path with the real password verification // path. Without this, an attacker can distinguish "no such user" from "wrong @@ -332,15 +317,6 @@ func (p *provider) Login(ctx context.Context, meta RequestMetadata, params *mode scope = params.Scope } - // Backfill accounts that predate the MFA-default-on-signup behavior (or - // predate MFA being configured at all on this server) before any branch - // below reads IsMultiFactorAuthEnabled. - user, err = p.ensureMFADefaultSet(ctx, user) - if err != nil { - log.Debug().Err(err).Msg("Failed to backfill MFA default") - return nil, nil, err - } - isMFAEnabled := p.Config.EnableMFA isTOTPLoginEnabled := p.Config.EnableTOTPLogin isMailOTPEnabled := p.Config.EnableEmailOTP diff --git a/internal/service/mfa_gate.go b/internal/service/mfa_gate.go index eef151d24..496450184 100644 --- a/internal/service/mfa_gate.go +++ b/internal/service/mfa_gate.go @@ -1,6 +1,12 @@ // internal/service/mfa_gate.go package service +import ( + "github.com/authorizerdev/authorizer/internal/config" + "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 @@ -55,3 +61,16 @@ func resolveMFAGate(userMFAEnabled, enforceMFA, authenticatorVerified, hasSkippe } return mfaGateOfferSetup } + +// 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 +} diff --git a/internal/service/mfa_gate_test.go b/internal/service/mfa_gate_test.go index 41d096c7e..26d800c57 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 { @@ -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/signup.go b/internal/service/signup.go index b72731be0..419ac227d 100644 --- a/internal/service/signup.go +++ b/internal/service/signup.go @@ -157,13 +157,6 @@ func (p *provider) SignUp(ctx context.Context, meta RequestMetadata, params *mod if params.IsMultiFactorAuthEnabled != nil { user.IsMultiFactorAuthEnabled = params.IsMultiFactorAuthEnabled - } else if p.Config.EnableMFA { - // MFA is available on this server and the caller didn't explicitly - // opt in or out - default new users into it so the optional-setup- - // with-skip flow (resolveMFAGate) has something to offer instead of - // silently never triggering. EnforceMFA below still applies on top - // of this regardless. - user.IsMultiFactorAuthEnabled = refs.NewBoolRef(true) } isMFAEnforced := p.Config.EnforceMFA diff --git a/internal/service/webauthn.go b/internal/service/webauthn.go index e34c594b8..6c01b00a0 100644 --- a/internal/service/webauthn.go +++ b/internal/service/webauthn.go @@ -155,15 +155,6 @@ 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") } - // Backfill accounts that predate the MFA-default-on-signup behavior (or - // predate MFA being configured at all) before the EnforceMFA gate below - // reads IsMultiFactorAuthEnabled - same lazy migration login.go applies. - user, err = p.ensureMFADefaultSet(ctx, user) - if err != nil { - log.Debug().Err(err).Msg("Failed to backfill MFA default") - return nil, nil, err - } - // 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 From 63df61f5a4f2c5817bed33268dd6451fcd20089c Mon Sep 17 00:00:00 2001 From: Lakhan Samani Date: Tue, 14 Jul 2026 18:19:36 +0530 Subject: [PATCH 06/28] feat(mfa): withhold token on first-time offer, unify passkey gate with resolveMFAGate mfaGateOfferSetup renamed to mfaGateOfferAll and moved into the token-withhold group, matching mfaGateBlockVerify/mfaGateBlockEnroll. WebauthnLoginVerify no longer hand-rolls its own EnforceMFA-only check -- passkey primary login now goes through the same 5-way gate password login does, so an optional-MFA user isn't silently exempted from the first-time setup offer just because they logged in with a passkey. Adds should_offer_webauthn_mfa_setup to AuthResponse (schema regen) -- needed for the offer response, since ShouldOfferEmailOtpMfaSetup/ ShouldOfferSmsOtpMfaSetup are Task 6's job but WebAuthn's own flag isn't. should_offer_mfa_setup is deprecated in place (still read by skip_mfa_setup_test.go) rather than deleted, since it's never written anymore once PendingTOTPOffer is gone. Also fixes a crash: webauthn.go's offer case unconditionally generated a TOTP enrollment, unlike its sibling block cases which guard on EnableTOTPLogin -- panicked on nil AuthenticatorProvider whenever TOTP login was disabled server-wide. --- internal/graph/generated/generated.go | 83 +++++++++++++++++-- internal/graph/model/models_gen.go | 1 + internal/graph/schema.graphqls | 16 ++-- .../integration_tests/mfa_gate_login_test.go | 25 +++--- .../webauthn_enforce_mfa_test.go | 17 +++- internal/service/login.go | 29 ++++--- internal/service/mfa_gate.go | 15 ++-- internal/service/mfa_gate_test.go | 2 +- internal/service/sideeffects.go | 4 - internal/service/skip_mfa_setup.go | 2 +- internal/service/webauthn.go | 76 ++++++++++++++--- 11 files changed, 207 insertions(+), 63 deletions(-) diff --git a/internal/graph/generated/generated.go b/internal/graph/generated/generated.go index a188a9f26..e206339a1 100644 --- a/internal/graph/generated/generated.go +++ b/internal/graph/generated/generated.go @@ -82,6 +82,7 @@ type ComplexityRoot struct { Message func(childComplexity int) int RefreshToken func(childComplexity int) int ShouldOfferMfaSetup func(childComplexity int) int + ShouldOfferWebauthnMfaSetup func(childComplexity int) int ShouldOfferWebauthnMfaVerify func(childComplexity int) int ShouldShowEmailOtpScreen func(childComplexity int) int ShouldShowMobileOtpScreen func(childComplexity int) int @@ -957,6 +958,13 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return e.complexity.AuthResponse.ShouldOfferMfaSetup(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 @@ -4559,12 +4567,18 @@ 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 access_token: String id_token: String refresh_token: String @@ -9572,6 +9586,47 @@ 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_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 { @@ -16201,6 +16256,8 @@ 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 "access_token": return ec.fieldContext_AuthResponse_access_token(ctx, field) case "id_token": @@ -16286,6 +16343,8 @@ 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 "access_token": return ec.fieldContext_AuthResponse_access_token(ctx, field) case "id_token": @@ -16371,6 +16430,8 @@ 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 "access_token": return ec.fieldContext_AuthResponse_access_token(ctx, field) case "id_token": @@ -16456,6 +16517,8 @@ 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 "access_token": return ec.fieldContext_AuthResponse_access_token(ctx, field) case "id_token": @@ -16707,6 +16770,8 @@ 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 "access_token": return ec.fieldContext_AuthResponse_access_token(ctx, field) case "id_token": @@ -17030,6 +17095,8 @@ 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 "access_token": return ec.fieldContext_AuthResponse_access_token(ctx, field) case "id_token": @@ -17399,6 +17466,8 @@ 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 "access_token": return ec.fieldContext_AuthResponse_access_token(ctx, field) case "id_token": @@ -23348,6 +23417,8 @@ 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 "access_token": return ec.fieldContext_AuthResponse_access_token(ctx, field) case "id_token": @@ -36235,6 +36306,8 @@ 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 "access_token": out.Values[i] = ec._AuthResponse_access_token(ctx, field, obj) case "id_token": diff --git a/internal/graph/model/models_gen.go b/internal/graph/model/models_gen.go index 4c9ee7aa1..2a071d07c 100644 --- a/internal/graph/model/models_gen.go +++ b/internal/graph/model/models_gen.go @@ -84,6 +84,7 @@ 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"` AccessToken *string `json:"access_token,omitempty"` IDToken *string `json:"id_token,omitempty"` RefreshToken *string `json:"refresh_token,omitempty"` diff --git a/internal/graph/schema.graphqls b/internal/graph/schema.graphqls index 8c7f3d421..d724dbe5c 100644 --- a/internal/graph/schema.graphqls +++ b/internal/graph/schema.graphqls @@ -132,12 +132,18 @@ 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 access_token: String id_token: String refresh_token: String diff --git a/internal/integration_tests/mfa_gate_login_test.go b/internal/integration_tests/mfa_gate_login_test.go index 2b508e6e3..558bd6c77 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" @@ -163,7 +163,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 +179,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) { diff --git a/internal/integration_tests/webauthn_enforce_mfa_test.go b/internal/integration_tests/webauthn_enforce_mfa_test.go index 9f7cf31f5..088b4a08f 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,7 +81,13 @@ 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) { diff --git a/internal/service/login.go b/internal/service/login.go index b19eb2373..f4aeb654a 100644 --- a/internal/service/login.go +++ b/internal/service/login.go @@ -70,7 +70,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 @@ -391,7 +391,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, @@ -429,15 +429,25 @@ func (p *provider) Login(ctx context.Context, meta RequestMetadata, params *mode AuthenticatorSecret: refs.NewStringRef(enrollment.Secret), AuthenticatorRecoveryCodes: enrollment.RecoveryCodes, }, side, nil - case mfaGateOfferSetup: + 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 + } enrollment, err := p.generateTOTPEnrollment(ctx, user.ID) if err != nil { log.Debug().Msg("Failed to generate totp for optional setup") return nil, nil, err } - // Falls through to normal token issuance below, with the offer - // flag and enrollment payload attached after CreateAuthToken. - side.PendingTOTPOffer = enrollment + return &model.AuthResponse{ + Message: `Proceed to mfa setup`, + ShouldShowTotpScreen: refs.NewBoolRef(true), + ShouldOfferWebauthnMfaSetup: refs.NewBoolRef(p.Config.EnableWebauthnMFA), + AuthenticatorScannerImage: refs.NewStringRef(enrollment.ScannerImage), + AuthenticatorSecret: refs.NewStringRef(enrollment.Secret), + AuthenticatorRecoveryCodes: enrollment.RecoveryCodes, + }, side, nil case mfaGateSkippedSetup: side.OfferMFASetupQuiet = true case mfaGateNone: @@ -517,13 +527,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/mfa_gate.go b/internal/service/mfa_gate.go index 496450184..2f6c87236 100644 --- a/internal/service/mfa_gate.go +++ b/internal/service/mfa_gate.go @@ -22,11 +22,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 ) @@ -59,7 +62,7 @@ func resolveMFAGate(userMFAEnabled, enforceMFA, authenticatorVerified, hasSkippe if hasSkippedSetup { return mfaGateSkippedSetup } - return mfaGateOfferSetup + return mfaGateOfferAll } // effectiveMFAEnabled reports whether MFA applies to this user right now. diff --git a/internal/service/mfa_gate_test.go b/internal/service/mfa_gate_test.go index 26d800c57..24f401232 100644 --- a/internal/service/mfa_gate_test.go +++ b/internal/service/mfa_gate_test.go @@ -24,7 +24,7 @@ func TestResolveMFAGate(t *testing.T) { {"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 { diff --git a/internal/service/sideeffects.go b/internal/service/sideeffects.go index 621779e10..49f4f3fc4 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/skip_mfa_setup.go b/internal/service/skip_mfa_setup.go index 604dfbbbe..f26525aaf 100644 --- a/internal/service/skip_mfa_setup.go +++ b/internal/service/skip_mfa_setup.go @@ -11,7 +11,7 @@ import ( // 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), +// (resolveMFAGate never returns mfaGateOfferAll when EnforceMFA is true), // but this is re-checked here server-side so a client can never forge the // request to bypass enforcement. // diff --git a/internal/service/webauthn.go b/internal/service/webauthn.go index 6c01b00a0..3f459a660 100644 --- a/internal/service/webauthn.go +++ b/internal/service/webauthn.go @@ -155,28 +155,47 @@ 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) { + // A passkey used for PRIMARY login is only one factor (something you + // have) — it does not itself satisfy an MFA requirement, so it goes + // through the exact same 5-way gate password login does. A WebAuthn + // credential registered for MFA purposes on this same account (there is + // no `purpose` field distinguishing "primary" vs "MFA" registrations) + // counts as a verified second factor here too, same as login.go's TOTP + // branch treats it — but the credential the user just authenticated + // PRIMARY with cannot also be counted as its own second factor, so + // authenticatorVerified below is TOTP-only for a passkey-primary login. + authenticator, authErr := p.StorageProvider.GetAuthenticatorDetailsByUserId(ctx, user.ID, constants.EnvKeyTOTPAuthenticator) + totpVerified := authErr == nil && authenticator != nil && authenticator.VerifiedAt != nil + gate := resolveMFAGate( + effectiveMFAEnabled(p.Config, user), + p.Config.EnforceMFA, + totpVerified, + user.HasSkippedMFASetupAt != nil, + ) + switch gate { + case mfaGateBlockVerify: if !p.Config.EnableTOTPLogin { log.Debug().Msg("EnforceMFA is on but no compatible second factor is configured for passkey login") return nil, nil, FailedPrecondition("multi-factor authentication is required but no compatible verification method is available for passkey sign-in; please sign in with your password instead") } - authenticator, authErr := p.StorageProvider.GetAuthenticatorDetailsByUserId(ctx, user.ID, constants.EnvKeyTOTPAuthenticator) - totpVerified := authErr == nil && authenticator != nil && authenticator.VerifiedAt != nil expiresAt := time.Now().Add(3 * time.Minute).Unix() if err := p.setMFASession(meta, side, user.ID, expiresAt); err != nil { log.Debug().Msg("Failed to set mfa session") return nil, nil, err } - if totpVerified { - return &model.AuthResponse{ - Message: `Proceed to mfa verification`, - ShouldShowTotpScreen: refs.NewBoolRef(true), - }, side, nil + return &model.AuthResponse{ + Message: `Proceed to mfa verification`, + ShouldShowTotpScreen: refs.NewBoolRef(true), + }, side, nil + case mfaGateBlockEnroll: + if !p.Config.EnableTOTPLogin { + log.Debug().Msg("EnforceMFA is on but no compatible second factor is configured for passkey login") + return nil, nil, FailedPrecondition("multi-factor authentication is required but no compatible verification method is available for passkey sign-in; please sign in with your password instead") + } + expiresAt := time.Now().Add(3 * time.Minute).Unix() + if err := p.setMFASession(meta, side, user.ID, expiresAt); err != nil { + log.Debug().Msg("Failed to set mfa session") + return nil, nil, err } enrollment, err := p.generateTOTPEnrollment(ctx, user.ID) if err != nil { @@ -190,6 +209,37 @@ 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), + } + // 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{ From 4190a1133771ba683eb853adec7a5cbf26c0168a Mon Sep 17 00:00:00 2001 From: Lakhan Samani Date: Tue, 14 Jul 2026 18:35:19 +0530 Subject: [PATCH 07/28] feat(mfa): rework skip_mfa_setup for the withheld-token model skip_mfa_setup now authenticates via the MFA session cookie plus email/phone_number (same pattern as verify_otp), not a bearer token -- none exists yet when the offer screen withholds it. Issues the previously-withheld access token on success. --- internal/graph/generated/generated.go | 158 ++++++++++++++++-- internal/graph/model/models_gen.go | 6 + internal/graph/schema.graphqls | 21 ++- internal/graph/schema.resolvers.go | 4 +- internal/graphql/provider.go | 8 +- internal/graphql/skip_mfa_setup.go | 6 +- .../integration_tests/skip_mfa_setup_test.go | 126 +++++++------- internal/integration_tests/test_helper.go | 23 +++ internal/service/provider.go | 8 +- internal/service/skip_mfa_setup.go | 83 ++++++--- 10 files changed, 331 insertions(+), 112 deletions(-) diff --git a/internal/graph/generated/generated.go b/internal/graph/generated/generated.go index e206339a1..6970b265d 100644 --- a/internal/graph/generated/generated.go +++ b/internal/graph/generated/generated.go @@ -337,7 +337,7 @@ type ComplexityRoot struct { RotateClientSecret func(childComplexity int, params model.ClientRequest) int RotateScimToken func(childComplexity int, params model.ScimEndpointRequest) int Signup func(childComplexity int, params model.SignUpRequest) int - SkipMfaSetup func(childComplexity int) int + SkipMfaSetup func(childComplexity int, params model.SkipMfaSetupRequest) int TestEndpoint func(childComplexity int, params model.TestEndpointRequest) int UpdateClient func(childComplexity int, params model.UpdateClientRequest) int UpdateEmailTemplate func(childComplexity int, params model.UpdateEmailTemplateRequest) int @@ -665,7 +665,7 @@ type MutationResolver interface { Revoke(ctx context.Context, params model.OAuthRevokeRequest) (*model.Response, error) VerifyOtp(ctx context.Context, params model.VerifyOTPRequest) (*model.AuthResponse, error) ResendOtp(ctx context.Context, params model.ResendOTPRequest) (*model.Response, error) - SkipMfaSetup(ctx context.Context) (*model.Response, error) + SkipMfaSetup(ctx context.Context, params model.SkipMfaSetupRequest) (*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) @@ -2543,7 +2543,12 @@ 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._test_endpoint": if e.complexity.Mutation.TestEndpoint == nil { @@ -4314,6 +4319,7 @@ func (e *executableSchema) Exec(ctx context.Context) graphql.ResponseHandler { ec.unmarshalInputScimEndpointRequest, ec.unmarshalInputSessionQueryRequest, ec.unmarshalInputSignUpRequest, + ec.unmarshalInputSkipMfaSetupRequest, ec.unmarshalInputTestEndpointRequest, ec.unmarshalInputTrustedIssuerRequest, ec.unmarshalInputUpdateAccessRequest, @@ -5666,6 +5672,15 @@ 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 ResendOTPRequest { email: String phone_number: String @@ -5781,10 +5796,14 @@ 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! # WebAuthn / passkey self-service ceremonies (no admin ` + "`" + `_` + "`" + ` prefix). webauthn_registration_options( email: String @@ -7429,6 +7448,34 @@ 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_update_profile_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { var err error args := map[string]any{} @@ -17204,7 +17251,7 @@ 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) @@ -17216,12 +17263,12 @@ func (ec *executionContext) _Mutation_skip_mfa_setup(ctx context.Context, field } return graphql.Null } - res := resTmp.(*model.Response) + res := resTmp.(*model.AuthResponse) fc.Result = res - return ec.marshalNResponse2ᚖgithubᚗcomᚋauthorizerdevᚋauthorizerᚋinternalᚋgraphᚋmodelᚐResponse(ctx, field.Selections, res) + return ec.marshalNAuthResponse2ᚖgithubᚗcomᚋauthorizerdevᚋauthorizerᚋinternalᚋgraphᚋmodelᚐAuthResponse(ctx, field.Selections, res) } -func (ec *executionContext) fieldContext_Mutation_skip_mfa_setup(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { +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, @@ -17230,11 +17277,50 @@ func (ec *executionContext) fieldContext_Mutation_skip_mfa_setup(_ context.Conte Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { switch field.Name { case "message": - return ec.fieldContext_Response_message(ctx, field) + return ec.fieldContext_AuthResponse_message(ctx, field) + case "should_show_email_otp_screen": + return ec.fieldContext_AuthResponse_should_show_email_otp_screen(ctx, field) + case "should_show_mobile_otp_screen": + return ec.fieldContext_AuthResponse_should_show_mobile_otp_screen(ctx, field) + case "should_show_totp_screen": + return ec.fieldContext_AuthResponse_should_show_totp_screen(ctx, field) + case "should_offer_webauthn_mfa_verify": + return ec.fieldContext_AuthResponse_should_offer_webauthn_mfa_verify(ctx, field) + case "should_offer_mfa_setup": + return ec.fieldContext_AuthResponse_should_offer_mfa_setup(ctx, field) + case "should_offer_webauthn_mfa_setup": + return ec.fieldContext_AuthResponse_should_offer_webauthn_mfa_setup(ctx, field) + case "access_token": + return ec.fieldContext_AuthResponse_access_token(ctx, field) + case "id_token": + return ec.fieldContext_AuthResponse_id_token(ctx, field) + case "refresh_token": + return ec.fieldContext_AuthResponse_refresh_token(ctx, field) + case "expires_in": + return ec.fieldContext_AuthResponse_expires_in(ctx, field) + case "user": + return ec.fieldContext_AuthResponse_user(ctx, field) + case "authenticator_scanner_image": + return ec.fieldContext_AuthResponse_authenticator_scanner_image(ctx, field) + case "authenticator_secret": + return ec.fieldContext_AuthResponse_authenticator_secret(ctx, field) + case "authenticator_recovery_codes": + return ec.fieldContext_AuthResponse_authenticator_recovery_codes(ctx, field) } - return nil, fmt.Errorf("no field named %q was found under type Response", field.Name) + return nil, fmt.Errorf("no field named %q was found under type AuthResponse", field.Name) }, } + defer func() { + 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 } @@ -34506,6 +34592,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{} @@ -42010,6 +42137,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) diff --git a/internal/graph/model/models_gen.go b/internal/graph/model/models_gen.go index 2a071d07c..95960a20b 100644 --- a/internal/graph/model/models_gen.go +++ b/internal/graph/model/models_gen.go @@ -714,6 +714,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"` diff --git a/internal/graph/schema.graphqls b/internal/graph/schema.graphqls index d724dbe5c..05f210212 100644 --- a/internal/graph/schema.graphqls +++ b/internal/graph/schema.graphqls @@ -1231,6 +1231,15 @@ 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 ResendOTPRequest { email: String phone_number: String @@ -1346,10 +1355,14 @@ 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! # 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 fba97056a..3f1461c76 100644 --- a/internal/graph/schema.resolvers.go +++ b/internal/graph/schema.resolvers.go @@ -83,8 +83,8 @@ 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) } // WebauthnRegistrationOptions is the resolver for the webauthn_registration_options field. diff --git a/internal/graphql/provider.go b/internal/graphql/provider.go index 11d7ea713..a0f6294df 100644 --- a/internal/graphql/provider.go +++ b/internal/graphql/provider.go @@ -96,9 +96,11 @@ 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) // 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 8e15a13ce..586556957 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/integration_tests/skip_mfa_setup_test.go b/internal/integration_tests/skip_mfa_setup_test.go index fbfb18ddf..fe0e2f870 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())) 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,56 @@ 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 InvalidArgument when neither email nor phone_number is given", func(t *testing.T) { + cfg := getTestConfig() + cfg.EnableMFA = true + cfg.EnableTOTPLogin = true + ts := initTestSetup(t, cfg) + req, ctx := createContext(ts) + + // SkipMFASetup reads the mfa session cookie before validating + // email/phone_number (mirrors VerifyOTP's ordering), so a cookie + // must be present here or the call short-circuits on Unauthenticated + // before ever reaching the check this subtest targets. The value + // itself need not resolve to a real session — no user lookup happens + // before the email/phone_number check runs. + 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.KindInvalidArgument, svcErr.Kind) + }) } diff --git a/internal/integration_tests/test_helper.go b/internal/integration_tests/test_helper.go index c1bcab83b..7f8c42c0d 100644 --- a/internal/integration_tests/test_helper.go +++ b/internal/integration_tests/test_helper.go @@ -424,3 +424,26 @@ func latestAppSessionCookie(s *testSetup) string { } return latest } + +// latestMfaSessionCookie returns the most recent value of the +// MfaCookieName+"_session" cookie written to the gin response writer in this +// test — same rationale as latestAppSessionCookie: a token-withheld MFA +// offer (e.g. Login) sets this cookie only on the response, and +// http.Request cookies are not auto-updated from responses in this +// in-process test setup, so a caller that needs to act on that session (e.g. +// skip_mfa_setup) must copy it onto the next request by hand. +func latestMfaSessionCookie(s *testSetup) string { + prefix := constants.MfaCookieName + "_session=" + latest := "" + for _, h := range s.GinContext.Writer.Header().Values("Set-Cookie") { + first := h + if i := strings.IndexByte(h, ';'); i >= 0 { + first = h[:i] + } + first = strings.TrimSpace(first) + if strings.HasPrefix(first, prefix) { + latest = strings.TrimPrefix(first, prefix) + } + } + return latest +} diff --git a/internal/service/provider.go b/internal/service/provider.go index 60d818041..785297ef1 100644 --- a/internal/service/provider.go +++ b/internal/service/provider.go @@ -100,9 +100,11 @@ 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) // ResendVerifyEmail re-issues a pending email-verification link. Public — // response is generic to avoid account enumeration. diff --git a/internal/service/skip_mfa_setup.go b/internal/service/skip_mfa_setup.go index f26525aaf..50cfddec2 100644 --- a/internal/service/skip_mfa_setup.go +++ b/internal/service/skip_mfa_setup.go @@ -3,31 +3,58 @@ 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 mfaGateOfferAll 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{} + + 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)) + 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 + 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") + } - // 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") + // 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. + 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 p.Config.EnforceMFA { @@ -35,16 +62,22 @@ func (p *provider) SkipMFASetup(ctx context.Context, meta RequestMetadata) (*mod return nil, nil, FailedPrecondition("cannot skip multi factor authentication setup as it is enforced by organization") } - user, err := p.StorageProvider.GetUserByID(ctx, tokenData.UserID) - if err != nil { - log.Debug().Err(err).Msg("Failed to get user by id") - return nil, nil, err - } now := time.Now().Unix() user.HasSkippedMFASetupAt = &now - if _, err := p.StorageProvider.UpdateUser(ctx, user); err != nil { + 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 + + // 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 } From d24771614f95eceebd48622138d051698af95c22 Mon Sep 17 00:00:00 2001 From: Lakhan Samani Date: Tue, 14 Jul 2026 18:58:45 +0530 Subject: [PATCH 08/28] feat(mfa): user-initiated lockout + admin reset via _update_user Adds MFALockedAt, lock_mfa (MFA-session-cookie-authenticated, refused when a verified OTP fallback exists), and reset_mfa on the existing _update_user admin mutation -- clears lock/opt-in/skip state and deletes all enrolled authenticators/passkeys, landing the user back on first-time setup on next login. --- internal/constants/audit_event.go | 3 + internal/constants/authenticator_method.go | 4 + internal/graph/generated/generated.go | 243 +++++++++++++++++- internal/graph/model/models_gen.go | 7 + internal/graph/schema.graphqls | 21 ++ internal/graph/schema.resolvers.go | 5 + internal/graphql/lock_mfa.go | 28 ++ internal/graphql/provider.go | 6 + .../integration_tests/admin_reset_mfa_test.go | 97 +++++++ internal/integration_tests/lock_mfa_test.go | 100 +++++++ internal/service/admin_users.go | 23 ++ internal/service/lock_mfa.go | 97 +++++++ internal/service/login.go | 8 + internal/service/provider.go | 6 + internal/service/webauthn.go | 5 + internal/storage/db/arangodb/authenticator.go | 15 ++ .../storage/db/cassandradb/authenticator.go | 28 ++ internal/storage/db/cassandradb/provider.go | 7 + internal/storage/db/cassandradb/user.go | 20 +- .../storage/db/couchbase/authenticator.go | 15 ++ internal/storage/db/couchbase/user.go | 10 +- internal/storage/db/dynamodb/authenticator.go | 20 ++ internal/storage/db/dynamodb/user.go | 3 + internal/storage/db/mongodb/authenticator.go | 8 + .../db/provider_template/authenticator.go | 6 + internal/storage/db/sql/authenticator.go | 6 + internal/storage/provider.go | 3 + internal/storage/schemas/user.go | 14 +- 28 files changed, 788 insertions(+), 20 deletions(-) create mode 100644 internal/graphql/lock_mfa.go create mode 100644 internal/integration_tests/admin_reset_mfa_test.go create mode 100644 internal/integration_tests/lock_mfa_test.go create mode 100644 internal/service/lock_mfa.go diff --git a/internal/constants/audit_event.go b/internal/constants/audit_event.go index bef16c98c..0cdebedbe 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 1310db3f6..de09cf800 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/graph/generated/generated.go b/internal/graph/generated/generated.go index 6970b265d..3ece8eaf4 100644 --- a/internal/graph/generated/generated.go +++ b/internal/graph/generated/generated.go @@ -322,6 +322,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 @@ -547,6 +548,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 @@ -666,6 +668,7 @@ type MutationResolver interface { VerifyOtp(ctx context.Context, params model.VerifyOTPRequest) (*model.AuthResponse, error) ResendOtp(ctx context.Context, params model.ResendOTPRequest) (*model.Response, error) SkipMfaSetup(ctx context.Context, params model.SkipMfaSetupRequest) (*model.AuthResponse, error) + LockMfa(ctx context.Context, params model.LockMfaRequest) (*model.Response, error) WebauthnRegistrationOptions(ctx context.Context, email *string) (*model.WebauthnRegistrationOptionsResponse, error) WebauthnRegistrationVerify(ctx context.Context, params model.WebauthnRegistrationVerifyRequest) (*model.Response, error) WebauthnLoginOptions(ctx context.Context, email *string) (*model.WebauthnLoginOptionsResponse, error) @@ -2363,6 +2366,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 @@ -3849,6 +3864,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 @@ -4300,6 +4322,7 @@ func (e *executableSchema) Exec(ctx context.Context) graphql.ResponseHandler { ec.unmarshalInputListTrustedIssuersRequest, ec.unmarshalInputListUsersRequest, ec.unmarshalInputListWebhookLogRequest, + ec.unmarshalInputLockMfaRequest, ec.unmarshalInputLoginRequest, ec.unmarshalInputMagicLinkLoginRequest, ec.unmarshalInputMobileLoginRequest, @@ -4531,6 +4554,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 } @@ -5269,6 +5296,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 } @@ -5681,6 +5713,13 @@ input SkipMfaSetupRequest { 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 ResendOTPRequest { email: String phone_number: String @@ -5804,6 +5843,11 @@ type Mutation { # 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! # WebAuthn / passkey self-service ceremonies (no admin ` + "`" + `_` + "`" + ` prefix). webauthn_registration_options( email: String @@ -7196,6 +7240,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{} @@ -9914,6 +9986,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) } @@ -14961,6 +15035,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) } @@ -17324,6 +17400,65 @@ func (ec *executionContext) fieldContext_Mutation_skip_mfa_setup(ctx context.Con 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_webauthn_registration_options(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { fc, err := ec.fieldContext_Mutation_webauthn_registration_options(ctx, field) if err != nil { @@ -17833,6 +17968,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) } @@ -23618,6 +23755,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) } @@ -23947,6 +24086,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) } @@ -27932,6 +28073,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 { @@ -28312,6 +28494,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) } @@ -28529,6 +28713,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) } @@ -33638,6 +33824,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{} @@ -35721,7 +35941,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 { @@ -35826,6 +36046,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) @@ -37762,6 +37989,13 @@ 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 "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) @@ -39955,6 +40189,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: @@ -41629,6 +41865,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) diff --git a/internal/graph/model/models_gen.go b/internal/graph/model/models_gen.go index 95960a20b..cc7ff6b27 100644 --- a/internal/graph/model/models_gen.go +++ b/internal/graph/model/models_gen.go @@ -431,6 +431,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"` @@ -920,6 +925,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"` } @@ -953,6 +959,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 05f210212..345029bce 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 } @@ -828,6 +832,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 } @@ -1240,6 +1249,13 @@ input SkipMfaSetupRequest { 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 ResendOTPRequest { email: String phone_number: String @@ -1363,6 +1379,11 @@ type Mutation { # 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! # 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 3f1461c76..d96c4a166 100644 --- a/internal/graph/schema.resolvers.go +++ b/internal/graph/schema.resolvers.go @@ -87,6 +87,11 @@ func (r *mutationResolver) SkipMfaSetup(ctx context.Context, params model.SkipMf 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) +} + // WebauthnRegistrationOptions is the resolver for the webauthn_registration_options field. func (r *mutationResolver) WebauthnRegistrationOptions(ctx context.Context, email *string) (*model.WebauthnRegistrationOptionsResponse, error) { return r.GraphQLProvider.WebauthnRegistrationOptions(ctx, email) diff --git a/internal/graphql/lock_mfa.go b/internal/graphql/lock_mfa.go new file mode 100644 index 000000000..77d6cd27f --- /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/provider.go b/internal/graphql/provider.go index a0f6294df..632ffedb9 100644 --- a/internal/graphql/provider.go +++ b/internal/graphql/provider.go @@ -101,6 +101,12 @@ type Provider interface { // 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) // 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/integration_tests/admin_reset_mfa_test.go b/internal/integration_tests/admin_reset_mfa_test.go new file mode 100644 index 000000000..a1d886b86 --- /dev/null +++ b/internal/integration_tests/admin_reset_mfa_test.go @@ -0,0 +1,97 @@ +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" + signupRes, err := ts.GraphQLProvider.SignUp(ctx, &model.SignUpRequest{ + Email: &email, Password: password, ConfirmPassword: password, + }) + require.NoError(t, err) + require.NotNil(t, signupRes.User) + userID := signupRes.User.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/lock_mfa_test.go b/internal/integration_tests/lock_mfa_test.go new file mode 100644 index 000000000..5af7ffaf9 --- /dev/null +++ b/internal/integration_tests/lock_mfa_test.go @@ -0,0 +1,100 @@ +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, 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") + }) + + 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) + }) + } +} diff --git a/internal/service/admin_users.go b/internal/service/admin_users.go index 9498c7caf..69f6c338c 100644 --- a/internal/service/admin_users.go +++ b/internal/service/admin_users.go @@ -125,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") @@ -309,6 +310,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/lock_mfa.go b/internal/service/lock_mfa.go new file mode 100644 index 000000000..083e06797 --- /dev/null +++ b/internal/service/lock_mfa.go @@ -0,0 +1,97 @@ +// 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)) + 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 + 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") + } + + 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 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 + } + + 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 — the one case where locking is refused because a +// working recovery path already exists. Depends on Task 6's Email/SMS-OTP +// Authenticator rows (constants.EnvKeyEmailOTPAuthenticator / +// constants.EnvKeySMSOTPAuthenticator) — until Task 6 lands, this always +// returns false (no such rows can exist yet), which is safe: it just means +// lock_mfa is never refused, matching this task's standalone test scope. +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 f4aeb654a..5c70b4c33 100644 --- a/internal/service/login.go +++ b/internal/service/login.go @@ -322,6 +322,14 @@ func (p *provider) Login(ctx context.Context, meta RequestMetadata, params *mode isMailOTPEnabled := p.Config.EnableEmailOTP isSMSOTPEnabled := p.Config.EnableSMSOTP + // 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") + } + // 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 { expiresAt := time.Now().Add(1 * time.Minute).Unix() diff --git a/internal/service/provider.go b/internal/service/provider.go index 785297ef1..9e05660d6 100644 --- a/internal/service/provider.go +++ b/internal/service/provider.go @@ -106,6 +106,12 @@ type Provider interface { // 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) + // ResendVerifyEmail re-issues a pending email-verification link. Public — // response is generic to avoid account enumeration. ResendVerifyEmail(ctx context.Context, meta RequestMetadata, params *model.ResendVerifyEmailRequest) (*model.Response, *ResponseSideEffects, error) diff --git a/internal/service/webauthn.go b/internal/service/webauthn.go index 3f459a660..9b407bdd2 100644 --- a/internal/service/webauthn.go +++ b/internal/service/webauthn.go @@ -155,6 +155,11 @@ 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") } + if user.MFALockedAt != nil { + log.Debug().Msg("User's MFA is locked, refusing passkey login") + return nil, nil, FailedPrecondition("your account's multi-factor authentication is locked; contact your administrator to regain access") + } + // A passkey used for PRIMARY login is only one factor (something you // have) — it does not itself satisfy an MFA requirement, so it goes // through the exact same 5-way gate password login does. A WebAuthn diff --git a/internal/storage/db/arangodb/authenticator.go b/internal/storage/db/arangodb/authenticator.go index 327c9e476..38c05af9e 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 b4b9280a6..bd68a8b62 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 996048204..c7501bfb6 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 1d5d253ab..72c55cea2 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 a228b3790..fe472cf64 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 609fef728..afbd1883e 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 673a6510c..042159cb6 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 a12f09c7d..6c8c41d15 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 314399819..383948062 100644 --- a/internal/storage/db/mongodb/authenticator.go +++ b/internal/storage/db/mongodb/authenticator.go @@ -58,3 +58,11 @@ func (p *provider) GetAuthenticatorDetailsByUserId(ctx context.Context, userId s } return authenticators, nil } + +// DeleteAuthenticatorsByUserID removes every authenticator row for a user. +// Used by admin MFA reset. +func (p *provider) DeleteAuthenticatorsByUserID(ctx context.Context, userID string) error { + authenticatorsCollection := p.db.Collection(schemas.Collections.Authenticators, options.Collection()) + _, err := authenticatorsCollection.DeleteMany(ctx, bson.M{"user_id": userID}) + return err +} diff --git a/internal/storage/db/provider_template/authenticator.go b/internal/storage/db/provider_template/authenticator.go index 5bc5702e0..d0f3ef61d 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 b1f71aacc..1b221a780 100644 --- a/internal/storage/db/sql/authenticator.go +++ b/internal/storage/db/sql/authenticator.go @@ -54,3 +54,9 @@ func (p *provider) GetAuthenticatorDetailsByUserId(ctx context.Context, userId s } return &authenticators, nil } + +// DeleteAuthenticatorsByUserID removes every authenticator row for a user. +// Used by admin MFA reset. +func (p *provider) DeleteAuthenticatorsByUserID(ctx context.Context, userID string) error { + return p.db.Where("user_id = ?", userID).Delete(&schemas.Authenticator{}).Error +} diff --git a/internal/storage/provider.go b/internal/storage/provider.go index b00af17a6..dc5505ea4 100644 --- a/internal/storage/provider.go +++ b/internal/storage/provider.go @@ -130,6 +130,9 @@ type Provider interface { // GetAuthenticatorDetailsByUserId retrieves details of an authenticator document based on user ID and authenticator type. // If found, the authenticator document is returned, or an error if not found or an error occurs during the retrieval. GetAuthenticatorDetailsByUserId(ctx context.Context, userId string, authenticatorType string) (*schemas.Authenticator, error) + // DeleteAuthenticatorsByUserID removes every authenticator row (TOTP, + // email OTP, SMS OTP) for a user. Used by admin MFA reset. + DeleteAuthenticatorsByUserID(ctx context.Context, userID string) error // Session Token methods (for database-backed memory store) // AddSessionToken adds a session token to the database diff --git a/internal/storage/schemas/user.go b/internal/storage/schemas/user.go index 554c43646..e8f464de6 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, From 311faa5721fb1004bf2b226c0beed010d404aad8 Mon Sep 17 00:00:00 2001 From: Lakhan Samani Date: Tue, 14 Jul 2026 19:47:26 +0530 Subject: [PATCH 09/28] feat(mfa): email/SMS OTP as enrolled MFA methods Adds email_otp_mfa_setup/sms_otp_mfa_setup (bearer-token authenticated, send a code and create an unverified Authenticator row) and marks that row verified on a successful verify_otp. Retrofits login.go's previously-ungated email/SMS-OTP-as-MFA branches to require a verified enrollment, not just config+contact-info presence -- closing the gap where every optional-MFA user with a phone number was silently OTP-challenged without ever choosing that method. --- internal/graph/generated/generated.go | 282 ++++++++++++++++++ internal/graph/model/models_gen.go | 2 + internal/graph/schema.graphqls | 16 + internal/graph/schema.resolvers.go | 10 + internal/graphql/otp_mfa_setup.go | 45 +++ internal/graphql/provider.go | 7 + .../integration_tests/otp_mfa_setup_test.go | 176 +++++++++++ internal/service/login.go | 10 +- internal/service/otp_mfa_setup.go | 180 +++++++++++ internal/service/provider.go | 7 + internal/service/verify_otp.go | 20 ++ internal/service/webauthn.go | 2 + 12 files changed, 755 insertions(+), 2 deletions(-) create mode 100644 internal/graphql/otp_mfa_setup.go create mode 100644 internal/integration_tests/otp_mfa_setup_test.go create mode 100644 internal/service/otp_mfa_setup.go diff --git a/internal/graph/generated/generated.go b/internal/graph/generated/generated.go index 3ece8eaf4..2c354e4cc 100644 --- a/internal/graph/generated/generated.go +++ b/internal/graph/generated/generated.go @@ -81,7 +81,9 @@ 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 @@ -314,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) int EnableAccess func(childComplexity int, param model.UpdateAccessRequest) int FgaDeleteTuples func(childComplexity int, params model.FgaWriteTuplesInput) int FgaReset func(childComplexity int) int @@ -339,6 +342,7 @@ type ComplexityRoot struct { RotateScimToken func(childComplexity int, params model.ScimEndpointRequest) int Signup func(childComplexity int, params model.SignUpRequest) int SkipMfaSetup func(childComplexity int, params model.SkipMfaSetupRequest) int + SmsOtpMfaSetup func(childComplexity int) int TestEndpoint func(childComplexity int, params model.TestEndpointRequest) int UpdateClient func(childComplexity int, params model.UpdateClientRequest) int UpdateEmailTemplate func(childComplexity int, params model.UpdateEmailTemplateRequest) int @@ -669,6 +673,8 @@ type MutationResolver interface { ResendOtp(ctx context.Context, params model.ResendOTPRequest) (*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) (*model.Response, error) + SmsOtpMfaSetup(ctx context.Context) (*model.Response, error) WebauthnRegistrationOptions(ctx context.Context, email *string) (*model.WebauthnRegistrationOptionsResponse, error) WebauthnRegistrationVerify(ctx context.Context, params model.WebauthnRegistrationVerifyRequest) (*model.Response, error) WebauthnLoginOptions(ctx context.Context, email *string) (*model.WebauthnLoginOptionsResponse, error) @@ -954,6 +960,13 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return e.complexity.AuthResponse.RefreshToken(childComplexity), true + case "AuthResponse.should_offer_email_otp_mfa_setup": + if e.complexity.AuthResponse.ShouldOfferEmailOtpMfaSetup == nil { + break + } + + return e.complexity.AuthResponse.ShouldOfferEmailOtpMfaSetup(childComplexity), true + case "AuthResponse.should_offer_mfa_setup": if e.complexity.AuthResponse.ShouldOfferMfaSetup == nil { break @@ -961,6 +974,13 @@ 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 @@ -2275,6 +2295,13 @@ 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 + } + + return e.complexity.Mutation.EmailOtpMfaSetup(childComplexity), true + case "Mutation._enable_access": if e.complexity.Mutation.EnableAccess == nil { break @@ -2565,6 +2592,13 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return e.complexity.Mutation.SkipMfaSetup(childComplexity, args["params"].(model.SkipMfaSetupRequest)), true + case "Mutation.sms_otp_mfa_setup": + if e.complexity.Mutation.SmsOtpMfaSetup == nil { + break + } + + return e.complexity.Mutation.SmsOtpMfaSetup(childComplexity), true + case "Mutation._test_endpoint": if e.complexity.Mutation.TestEndpoint == nil { break @@ -4612,6 +4646,11 @@ type AuthResponse { # 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 @@ -5848,6 +5887,17 @@ type Mutation { # 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. Requires an + # authenticated caller (bearer token) — this is a settings-screen action + # for an ALREADY-logged-in user adding a second factor, distinct from the + # withheld-token first-time-setup screen (which reuses the same + # underlying Authenticator row once verify_otp marks it verified). + email_otp_mfa_setup: 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 permissions and + # relationship to verify_otp as email_otp_mfa_setup. + sms_otp_mfa_setup: Response! # WebAuthn / passkey self-service ceremonies (no admin ` + "`" + `_` + "`" + ` prefix). webauthn_registration_options( email: String @@ -9746,6 +9796,88 @@ func (ec *executionContext) fieldContext_AuthResponse_should_offer_webauthn_mfa_ 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 { @@ -16381,6 +16513,10 @@ func (ec *executionContext) fieldContext_Mutation_signup(ctx context.Context, fi 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": @@ -16468,6 +16604,10 @@ func (ec *executionContext) fieldContext_Mutation_mobile_signup(ctx context.Cont 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": @@ -16555,6 +16695,10 @@ func (ec *executionContext) fieldContext_Mutation_login(ctx context.Context, fie 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": @@ -16642,6 +16786,10 @@ func (ec *executionContext) fieldContext_Mutation_mobile_login(ctx context.Conte 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": @@ -16895,6 +17043,10 @@ func (ec *executionContext) fieldContext_Mutation_verify_email(ctx context.Conte 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": @@ -17220,6 +17372,10 @@ func (ec *executionContext) fieldContext_Mutation_verify_otp(ctx context.Context 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": @@ -17366,6 +17522,10 @@ func (ec *executionContext) fieldContext_Mutation_skip_mfa_setup(ctx context.Con 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": @@ -17459,6 +17619,102 @@ func (ec *executionContext) fieldContext_Mutation_lock_mfa(ctx context.Context, 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) + }) + 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(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Mutation", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "message": + return ec.fieldContext_Response_message(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type Response", field.Name) + }, + } + return fc, nil +} + +func (ec *executionContext) _Mutation_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) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(*model.Response) + fc.Result = res + return ec.marshalNResponse2ᚖgithubᚗcomᚋauthorizerdevᚋauthorizerᚋinternalᚋgraphᚋmodelᚐResponse(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_Mutation_sms_otp_mfa_setup(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Mutation", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "message": + return ec.fieldContext_Response_message(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type Response", field.Name) + }, + } + return fc, nil +} + func (ec *executionContext) _Mutation_webauthn_registration_options(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { fc, err := ec.fieldContext_Mutation_webauthn_registration_options(ctx, field) if err != nil { @@ -17689,6 +17945,10 @@ func (ec *executionContext) fieldContext_Mutation_webauthn_login_verify(ctx cont 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": @@ -23642,6 +23902,10 @@ func (ec *executionContext) fieldContext_Query_session(ctx context.Context, fiel 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": @@ -36662,6 +36926,10 @@ func (ec *executionContext) _AuthResponse(ctx context.Context, sel ast.Selection 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": @@ -37996,6 +38264,20 @@ func (ec *executionContext) _Mutation(ctx context.Context, sel ast.SelectionSet) if out.Values[i] == graphql.Null { out.Invalids++ } + case "email_otp_mfa_setup": + out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { + return ec._Mutation_email_otp_mfa_setup(ctx, field) + }) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "sms_otp_mfa_setup": + out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { + return ec._Mutation_sms_otp_mfa_setup(ctx, field) + }) + if out.Values[i] == graphql.Null { + out.Invalids++ + } case "webauthn_registration_options": out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { return ec._Mutation_webauthn_registration_options(ctx, field) diff --git a/internal/graph/model/models_gen.go b/internal/graph/model/models_gen.go index cc7ff6b27..9190126fa 100644 --- a/internal/graph/model/models_gen.go +++ b/internal/graph/model/models_gen.go @@ -85,6 +85,8 @@ type AuthResponse struct { 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"` diff --git a/internal/graph/schema.graphqls b/internal/graph/schema.graphqls index 345029bce..4fedcad4b 100644 --- a/internal/graph/schema.graphqls +++ b/internal/graph/schema.graphqls @@ -148,6 +148,11 @@ type AuthResponse { # 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 @@ -1384,6 +1389,17 @@ type Mutation { # 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. Requires an + # authenticated caller (bearer token) — this is a settings-screen action + # for an ALREADY-logged-in user adding a second factor, distinct from the + # withheld-token first-time-setup screen (which reuses the same + # underlying Authenticator row once verify_otp marks it verified). + email_otp_mfa_setup: 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 permissions and + # relationship to verify_otp as email_otp_mfa_setup. + sms_otp_mfa_setup: Response! # WebAuthn / passkey self-service ceremonies (no admin `_` prefix). webauthn_registration_options( email: String diff --git a/internal/graph/schema.resolvers.go b/internal/graph/schema.resolvers.go index d96c4a166..0cc347a40 100644 --- a/internal/graph/schema.resolvers.go +++ b/internal/graph/schema.resolvers.go @@ -92,6 +92,16 @@ func (r *mutationResolver) LockMfa(ctx context.Context, params model.LockMfaRequ return r.GraphQLProvider.LockMFA(ctx, ¶ms) } +// EmailOtpMfaSetup is the resolver for the email_otp_mfa_setup field. +func (r *mutationResolver) EmailOtpMfaSetup(ctx context.Context) (*model.Response, error) { + return r.GraphQLProvider.EmailOTPMFASetup(ctx) +} + +// SmsOtpMfaSetup is the resolver for the sms_otp_mfa_setup field. +func (r *mutationResolver) SmsOtpMfaSetup(ctx context.Context) (*model.Response, error) { + return r.GraphQLProvider.SMSOTPMFASetup(ctx) +} + // WebauthnRegistrationOptions is the resolver for the webauthn_registration_options field. func (r *mutationResolver) WebauthnRegistrationOptions(ctx context.Context, email *string) (*model.WebauthnRegistrationOptionsResponse, error) { return r.GraphQLProvider.WebauthnRegistrationOptions(ctx, email) diff --git a/internal/graphql/otp_mfa_setup.go b/internal/graphql/otp_mfa_setup.go new file mode 100644 index 000000000..b52259785 --- /dev/null +++ b/internal/graphql/otp_mfa_setup.go @@ -0,0 +1,45 @@ +// internal/graphql/otp_mfa_setup.go +package graphql + +import ( + "context" + + "github.com/authorizerdev/authorizer/internal/graph/model" + "github.com/authorizerdev/authorizer/internal/metrics" + "github.com/authorizerdev/authorizer/internal/service" + "github.com/authorizerdev/authorizer/internal/utils" +) + +// EmailOTPMFASetup delegates to the transport-agnostic service layer. +// Permissions: authenticated user. +func (g *graphqlProvider) EmailOTPMFASetup(ctx context.Context) (*model.Response, error) { + gc, err := utils.GinContextFromContext(ctx) + if err != nil { + g.Log.Debug().Err(err).Msg("failed to get gin context") + metrics.RecordSecurityEvent(metrics.SecurityEventGinContextMissing, "graphql") + return nil, err + } + res, side, err := g.ServiceProvider.EmailOTPMFASetup(ctx, service.MetaFromGin(gc)) + if err != nil { + return nil, err + } + service.ApplyToGin(gc, side) + return res, nil +} + +// SMSOTPMFASetup delegates to the transport-agnostic service layer. +// Permissions: authenticated user. +func (g *graphqlProvider) SMSOTPMFASetup(ctx context.Context) (*model.Response, error) { + gc, err := utils.GinContextFromContext(ctx) + if err != nil { + g.Log.Debug().Err(err).Msg("failed to get gin context") + metrics.RecordSecurityEvent(metrics.SecurityEventGinContextMissing, "graphql") + return nil, err + } + res, side, err := g.ServiceProvider.SMSOTPMFASetup(ctx, service.MetaFromGin(gc)) + 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 632ffedb9..22acd1211 100644 --- a/internal/graphql/provider.go +++ b/internal/graphql/provider.go @@ -107,6 +107,13 @@ type Provider interface { // 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. + // Permissions: authorized user (bearer token). + EmailOTPMFASetup(ctx context.Context) (*model.Response, error) + // SMSOTPMFASetup is EmailOTPMFASetup's SMS twin. + // Permissions: authorized user (bearer token). + SMSOTPMFASetup(ctx context.Context) (*model.Response, error) // DeleteEmailTemplate is the method to delete email template. // Permissions: authorizer:admin DeleteEmailTemplate(ctx context.Context, params *model.DeleteEmailTemplateRequest) (*model.Response, error) diff --git a/internal/integration_tests/otp_mfa_setup_test.go b/internal/integration_tests/otp_mfa_setup_test.go new file mode 100644 index 000000000..e8a9542c6 --- /dev/null +++ b/internal/integration_tests/otp_mfa_setup_test.go @@ -0,0 +1,176 @@ +package integration_tests + +import ( + "fmt" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/authorizerdev/authorizer/internal/constants" + "github.com/authorizerdev/authorizer/internal/crypto" + "github.com/authorizerdev/authorizer/internal/graph/model" + "github.com/authorizerdev/authorizer/internal/refs" + "github.com/authorizerdev/authorizer/internal/storage/schemas" +) + +// TestEmailOTPMFAEnrollment covers the full enroll-then-use cycle for +// email-OTP-as-MFA: +// - a first-time, unenrolled login is withheld and offers email OTP setup +// (mfaGateOfferAll's ShouldOfferEmailOtpMfaSetup, wired in login.go). +// - EmailOTPMFASetup (bearer-token authenticated) creates an unverified +// Authenticator row and does not by itself gate login. +// - verify_otp marks that row verified. +// - only after verification does a subsequent login route through the +// retrofitted email-OTP-as-MFA branch instead of the offer-all screen. +// +// Sequencing note: EmailOTPMFASetup requires a bearer token, but the first +// login's token is withheld (mfaGateOfferAll). This test obtains a token via +// skip_mfa_setup first -- the realistic "skip now, add a second factor later +// from account settings" flow -- rather than calling EmailOTPMFASetup with no +// token, which cannot succeed. +func TestEmailOTPMFAEnrollment(t *testing.T) { + const password = "Password@123" + + cfg := getTestConfig() + cfg.EnableMFA = true + cfg.EnableTOTPLogin = true + cfg.EnableEmailOTP = true + cfg.SMTPHost = "localhost" + cfg.SMTPPort = 1025 + cfg.SMTPSenderEmail = "test@authorizer.dev" + cfg.SMTPSenderName = "Test" + cfg.SMTPLocalName = "Test" + cfg.SMTPSkipTLSVerification = true + cfg.IsEmailServiceEnabled = true + ts := initTestSetup(t, cfg) + require.True(t, ts.Config.IsEmailServiceEnabled, "test SMTP fixture must derive IsEmailServiceEnabled=true") + req, ctx := createContext(ts) + + email := "email_otp_mfa_" + uuid.NewString() + "@authorizer.dev" + _, err := ts.GraphQLProvider.SignUp(ctx, &model.SignUpRequest{ + Email: &email, Password: password, ConfirmPassword: password, + IsMultiFactorAuthEnabled: refs.NewBoolRef(true), + }) + require.NoError(t, err) + user, err := ts.StorageProvider.GetUserByEmail(ctx, email) + require.NoError(t, err) + + // First login: nothing enrolled yet -> withheld, offered. + loginRes, err := ts.GraphQLProvider.Login(ctx, &model.LoginRequest{Email: &email, Password: password}) + require.NoError(t, err) + require.Nil(t, loginRes.AccessToken, "no method enrolled yet -> withheld, offer-all") + assert.True(t, refs.BoolValue(loginRes.ShouldOfferEmailOtpMfaSetup)) + + // Get a bearer token the realistic way: skip the offer now (settings- + // screen enrollment is a separate, later, already-logged-in action). + mfaSession := latestMfaSessionCookie(ts) + require.NotEmpty(t, mfaSession, "login must have set an mfa session cookie on the response") + req.Header.Set("Cookie", fmt.Sprintf("%s=%s", constants.MfaCookieName+"_session", mfaSession)) + skipRes, err := ts.GraphQLProvider.SkipMFASetup(ctx, &model.SkipMfaSetupRequest{Email: &email}) + require.NoError(t, err) + require.NotNil(t, skipRes.AccessToken) + req.Header.Set("Authorization", "Bearer "+*skipRes.AccessToken) + + // Now, as an already-logged-in user, enroll a second factor. + setupRes, err := ts.GraphQLProvider.EmailOTPMFASetup(ctx) + require.NoError(t, err) + require.NotNil(t, setupRes) + + authenticator, err := ts.StorageProvider.GetAuthenticatorDetailsByUserId(ctx, user.ID, constants.EnvKeyEmailOTPAuthenticator) + require.NoError(t, err) + require.NotNil(t, authenticator) + assert.Nil(t, authenticator.VerifiedAt, "setup alone must not mark the enrollment verified") + + // Verify the code EmailOTPMFASetup sent. The test can't intercept the + // outgoing email, so overwrite the stored digest with one for a known + // plaintext, mirroring TestVerifyOTP's pattern. + const knownPlainOTP = "654321" + storedOTP, err := ts.StorageProvider.GetOTPByEmail(ctx, email) + require.NoError(t, err) + require.NotNil(t, storedOTP) + storedOTP.Otp = crypto.HashOTP(knownPlainOTP, cfg.JWTSecret) + storedOTP.ExpiresAt = time.Now().Add(5 * time.Minute).Unix() + _, err = ts.StorageProvider.UpsertOTP(ctx, storedOTP) + require.NoError(t, err) + + // verify_otp is identified by the mfa session cookie, not the bearer + // token -- arm a fresh session directly (same approach as + // TestVerifyOTPNoRecord) rather than threading the earlier one through. + verifySession := uuid.NewString() + require.NoError(t, ts.MemoryStoreProvider.SetMfaSession(user.ID, verifySession, time.Now().Add(5*time.Minute).Unix())) + req.Header.Set("Cookie", fmt.Sprintf("%s=%s", constants.MfaCookieName+"_session", verifySession)) + verifyRes, err := ts.GraphQLProvider.VerifyOTP(ctx, &model.VerifyOTPRequest{Email: &email, Otp: knownPlainOTP}) + require.NoError(t, err) + require.NotNil(t, verifyRes) + + authenticator, err = ts.StorageProvider.GetAuthenticatorDetailsByUserId(ctx, user.ID, constants.EnvKeyEmailOTPAuthenticator) + require.NoError(t, err) + require.NotNil(t, authenticator) + require.NotNil(t, authenticator.VerifiedAt, "verify_otp must mark the pending enrollment verified") + + // A subsequent login now routes through the retrofitted email-OTP-as-MFA + // challenge branch (not mfaGateOfferAll) since the enrollment is verified. + req.Header.Set("Authorization", "") + secondLogin, err := ts.GraphQLProvider.Login(ctx, &model.LoginRequest{Email: &email, Password: password}) + require.NoError(t, err) + require.Nil(t, secondLogin.AccessToken, "an enrolled-and-verified email OTP factor must challenge, not skip, login") + assert.True(t, refs.BoolValue(secondLogin.ShouldShowEmailOtpScreen), "must route through the OTP challenge branch, not the offer-all screen") +} + +// 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/service/login.go b/internal/service/login.go index 5c70b4c33..691d11876 100644 --- a/internal/service/login.go +++ b/internal/service/login.go @@ -331,7 +331,9 @@ func (p *provider) Login(ctx context.Context, meta RequestMetadata, params *mode } // 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 { + emailOTPAuthenticator, _ := p.StorageProvider.GetAuthenticatorDetailsByUserId(ctx, user.ID, constants.EnvKeyEmailOTPAuthenticator) + emailOTPEnrolled := emailOTPAuthenticator != nil && emailOTPAuthenticator.VerifiedAt != nil + if effectiveMFAEnabled(p.Config, user) && isMFAEnabled && isMailOTPEnabled && isEmailServiceEnabled && isEmailLogin && emailOTPEnrolled { expiresAt := time.Now().Add(1 * time.Minute).Unix() otpData, err := generateOTP(expiresAt) if err != nil { @@ -360,7 +362,9 @@ func (p *provider) Login(ctx context.Context, meta RequestMetadata, params *mode }, 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 { + smsOTPAuthenticator, _ := p.StorageProvider.GetAuthenticatorDetailsByUserId(ctx, user.ID, constants.EnvKeySMSOTPAuthenticator) + smsOTPEnrolled := smsOTPAuthenticator != nil && smsOTPAuthenticator.VerifiedAt != nil + if effectiveMFAEnabled(p.Config, user) && isMFAEnabled && isSMSOTPEnabled && isSMSServiceEnabled && isMobileLogin && smsOTPEnrolled { expiresAt := time.Now().Add(1 * time.Minute).Unix() otpData, err := generateOTP(expiresAt) if err != nil { @@ -452,6 +456,8 @@ func (p *provider) Login(ctx context.Context, meta RequestMetadata, params *mode Message: `Proceed to mfa setup`, ShouldShowTotpScreen: refs.NewBoolRef(true), ShouldOfferWebauthnMfaSetup: refs.NewBoolRef(p.Config.EnableWebauthnMFA), + ShouldOfferEmailOtpMfaSetup: refs.NewBoolRef(p.Config.EnableEmailOTP && p.Config.IsEmailServiceEnabled), + ShouldOfferSmsOtpMfaSetup: refs.NewBoolRef(p.Config.EnableSMSOTP && p.Config.IsSMSServiceEnabled), AuthenticatorScannerImage: refs.NewStringRef(enrollment.ScannerImage), AuthenticatorSecret: refs.NewStringRef(enrollment.Secret), AuthenticatorRecoveryCodes: enrollment.RecoveryCodes, diff --git a/internal/service/otp_mfa_setup.go b/internal/service/otp_mfa_setup.go new file mode 100644 index 000000000..dc1041c23 --- /dev/null +++ b/internal/service/otp_mfa_setup.go @@ -0,0 +1,180 @@ +// internal/service/otp_mfa_setup.go +package service + +import ( + "context" + "strings" + "time" + + "github.com/authorizerdev/authorizer/internal/audit" + "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" + "github.com/authorizerdev/authorizer/internal/utils" +) + +// EmailOTPMFASetup sends a one-time code to the authenticated caller's own +// email and creates (or refreshes) an unverified email-OTP Authenticator +// row. Permissions: authenticated caller (bearer token) — this is a +// settings-screen "add a second factor" action. +func (p *provider) EmailOTPMFASetup(ctx context.Context, meta RequestMetadata) (*model.Response, *ResponseSideEffects, error) { + log := p.Log.With().Str("func", "EmailOTPMFASetup").Logger() + + tokenData, err := p.callerTokenData(ctx, meta) + if err != nil || tokenData == nil || tokenData.UserID == "" { + log.Debug().Err(err).Msg("Failed to get user id from session or access token") + return nil, nil, Unauthenticated("unauthorized") + } + + if !p.Config.EnableEmailOTP || !p.Config.IsEmailServiceEnabled { + return nil, nil, FailedPrecondition("email OTP is not available on this server") + } + + 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 + } + email := strings.TrimSpace(refs.StringValue(user.Email)) + if email == "" { + return nil, nil, FailedPrecondition("account has no email address to send an OTP to") + } + + expiresAt := time.Now().Add(1 * time.Minute).Unix() + otpData, err := p.generateAndStoreOTP(ctx, user, expiresAt) + if err != nil { + log.Debug().Err(err).Msg("Failed to generate otp") + return nil, nil, err + } + + if err := p.upsertUnverifiedAuthenticator(ctx, user.ID, constants.EnvKeyEmailOTPAuthenticator); err != nil { + log.Debug().Err(err).Msg("Failed to record pending enrollment") + return nil, nil, err + } + + go func() { + if err := p.EmailProvider.SendEmail([]string{email}, constants.VerificationTypeOTP, map[string]any{ + "user": user.ToMap(), + "organization": utils.GetOrganization(p.Config), + "otp": otpData.Otp, + }); err != nil { + log.Debug().Msg("Failed to send otp email") + } + }() + + p.AuditProvider.LogEvent(audit.Event{ + Action: constants.AuditMFAEnabledEvent, + Protocol: meta.Protocol, ActorID: user.ID, + ActorType: constants.AuditActorTypeUser, + ActorEmail: email, + ResourceType: constants.AuditResourceTypeUser, + ResourceID: user.ID, + IPAddress: meta.IPAddress, + UserAgent: meta.UserAgent, + }) + + return &model.Response{Message: "Check your email for the verification code"}, nil, nil +} + +// SMSOTPMFASetup is EmailOTPMFASetup's SMS twin. +func (p *provider) SMSOTPMFASetup(ctx context.Context, meta RequestMetadata) (*model.Response, *ResponseSideEffects, error) { + log := p.Log.With().Str("func", "SMSOTPMFASetup").Logger() + + tokenData, err := p.callerTokenData(ctx, meta) + if err != nil || tokenData == nil || tokenData.UserID == "" { + log.Debug().Err(err).Msg("Failed to get user id from session or access token") + return nil, nil, Unauthenticated("unauthorized") + } + + if !p.Config.EnableSMSOTP || !p.Config.IsSMSServiceEnabled { + return nil, nil, FailedPrecondition("SMS OTP is not available on this server") + } + + 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 + } + phone := strings.TrimSpace(refs.StringValue(user.PhoneNumber)) + if phone == "" { + return nil, nil, FailedPrecondition("account has no phone number to send an OTP to") + } + + expiresAt := time.Now().Add(1 * time.Minute).Unix() + otpData, err := p.generateAndStoreOTP(ctx, user, expiresAt) + if err != nil { + log.Debug().Err(err).Msg("Failed to generate otp") + return nil, nil, err + } + + if err := p.upsertUnverifiedAuthenticator(ctx, user.ID, constants.EnvKeySMSOTPAuthenticator); err != nil { + log.Debug().Err(err).Msg("Failed to record pending enrollment") + return nil, nil, err + } + + go func() { + smsBody := strings.Builder{} + smsBody.WriteString("Your verification code is: ") + smsBody.WriteString(otpData.Otp) + if err := p.SMSProvider.SendSMS(phone, smsBody.String()); err != nil { + log.Debug().Msg("Failed to send sms") + } + }() + + p.AuditProvider.LogEvent(audit.Event{ + Action: constants.AuditMFAEnabledEvent, + Protocol: meta.Protocol, ActorID: user.ID, + ActorType: constants.AuditActorTypeUser, + ActorEmail: refs.StringValue(user.Email), + ResourceType: constants.AuditResourceTypeUser, + ResourceID: user.ID, + IPAddress: meta.IPAddress, + UserAgent: meta.UserAgent, + }) + + return &model.Response{Message: "Check your phone for the verification code"}, nil, nil +} + +// generateAndStoreOTP mirrors the local `generateOTP` closures in login.go / +// resend_otp.go: 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. Not shared with those +// closures directly since they capture per-call locals (log, ctx); this is +// the package-level equivalent for the setup mutations. +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 9e05660d6..3899f69c9 100644 --- a/internal/service/provider.go +++ b/internal/service/provider.go @@ -112,6 +112,13 @@ type Provider interface { // 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. Requires + // an authenticated caller (bearer token) — a settings-screen action. + EmailOTPMFASetup(ctx context.Context, meta RequestMetadata) (*model.Response, *ResponseSideEffects, error) + // SMSOTPMFASetup is EmailOTPMFASetup's SMS twin. + SMSOTPMFASetup(ctx context.Context, meta RequestMetadata) (*model.Response, *ResponseSideEffects, error) + // ResendVerifyEmail re-issues a pending email-verification link. Public — // response is generic to avoid account enumeration. ResendVerifyEmail(ctx context.Context, meta RequestMetadata, params *model.ResendVerifyEmailRequest) (*model.Response, *ResponseSideEffects, error) diff --git a/internal/service/verify_otp.go b/internal/service/verify_otp.go index ddc43a6dc..3b4dbeccb 100644 --- a/internal/service/verify_otp.go +++ b/internal/service/verify_otp.go @@ -181,6 +181,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 diff --git a/internal/service/webauthn.go b/internal/service/webauthn.go index 9b407bdd2..17eb27530 100644 --- a/internal/service/webauthn.go +++ b/internal/service/webauthn.go @@ -223,6 +223,8 @@ func (p *provider) WebauthnLoginVerify(ctx context.Context, meta RequestMetadata 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 From f4fcbd93f77f70ba4e1b770e7baaa84c88e7ff68 Mon Sep 17 00:00:00 2001 From: Lakhan Samani Date: Tue, 14 Jul 2026 20:07:43 +0530 Subject: [PATCH 10/28] feat(mfa): gate SignUp() the same way as Login() A brand-new signup with MFA available now has its token withheld and is offered the first-time setup screen, matching login/passkey -- previously SignUp always issued a token immediately regardless of MFA. Guarded behind EnableMFA && EnableTOTPLogin, mirroring login.go's own guard around the same resolveMFAGate/generateTOTPEnrollment call: AuthenticatorProvider is nil whenever EnableTOTPLogin is off, so calling generateTOTPEnrollment unconditionally would panic on a webauthn-only-enforced-MFA signup. Also fixes three pre-existing tests whose fixtures assumed SignUp never creates MFA artifacts and always returns AuthResponse.User: - mfa_gate_login_test.go: addVerifiedAuthenticator now upserts instead of relying on AddAuthenticator's create-only-if-absent semantics, since signUpUser's own SignUp call (cfg.EnableMFA=true) already leaves an unverified TOTP row behind via the new gate. - mfa_service_availability_test.go, admin_reset_mfa_test.go: look the signed-up user up by email instead of reading SignUpResponse.User, which the withheld-token gate path doesn't set (matching login.go's own mfaGateOfferAll/BlockEnroll responses). --- .../integration_tests/admin_reset_mfa_test.go | 13 ++++-- .../integration_tests/mfa_gate_login_test.go | 17 +++++++- .../mfa_service_availability_test.go | 11 ++++- internal/integration_tests/signup_test.go | 42 ++++++++++++++++++ internal/service/signup.go | 43 +++++++++++++++++++ 5 files changed, 120 insertions(+), 6 deletions(-) diff --git a/internal/integration_tests/admin_reset_mfa_test.go b/internal/integration_tests/admin_reset_mfa_test.go index a1d886b86..5ae42d95e 100644 --- a/internal/integration_tests/admin_reset_mfa_test.go +++ b/internal/integration_tests/admin_reset_mfa_test.go @@ -29,12 +29,19 @@ func TestAdminResetMFA(t *testing.T) { email := "admin_reset_mfa_" + uuid.NewString() + "@authorizer.dev" password := "Password@123" - signupRes, err := ts.GraphQLProvider.SignUp(ctx, &model.SignUpRequest{ + _, err := ts.GraphQLProvider.SignUp(ctx, &model.SignUpRequest{ Email: &email, Password: password, ConfirmPassword: password, }) require.NoError(t, err) - require.NotNil(t, signupRes.User) - userID := signupRes.User.ID + // 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 diff --git a/internal/integration_tests/mfa_gate_login_test.go b/internal/integration_tests/mfa_gate_login_test.go index 558bd6c77..ceb86b22c 100644 --- a/internal/integration_tests/mfa_gate_login_test.go +++ b/internal/integration_tests/mfa_gate_login_test.go @@ -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, diff --git a/internal/integration_tests/mfa_service_availability_test.go b/internal/integration_tests/mfa_service_availability_test.go index 436ec343f..17c38ce21 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/signup_test.go b/internal/integration_tests/signup_test.go index 525a2b7e6..11a933f7c 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,44 @@ 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) + }) +} diff --git a/internal/service/signup.go b/internal/service/signup.go index 419ac227d..4b4a174b3 100644 --- a/internal/service/signup.go +++ b/internal/service/signup.go @@ -321,6 +321,49 @@ func (p *provider) SignUp(ctx context.Context, meta RequestMetadata, params *mod if nonce == "" { nonce = uuid.New().String() } + // Mirrors login.go's own guard around its resolveMFAGate/ + // generateTOTPEnrollment call (see isMFAEnabled && isTOTPLoginEnabled + // there): AuthenticatorProvider is nil whenever EnableTOTPLogin is off + // (internal/authenticators/providers.go), so generateTOTPEnrollment + // would panic on a nil interface call if reached with TOTP unavailable. + // login.go never reaches its gate in that case either — a server with + // MFA available only via WebAuthn/email/SMS OTP (no TOTP) doesn't gate + // password-based login/signup at all; that's an existing, accepted + // scope limit of the basic-auth gate, not something Task 7 changes. + if p.Config.EnableMFA && p.Config.EnableTOTPLogin { + 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 + } + enrollment, err := p.generateTOTPEnrollment(ctx, user.ID) + if err != nil { + log.Debug().Err(err).Msg("Failed to generate totp") + return nil, nil, err + } + return &model.AuthResponse{ + Message: `Proceed to mfa setup`, + ShouldShowTotpScreen: refs.NewBoolRef(true), + ShouldOfferWebauthnMfaSetup: refs.NewBoolRef(p.Config.EnableWebauthnMFA), + ShouldOfferEmailOtpMfaSetup: refs.NewBoolRef(p.Config.EnableEmailOTP && p.Config.IsEmailServiceEnabled), + ShouldOfferSmsOtpMfaSetup: refs.NewBoolRef(p.Config.EnableSMSOTP && p.Config.IsSMSServiceEnabled), + AuthenticatorScannerImage: refs.NewStringRef(enrollment.ScannerImage), + AuthenticatorSecret: refs.NewStringRef(enrollment.Secret), + AuthenticatorRecoveryCodes: enrollment.RecoveryCodes, + }, 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 From cfa5822a3706e22218d5325fb9091ca80bd3b9cb Mon Sep 17 00:00:00 2001 From: Lakhan Samani Date: Tue, 14 Jul 2026 20:25:49 +0530 Subject: [PATCH 11/28] feat(mfa): gate oauth_callback.go the same way as password login Consumer social logins (Google/GitHub/etc.) now go through the same resolveMFAGate decision as password/passkey login before the browser session cookie is set -- matches verified Auth0 behavior (MFA policy applies on top of social connections, not exempted). Enterprise SSO (saml_sp.go) and Session()'s routine token refresh are untouched. --- internal/http_handlers/oauth_callback.go | 29 +++ .../integration_tests/oauth_mfa_gate_test.go | 174 ++++++++++++++++++ internal/service/oauth_mfa_gate.go | 64 +++++++ internal/service/provider.go | 10 + 4 files changed, 277 insertions(+) create mode 100644 internal/integration_tests/oauth_mfa_gate_test.go create mode 100644 internal/service/oauth_mfa_gate.go diff --git a/internal/http_handlers/oauth_callback.go b/internal/http_handlers/oauth_callback.go index 0d931cb2f..a89cda967 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" @@ -351,6 +352,34 @@ 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 + } + sessionKey := provider + ":" + user.ID cookie.SetSession(ctx, authToken.FingerPrintHash, h.Config.AppCookieSecure, cookie.ParseSameSite(h.Config.AppCookieSameSite)) _ = h.MemoryStoreProvider.SetUserSession(sessionKey, constants.TokenTypeSessionToken+"_"+authToken.FingerPrint, authToken.FingerPrintHash, authToken.SessionTokenExpiresAt) diff --git a/internal/integration_tests/oauth_mfa_gate_test.go b/internal/integration_tests/oauth_mfa_gate_test.go new file mode 100644 index 000000000..e94d763f3 --- /dev/null +++ b/internal/integration_tests/oauth_mfa_gate_test.go @@ -0,0 +1,174 @@ +package integration_tests + +import ( + "context" + "errors" + "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("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 + ts := initTestSetup(t, cfg) + _, ctx := createContext(ts) + + user := newTestUser(t, ts, ctx, func(u *schemas.User) { + u.IsMultiFactorAuthEnabled = refs.NewBoolRef(true) + }) + now := time.Now().Unix() + _, err := ts.StorageProvider.AddAuthenticator(ctx, &schemas.Authenticator{ + UserID: user.ID, + Method: constants.EnvKeyTOTPAuthenticator, + Secret: "dummy-secret-for-oauth-gate-test", + VerifiedAt: &now, + }) + require.NoError(t, err) + + side := &service.ResponseSideEffects{} + meta := service.RequestMetadata{HostURL: testAuthorizerHost(ts)} + withheld, redirectSuffix, err := ts.ServiceProvider.EvaluateMFAGateForOAuth(ctx, meta, side, user) + require.NoError(t, err) + assert.True(t, withheld) + assert.Contains(t, redirectSuffix, "mfa_required=1") + assert.Contains(t, redirectSuffix, "totp") + }) + + 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/service/oauth_mfa_gate.go b/internal/service/oauth_mfa_gate.go new file mode 100644 index 000000000..d84ba7c7d --- /dev/null +++ b/internal/service/oauth_mfa_gate.go @@ -0,0 +1,64 @@ +// internal/service/oauth_mfa_gate.go +package service + +import ( + "context" + "net/url" + "strings" + "time" + + "github.com/authorizerdev/authorizer/internal/constants" + "github.com/authorizerdev/authorizer/internal/storage/schemas" +) + +// EvaluateMFAGateForOAuth is oauth_callback.go's entry point into the same +// gate Login/SignUp/WebauthnLoginVerify use. See interface doc comment on +// Provider.EvaluateMFAGateForOAuth. +// +// Like WebauthnLoginVerify, an OAuth/social login is only one factor +// (something you have — the provider's own session) and does not itself +// satisfy an MFA requirement, so authenticatorVerified below only considers +// TOTP + WebAuthn (not email/SMS OTP, which login.go only offers for a +// password-based primary login that has an associated email/phone to send +// a code to). +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 + authenticatorVerified := totpVerified || len(webauthnCreds) > 0 + + 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) + } + case mfaGateBlockEnroll, mfaGateOfferAll: + methods = append(methods, constants.EnvKeyTOTPAuthenticator) + if p.Config.EnableWebauthnMFA { + methods = append(methods, constants.AuthRecipeMethodWebauthn) + } + } + q := url.Values{} + q.Set("mfa_required", "1") + q.Set("mfa_methods", strings.Join(methods, ",")) + return true, q.Encode(), nil +} diff --git a/internal/service/provider.go b/internal/service/provider.go index 3899f69c9..23c9a03ec 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" ) @@ -175,6 +176,15 @@ type Provider interface { // WebauthnDeleteCredential deletes one of the authenticated caller's own // passkeys. Requires a session. Public (self-service). WebauthnDeleteCredential(ctx context.Context, meta RequestMetadata, id string) (*model.Response, error) + + // EvaluateMFAGateForOAuth runs the same MFA gate Login/SignUp/ + // WebauthnLoginVerify use, for a user who just completed an OAuth/ + // social-provider callback. On a withhold-group outcome it sets the MFA + // session cookie via side and returns (true, redirectSuffix) where + // redirectSuffix is the query string to append instead of the normal + // state/code params. On mfaGateNone/mfaGateSkippedSetup it returns + // (false, "") and the caller proceeds with cookie.SetSession as today. + EvaluateMFAGateForOAuth(ctx context.Context, meta RequestMetadata, side *ResponseSideEffects, user *schemas.User) (withheld bool, redirectSuffix string, err error) } // New constructs a new service provider. From af90accf45f5047504710f8d46743abc119e98b8 Mon Sep 17 00:00:00 2001 From: Lakhan Samani Date: Wed, 15 Jul 2026 07:37:32 +0530 Subject: [PATCH 12/28] fix(mfa): close I1 -- gate Login/SignUp even when TOTP login is off login.go and signup.go only ran resolveMFAGate when isTOTPLoginEnabled was also true, so a WebAuthn-only enforced-MFA server (EnableTOTPLogin off) skipped the gate entirely and issued tokens unconditionally to unenrolled users -- no offer, no enforcement. WebauthnLoginVerify was already correctly gated on such a server (Task 3), which is what exposed the gap. Broaden both guards to isMFAEnabled / EnableMFA alone, matching webauthn.go's pattern: call resolveMFAGate unconditionally and condition only the TOTP-specific parts of the response (enrollment generation, TOTP screen/fields) on EnableTOTPLogin. WebAuthn/email/SMS offer flags are now set from config in mfaGateBlockEnroll too, not just mfaGateOfferAll, so a WebAuthn-only server has something actionable to offer. --- .../integration_tests/mfa_gate_login_test.go | 55 +++++++++++++++ internal/integration_tests/signup_test.go | 29 ++++++++ internal/service/login.go | 69 ++++++++++++------- internal/service/signup.go | 44 ++++++------ 4 files changed, 151 insertions(+), 46 deletions(-) diff --git a/internal/integration_tests/mfa_gate_login_test.go b/internal/integration_tests/mfa_gate_login_test.go index ceb86b22c..e25e0246f 100644 --- a/internal/integration_tests/mfa_gate_login_test.go +++ b/internal/integration_tests/mfa_gate_login_test.go @@ -245,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/signup_test.go b/internal/integration_tests/signup_test.go index 11a933f7c..55b07dbdb 100644 --- a/internal/integration_tests/signup_test.go +++ b/internal/integration_tests/signup_test.go @@ -228,4 +228,33 @@ func TestSignUpGatesToken(t *testing.T) { 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/service/login.go b/internal/service/login.go index 691d11876..9e2e1ad65 100644 --- a/internal/service/login.go +++ b/internal/service/login.go @@ -390,8 +390,14 @@ func (p *provider) Login(ctx context.Context, meta RequestMetadata, params *mode ShouldShowMobileOtpScreen: refs.NewBoolRef(isMobileLogin), }, 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 @@ -416,7 +422,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 { @@ -429,39 +439,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 + 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 } - enrollment, err := p.generateTOTPEnrollment(ctx, user.ID) - if err != nil { - log.Debug().Msg("Failed to generate totp for optional setup") - return nil, nil, err - } - return &model.AuthResponse{ + res := &model.AuthResponse{ Message: `Proceed to mfa setup`, - ShouldShowTotpScreen: refs.NewBoolRef(true), ShouldOfferWebauthnMfaSetup: refs.NewBoolRef(p.Config.EnableWebauthnMFA), ShouldOfferEmailOtpMfaSetup: refs.NewBoolRef(p.Config.EnableEmailOTP && p.Config.IsEmailServiceEnabled), ShouldOfferSmsOtpMfaSetup: refs.NewBoolRef(p.Config.EnableSMSOTP && p.Config.IsSMSServiceEnabled), - AuthenticatorScannerImage: refs.NewStringRef(enrollment.ScannerImage), - AuthenticatorSecret: refs.NewStringRef(enrollment.Secret), - AuthenticatorRecoveryCodes: enrollment.RecoveryCodes, - }, side, nil + } + 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: diff --git a/internal/service/signup.go b/internal/service/signup.go index 4b4a174b3..f97887139 100644 --- a/internal/service/signup.go +++ b/internal/service/signup.go @@ -321,16 +321,15 @@ func (p *provider) SignUp(ctx context.Context, meta RequestMetadata, params *mod if nonce == "" { nonce = uuid.New().String() } - // Mirrors login.go's own guard around its resolveMFAGate/ - // generateTOTPEnrollment call (see isMFAEnabled && isTOTPLoginEnabled - // there): AuthenticatorProvider is nil whenever EnableTOTPLogin is off - // (internal/authenticators/providers.go), so generateTOTPEnrollment - // would panic on a nil interface call if reached with TOTP unavailable. - // login.go never reaches its gate in that case either — a server with - // MFA available only via WebAuthn/email/SMS OTP (no TOTP) doesn't gate - // password-based login/signup at all; that's an existing, accepted - // scope limit of the basic-auth gate, not something Task 7 changes. - if p.Config.EnableMFA && p.Config.EnableTOTPLogin { + // 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: @@ -339,21 +338,24 @@ func (p *provider) SignUp(ctx context.Context, meta RequestMetadata, params *mod log.Debug().Err(err).Msg("Failed to set mfa session") return nil, nil, err } - enrollment, err := p.generateTOTPEnrollment(ctx, user.ID) - if err != nil { - log.Debug().Err(err).Msg("Failed to generate totp") - return nil, nil, err - } - return &model.AuthResponse{ + res := &model.AuthResponse{ Message: `Proceed to mfa setup`, - ShouldShowTotpScreen: refs.NewBoolRef(true), ShouldOfferWebauthnMfaSetup: refs.NewBoolRef(p.Config.EnableWebauthnMFA), ShouldOfferEmailOtpMfaSetup: refs.NewBoolRef(p.Config.EnableEmailOTP && p.Config.IsEmailServiceEnabled), ShouldOfferSmsOtpMfaSetup: refs.NewBoolRef(p.Config.EnableSMSOTP && p.Config.IsSMSServiceEnabled), - AuthenticatorScannerImage: refs.NewStringRef(enrollment.ScannerImage), - AuthenticatorSecret: refs.NewStringRef(enrollment.Secret), - AuthenticatorRecoveryCodes: enrollment.RecoveryCodes, - }, side, nil + } + 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 From 0a1b1e8ff4adec3d7b22edb7f143af9eaae9b32f Mon Sep 17 00:00:00 2001 From: Lakhan Samani Date: Wed, 15 Jul 2026 07:52:18 +0530 Subject: [PATCH 13/28] fix(mfa): close I2 -- cookie-authenticated email/SMS OTP setup EmailOTPMFASetup/SMSOTPMFASetup required a bearer token, but a user in the token-withheld first-time MFA offer has none yet -- only the MFA session cookie. Add a resolveOTPSetupCaller helper that tries the bearer token first (unchanged) and falls back to the cookie + email/phone_number pattern already used by SkipMFASetup/LockMFA. Schema gains an optional OtpMfaSetupRequest (email/phone_number, mirrors SkipMfaSetupRequest minus state) on both mutations. Also confirmed webauthn_registration_options/verify have the same bearer-token-only gap -- out of scope here, left for a follow-up. --- internal/graph/generated/generated.go | 183 ++++++++++++++++-- internal/graph/model/models_gen.go | 5 + internal/graph/schema.graphqls | 32 ++- internal/graph/schema.resolvers.go | 8 +- internal/graphql/otp_mfa_setup.go | 12 +- internal/graphql/provider.go | 12 +- .../integration_tests/otp_mfa_setup_test.go | 161 ++++++++++++++- internal/service/otp_mfa_setup.go | 95 ++++++--- internal/service/provider.go | 11 +- 9 files changed, 447 insertions(+), 72 deletions(-) diff --git a/internal/graph/generated/generated.go b/internal/graph/generated/generated.go index 2c354e4cc..ec5eac7bf 100644 --- a/internal/graph/generated/generated.go +++ b/internal/graph/generated/generated.go @@ -316,7 +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) 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 @@ -342,7 +342,7 @@ type ComplexityRoot struct { RotateScimToken func(childComplexity int, params model.ScimEndpointRequest) int Signup func(childComplexity int, params model.SignUpRequest) int SkipMfaSetup func(childComplexity int, params model.SkipMfaSetupRequest) int - SmsOtpMfaSetup func(childComplexity int) int + SmsOtpMfaSetup func(childComplexity int, params *model.OtpMfaSetupRequest) int TestEndpoint func(childComplexity int, params model.TestEndpointRequest) int UpdateClient func(childComplexity int, params model.UpdateClientRequest) int UpdateEmailTemplate func(childComplexity int, params model.UpdateEmailTemplateRequest) int @@ -673,8 +673,8 @@ type MutationResolver interface { ResendOtp(ctx context.Context, params model.ResendOTPRequest) (*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) (*model.Response, error) - SmsOtpMfaSetup(ctx context.Context) (*model.Response, error) + EmailOtpMfaSetup(ctx context.Context, params *model.OtpMfaSetupRequest) (*model.Response, error) + SmsOtpMfaSetup(ctx context.Context, params *model.OtpMfaSetupRequest) (*model.Response, error) WebauthnRegistrationOptions(ctx context.Context, email *string) (*model.WebauthnRegistrationOptionsResponse, error) WebauthnRegistrationVerify(ctx context.Context, params model.WebauthnRegistrationVerifyRequest) (*model.Response, error) WebauthnLoginOptions(ctx context.Context, email *string) (*model.WebauthnLoginOptionsResponse, error) @@ -2300,7 +2300,12 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin break } - return e.complexity.Mutation.EmailOtpMfaSetup(childComplexity), true + 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 { @@ -2597,7 +2602,12 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin break } - return e.complexity.Mutation.SmsOtpMfaSetup(childComplexity), true + 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 { @@ -4365,6 +4375,7 @@ func (e *executableSchema) Exec(ctx context.Context) graphql.ResponseHandler { ec.unmarshalInputOrgOIDCConnectionRequest, ec.unmarshalInputOrgSAMLConnectionRequest, ec.unmarshalInputOrganizationRequest, + ec.unmarshalInputOtpMfaSetupRequest, ec.unmarshalInputPaginatedRequest, ec.unmarshalInputPaginationRequest, ec.unmarshalInputPermissionCheckInput, @@ -5759,6 +5770,17 @@ input LockMfaRequest { 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 @@ -5888,16 +5910,19 @@ type Mutation { # 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. Requires an - # authenticated caller (bearer token) — this is a settings-screen action - # for an ALREADY-logged-in user adding a second factor, distinct from the - # withheld-token first-time-setup screen (which reuses the same - # underlying Authenticator row once verify_otp marks it verified). - email_otp_mfa_setup: Response! + # 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 permissions and - # relationship to verify_otp as email_otp_mfa_setup. - sms_otp_mfa_setup: Response! + # and creates an unverified SMS-OTP MFA enrollment. Same dual-mode + # permissions and relationship to verify_otp as email_otp_mfa_setup. + sms_otp_mfa_setup(params: OtpMfaSetupRequest): Response! # WebAuthn / passkey self-service ceremonies (no admin ` + "`" + `_` + "`" + ` prefix). webauthn_registration_options( email: String @@ -7262,6 +7287,34 @@ func (ec *executionContext) field_Mutation__verify_org_domain_argsParams( return zeroVal, nil } +func (ec *executionContext) field_Mutation_email_otp_mfa_setup_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { + var err error + args := map[string]any{} + arg0, err := ec.field_Mutation_email_otp_mfa_setup_argsParams(ctx, rawArgs) + if err != nil { + return nil, err + } + args["params"] = arg0 + return args, nil +} +func (ec *executionContext) field_Mutation_email_otp_mfa_setup_argsParams( + ctx context.Context, + rawArgs map[string]any, +) (*model.OtpMfaSetupRequest, error) { + if _, ok := rawArgs["params"]; !ok { + var zeroVal *model.OtpMfaSetupRequest + return zeroVal, nil + } + + ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("params")) + if tmp, ok := rawArgs["params"]; ok { + return ec.unmarshalOOtpMfaSetupRequest2ᚖgithubᚗcomᚋauthorizerdevᚋauthorizerᚋinternalᚋgraphᚋmodelᚐOtpMfaSetupRequest(ctx, tmp) + } + + var zeroVal *model.OtpMfaSetupRequest + return zeroVal, nil +} + func (ec *executionContext) field_Mutation_forgot_password_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { var err error args := map[string]any{} @@ -7598,6 +7651,34 @@ func (ec *executionContext) field_Mutation_skip_mfa_setup_argsParams( return zeroVal, nil } +func (ec *executionContext) field_Mutation_sms_otp_mfa_setup_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { + var err error + args := map[string]any{} + arg0, err := ec.field_Mutation_sms_otp_mfa_setup_argsParams(ctx, rawArgs) + if err != nil { + return nil, err + } + args["params"] = arg0 + return args, nil +} +func (ec *executionContext) field_Mutation_sms_otp_mfa_setup_argsParams( + ctx context.Context, + rawArgs map[string]any, +) (*model.OtpMfaSetupRequest, error) { + if _, ok := rawArgs["params"]; !ok { + var zeroVal *model.OtpMfaSetupRequest + return zeroVal, nil + } + + ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("params")) + if tmp, ok := rawArgs["params"]; ok { + return ec.unmarshalOOtpMfaSetupRequest2ᚖgithubᚗcomᚋauthorizerdevᚋauthorizerᚋinternalᚋgraphᚋmodelᚐOtpMfaSetupRequest(ctx, tmp) + } + + var zeroVal *model.OtpMfaSetupRequest + return zeroVal, nil +} + func (ec *executionContext) field_Mutation_update_profile_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { var err error args := map[string]any{} @@ -17633,7 +17714,7 @@ func (ec *executionContext) _Mutation_email_otp_mfa_setup(ctx context.Context, f }() 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) + return ec.resolvers.Mutation().EmailOtpMfaSetup(rctx, fc.Args["params"].(*model.OtpMfaSetupRequest)) }) if err != nil { ec.Error(ctx, err) @@ -17650,7 +17731,7 @@ func (ec *executionContext) _Mutation_email_otp_mfa_setup(ctx context.Context, f return ec.marshalNResponse2ᚖgithubᚗcomᚋauthorizerdevᚋauthorizerᚋinternalᚋgraphᚋmodelᚐResponse(ctx, field.Selections, res) } -func (ec *executionContext) fieldContext_Mutation_email_otp_mfa_setup(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { +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, @@ -17664,6 +17745,17 @@ func (ec *executionContext) fieldContext_Mutation_email_otp_mfa_setup(_ context. 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 } @@ -17681,7 +17773,7 @@ func (ec *executionContext) _Mutation_sms_otp_mfa_setup(ctx context.Context, fie }() 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) + return ec.resolvers.Mutation().SmsOtpMfaSetup(rctx, fc.Args["params"].(*model.OtpMfaSetupRequest)) }) if err != nil { ec.Error(ctx, err) @@ -17698,7 +17790,7 @@ func (ec *executionContext) _Mutation_sms_otp_mfa_setup(ctx context.Context, fie return ec.marshalNResponse2ᚖgithubᚗcomᚋauthorizerdevᚋauthorizerᚋinternalᚋgraphᚋmodelᚐResponse(ctx, field.Selections, res) } -func (ec *executionContext) fieldContext_Mutation_sms_otp_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, @@ -17712,6 +17804,17 @@ func (ec *executionContext) fieldContext_Mutation_sms_otp_mfa_setup(_ context.Co 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 } @@ -34555,6 +34658,40 @@ func (ec *executionContext) unmarshalInputOrganizationRequest(ctx context.Contex return it, nil } +func (ec *executionContext) unmarshalInputOtpMfaSetupRequest(ctx context.Context, obj any) (model.OtpMfaSetupRequest, error) { + var it model.OtpMfaSetupRequest + asMap := map[string]any{} + for k, v := range obj.(map[string]any) { + asMap[k] = v + } + + fieldsInOrder := [...]string{"email", "phone_number"} + for _, k := range fieldsInOrder { + v, ok := asMap[k] + if !ok { + continue + } + switch k { + case "email": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("email")) + data, err := ec.unmarshalOString2ᚖstring(ctx, v) + if err != nil { + return it, err + } + it.Email = data + case "phone_number": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("phone_number")) + data, err := ec.unmarshalOString2ᚖstring(ctx, v) + if err != nil { + return it, err + } + it.PhoneNumber = data + } + } + + return it, nil +} + func (ec *executionContext) unmarshalInputPaginatedRequest(ctx context.Context, obj any) (model.PaginatedRequest, error) { var it model.PaginatedRequest asMap := map[string]any{} @@ -43789,6 +43926,14 @@ func (ec *executionContext) unmarshalOMobileSignUpRequest2ᚖgithubᚗcomᚋauth return &res, graphql.ErrorOnPath(ctx, err) } +func (ec *executionContext) unmarshalOOtpMfaSetupRequest2ᚖgithubᚗcomᚋauthorizerdevᚋauthorizerᚋinternalᚋgraphᚋmodelᚐOtpMfaSetupRequest(ctx context.Context, v any) (*model.OtpMfaSetupRequest, error) { + if v == nil { + return nil, nil + } + res, err := ec.unmarshalInputOtpMfaSetupRequest(ctx, v) + return &res, graphql.ErrorOnPath(ctx, err) +} + func (ec *executionContext) unmarshalOPaginatedRequest2ᚖgithubᚗcomᚋauthorizerdevᚋauthorizerᚋinternalᚋgraphᚋmodelᚐPaginatedRequest(ctx context.Context, v any) (*model.PaginatedRequest, error) { if v == nil { return nil, nil diff --git a/internal/graph/model/models_gen.go b/internal/graph/model/models_gen.go index 9190126fa..126b25b94 100644 --- a/internal/graph/model/models_gen.go +++ b/internal/graph/model/models_gen.go @@ -612,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"` } diff --git a/internal/graph/schema.graphqls b/internal/graph/schema.graphqls index 4fedcad4b..0cf8ce49e 100644 --- a/internal/graph/schema.graphqls +++ b/internal/graph/schema.graphqls @@ -1261,6 +1261,17 @@ input LockMfaRequest { 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 @@ -1390,16 +1401,19 @@ type Mutation { # 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. Requires an - # authenticated caller (bearer token) — this is a settings-screen action - # for an ALREADY-logged-in user adding a second factor, distinct from the - # withheld-token first-time-setup screen (which reuses the same - # underlying Authenticator row once verify_otp marks it verified). - email_otp_mfa_setup: Response! + # 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 permissions and - # relationship to verify_otp as email_otp_mfa_setup. - sms_otp_mfa_setup: Response! + # and creates an unverified SMS-OTP MFA enrollment. Same dual-mode + # permissions and relationship to verify_otp as email_otp_mfa_setup. + sms_otp_mfa_setup(params: OtpMfaSetupRequest): Response! # WebAuthn / passkey self-service ceremonies (no admin `_` prefix). webauthn_registration_options( email: String diff --git a/internal/graph/schema.resolvers.go b/internal/graph/schema.resolvers.go index 0cc347a40..4fb74542f 100644 --- a/internal/graph/schema.resolvers.go +++ b/internal/graph/schema.resolvers.go @@ -93,13 +93,13 @@ func (r *mutationResolver) LockMfa(ctx context.Context, params model.LockMfaRequ } // EmailOtpMfaSetup is the resolver for the email_otp_mfa_setup field. -func (r *mutationResolver) EmailOtpMfaSetup(ctx context.Context) (*model.Response, error) { - return r.GraphQLProvider.EmailOTPMFASetup(ctx) +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) (*model.Response, error) { - return r.GraphQLProvider.SMSOTPMFASetup(ctx) +func (r *mutationResolver) SmsOtpMfaSetup(ctx context.Context, params *model.OtpMfaSetupRequest) (*model.Response, error) { + return r.GraphQLProvider.SMSOTPMFASetup(ctx, params) } // WebauthnRegistrationOptions is the resolver for the webauthn_registration_options field. diff --git a/internal/graphql/otp_mfa_setup.go b/internal/graphql/otp_mfa_setup.go index b52259785..8fb2199f6 100644 --- a/internal/graphql/otp_mfa_setup.go +++ b/internal/graphql/otp_mfa_setup.go @@ -11,15 +11,15 @@ import ( ) // EmailOTPMFASetup delegates to the transport-agnostic service layer. -// Permissions: authenticated user. -func (g *graphqlProvider) EmailOTPMFASetup(ctx context.Context) (*model.Response, error) { +// 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)) + res, side, err := g.ServiceProvider.EmailOTPMFASetup(ctx, service.MetaFromGin(gc), params) if err != nil { return nil, err } @@ -28,15 +28,15 @@ func (g *graphqlProvider) EmailOTPMFASetup(ctx context.Context) (*model.Response } // SMSOTPMFASetup delegates to the transport-agnostic service layer. -// Permissions: authenticated user. -func (g *graphqlProvider) SMSOTPMFASetup(ctx context.Context) (*model.Response, error) { +// 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)) + res, side, err := g.ServiceProvider.SMSOTPMFASetup(ctx, service.MetaFromGin(gc), params) if err != nil { return nil, err } diff --git a/internal/graphql/provider.go b/internal/graphql/provider.go index 22acd1211..7a92e3cf5 100644 --- a/internal/graphql/provider.go +++ b/internal/graphql/provider.go @@ -108,12 +108,14 @@ type Provider interface { // 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. - // Permissions: authorized user (bearer token). - EmailOTPMFASetup(ctx context.Context) (*model.Response, error) + // 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). - SMSOTPMFASetup(ctx context.Context) (*model.Response, error) + // Permissions: authorized user (bearer token) OR MFA session cookie. + SMSOTPMFASetup(ctx context.Context, params *model.OtpMfaSetupRequest) (*model.Response, error) // DeleteEmailTemplate is the method to delete email template. // Permissions: authorizer:admin DeleteEmailTemplate(ctx context.Context, params *model.DeleteEmailTemplateRequest) (*model.Response, error) diff --git a/internal/integration_tests/otp_mfa_setup_test.go b/internal/integration_tests/otp_mfa_setup_test.go index e8a9542c6..4b4ba54f2 100644 --- a/internal/integration_tests/otp_mfa_setup_test.go +++ b/internal/integration_tests/otp_mfa_setup_test.go @@ -75,7 +75,7 @@ func TestEmailOTPMFAEnrollment(t *testing.T) { req.Header.Set("Authorization", "Bearer "+*skipRes.AccessToken) // Now, as an already-logged-in user, enroll a second factor. - setupRes, err := ts.GraphQLProvider.EmailOTPMFASetup(ctx) + setupRes, err := ts.GraphQLProvider.EmailOTPMFASetup(ctx, nil) require.NoError(t, err) require.NotNil(t, setupRes) @@ -120,6 +120,165 @@ func TestEmailOTPMFAEnrollment(t *testing.T) { 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. +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) +} + +// TestOTPMFASetupRejectsUnauthenticatedCaller confirms the new dual-mode +// resolution fails closed: a caller with neither a valid bearer token/session +// NOR a valid MFA session cookie + email/phone_number must be rejected, for +// both email_otp_mfa_setup and sms_otp_mfa_setup. +func TestOTPMFASetupRejectsUnauthenticatedCaller(t *testing.T) { + cfg := getTestConfig() + cfg.EnableEmailOTP = true + cfg.IsEmailServiceEnabled = true + cfg.EnableSMSOTP = true + cfg.IsSMSServiceEnabled = true + ts := initTestSetup(t, cfg) + _, ctx := createContext(ts) + + // No Authorization header, no MFA session cookie, no params at all. + _, err := ts.GraphQLProvider.EmailOTPMFASetup(ctx, nil) + assert.Error(t, err, "no token and no cookie/identity must be rejected") + + _, err = ts.GraphQLProvider.SMSOTPMFASetup(ctx, nil) + assert.Error(t, err, "no token and no cookie/identity must be rejected") + + // email/phone_number supplied but no MFA session cookie set at all -- + // still must be rejected (params alone never authenticate). + someEmail := "no_cookie_" + uuid.NewString() + "@authorizer.dev" + _, err = ts.GraphQLProvider.EmailOTPMFASetup(ctx, &model.OtpMfaSetupRequest{Email: &someEmail}) + assert.Error(t, err, "email/phone_number without a valid mfa session cookie must be rejected") +} + // TestVerifyOTPDoesNotAutoEnroll ensures verify_otp's new VerifiedAt-marking // logic only fires when a pending (unverified) Authenticator row already // exists -- i.e. only for a code sent by EmailOTPMFASetup/SMSOTPMFASetup. A diff --git a/internal/service/otp_mfa_setup.go b/internal/service/otp_mfa_setup.go index dc1041c23..be402e946 100644 --- a/internal/service/otp_mfa_setup.go +++ b/internal/service/otp_mfa_setup.go @@ -6,8 +6,11 @@ import ( "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" @@ -15,28 +18,77 @@ import ( "github.com/authorizerdev/authorizer/internal/utils" ) -// EmailOTPMFASetup sends a one-time code to the authenticated caller's own -// email and creates (or refreshes) an unverified email-OTP Authenticator -// row. Permissions: authenticated caller (bearer token) — this is a -// settings-screen "add a second factor" action. -func (p *provider) EmailOTPMFASetup(ctx context.Context, meta RequestMetadata) (*model.Response, *ResponseSideEffects, error) { - log := p.Log.With().Str("func", "EmailOTPMFASetup").Logger() +// resolveOTPSetupCaller resolves the caller for EmailOTPMFASetup / +// SMSOTPMFASetup under either of two auth modes: +// +// 1. Bearer token / session (unchanged, existing behavior) — an +// already-logged-in user adding a second factor from account settings. +// Any email/phone_number param is ignored; the token already identifies +// the user. +// 2. MFA session cookie — a caller in the token-withheld first-time-offer +// state (mfaGateOfferAll) has no bearer token yet. Falls back to the +// same cookie + email/phone_number identity-resolution pattern already +// used by SkipMFASetup/LockMFA: resolve the user by the given +// email/phone_number, then validate the MFA session cookie is actually +// theirs. +// +// Returns Unauthenticated if neither mode resolves a caller. +func (p *provider) resolveOTPSetupCaller(ctx context.Context, meta RequestMetadata, params *model.OtpMfaSetupRequest) (*schemas.User, error) { + if tokenData, err := p.callerTokenData(ctx, meta); err == nil && tokenData != nil && tokenData.UserID != "" { + return p.StorageProvider.GetUserByID(ctx, tokenData.UserID) + } - 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 { + return nil, Unauthenticated(`unauthorized`) } - if !p.Config.EnableEmailOTP || !p.Config.IsEmailServiceEnabled { - return nil, nil, FailedPrecondition("email OTP is not available on this server") + var email, phoneNumber string + if params != nil { + email = strings.TrimSpace(refs.StringValue(params.Email)) + phoneNumber = strings.TrimSpace(refs.StringValue(params.PhoneNumber)) + } + if email == "" && phoneNumber == "" { + return nil, Unauthenticated(`unauthorized`) } - user, err := p.StorageProvider.GetUserByID(ctx, tokenData.UserID) + var user *schemas.User + if email != "" { + user, err = p.StorageProvider.GetUserByEmail(ctx, email) + } else { + user, err = p.StorageProvider.GetUserByPhoneNumber(ctx, phoneNumber) + } + if user == nil || err != nil { + return nil, Unauthenticated(`unauthorized`) + } + + if _, err := p.MemoryStoreProvider.GetMfaSession(user.ID, mfaSession); err != nil { + return nil, Unauthenticated(`unauthorized`) + } + + return user, nil +} + +// EmailOTPMFASetup sends a one-time code to the caller's own email and +// creates (or refreshes) an unverified email-OTP Authenticator row. +// Permissions: authenticated caller (bearer token) — the settings-screen +// "add a second factor" action — OR, absent a token, the MFA session cookie +// plus params.email/phone_number for a caller in the withheld first-time- +// offer state. See resolveOTPSetupCaller. +func (p *provider) EmailOTPMFASetup(ctx context.Context, meta RequestMetadata, params *model.OtpMfaSetupRequest) (*model.Response, *ResponseSideEffects, error) { + log := p.Log.With().Str("func", "EmailOTPMFASetup").Logger() + + user, err := p.resolveOTPSetupCaller(ctx, meta, params) if err != nil { - log.Debug().Err(err).Msg("Failed to get user by id") + 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") @@ -79,24 +131,19 @@ func (p *provider) EmailOTPMFASetup(ctx context.Context, meta RequestMetadata) ( } // SMSOTPMFASetup is EmailOTPMFASetup's SMS twin. -func (p *provider) SMSOTPMFASetup(ctx context.Context, meta RequestMetadata) (*model.Response, *ResponseSideEffects, error) { +func (p *provider) SMSOTPMFASetup(ctx context.Context, meta RequestMetadata, params *model.OtpMfaSetupRequest) (*model.Response, *ResponseSideEffects, error) { log := p.Log.With().Str("func", "SMSOTPMFASetup").Logger() - 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") + user, err := p.resolveOTPSetupCaller(ctx, meta, params) + if err != nil { + log.Debug().Err(err).Msg("Failed to resolve caller") + return nil, nil, err } if !p.Config.EnableSMSOTP || !p.Config.IsSMSServiceEnabled { return nil, nil, FailedPrecondition("SMS OTP is not available on this server") } - 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 - } phone := strings.TrimSpace(refs.StringValue(user.PhoneNumber)) if phone == "" { return nil, nil, FailedPrecondition("account has no phone number to send an OTP to") diff --git a/internal/service/provider.go b/internal/service/provider.go index 23c9a03ec..05b001e7a 100644 --- a/internal/service/provider.go +++ b/internal/service/provider.go @@ -114,11 +114,14 @@ type Provider interface { 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. Requires - // an authenticated caller (bearer token) — a settings-screen action. - EmailOTPMFASetup(ctx context.Context, meta RequestMetadata) (*model.Response, *ResponseSideEffects, error) + // 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) (*model.Response, *ResponseSideEffects, error) + SMSOTPMFASetup(ctx context.Context, meta RequestMetadata, params *model.OtpMfaSetupRequest) (*model.Response, *ResponseSideEffects, error) // ResendVerifyEmail re-issues a pending email-verification link. Public — // response is generic to avoid account enumeration. From 58540c1aaf53dba46000f92f82cfcfab4e192582 Mon Sep 17 00:00:00 2001 From: Lakhan Samani Date: Wed, 15 Jul 2026 08:05:36 +0530 Subject: [PATCH 14/28] fix(mfa): oauth gate honors email/SMS OTP, tighten weak assertion (M1, M3) authenticatorVerified in oauth_mfa_gate.go only checked TOTP+WebAuthn, so a user whose only enrolled factor was a verified email/SMS OTP authenticator got routed to mfaGateOfferAll (re-offered setup) instead of mfaGateBlockVerify (asked to verify what they already have). Fold email/SMS OTP verification into the same computation login.go already uses for its own enrollment checks, and list email_otp/sms_otp in the mfa_methods hint on both the verify and offer/enroll branches. Also tighten mfaGateBlockVerify's test assertion: Contains(...,"totp") never actually distinguished it from the offer/enroll branches. Enable every other configured MFA method in that subtest and assert mfa_methods is exactly "totp" -- the only value the verify branch (lists what's verified) can produce vs. what an offer/enroll branch (lists everything configured) would. --- .../integration_tests/oauth_mfa_gate_test.go | 24 ++++++++++++++- internal/service/oauth_mfa_gate.go | 30 +++++++++++++++---- 2 files changed, 48 insertions(+), 6 deletions(-) diff --git a/internal/integration_tests/oauth_mfa_gate_test.go b/internal/integration_tests/oauth_mfa_gate_test.go index e94d763f3..6ed3cc8dc 100644 --- a/internal/integration_tests/oauth_mfa_gate_test.go +++ b/internal/integration_tests/oauth_mfa_gate_test.go @@ -3,6 +3,7 @@ package integration_tests import ( "context" "errors" + "net/url" "testing" "time" @@ -124,6 +125,19 @@ func TestEvaluateMFAGateForOAuth(t *testing.T) { 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) @@ -145,7 +159,15 @@ func TestEvaluateMFAGateForOAuth(t *testing.T) { require.NoError(t, err) assert.True(t, withheld) assert.Contains(t, redirectSuffix, "mfa_required=1") - assert.Contains(t, redirectSuffix, "totp") + + 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) { diff --git a/internal/service/oauth_mfa_gate.go b/internal/service/oauth_mfa_gate.go index d84ba7c7d..a44489c89 100644 --- a/internal/service/oauth_mfa_gate.go +++ b/internal/service/oauth_mfa_gate.go @@ -17,10 +17,14 @@ import ( // // 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, so authenticatorVerified below only considers -// TOTP + WebAuthn (not email/SMS OTP, which login.go only offers for a -// password-based primary login that has an associated email/phone to send -// a code to). +// 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") @@ -29,7 +33,11 @@ func (p *provider) EvaluateMFAGateForOAuth(ctx context.Context, meta RequestMeta webauthnCreds, _ := p.StorageProvider.ListWebauthnCredentialsByUserID(ctx, user.ID) totpAuthenticator, _ := p.StorageProvider.GetAuthenticatorDetailsByUserId(ctx, user.ID, constants.EnvKeyTOTPAuthenticator) totpVerified := totpAuthenticator != nil && totpAuthenticator.VerifiedAt != nil - authenticatorVerified := totpVerified || len(webauthnCreds) > 0 + 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 { @@ -51,11 +59,23 @@ func (p *provider) EvaluateMFAGateForOAuth(ctx context.Context, meta RequestMeta 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: 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") From 1472bc69cd51192861519db54303c6b1ea6a1409 Mon Sep 17 00:00:00 2001 From: Lakhan Samani Date: Wed, 15 Jul 2026 08:05:41 +0530 Subject: [PATCH 15/28] fix(mfa): defer OIDC bridge SetState until after the MFA gate (M2) SetState wrote the code/authToken bridge entry before EvaluateMFAGateForOAuth ran. Not exploitable as-is (code is never disclosed to the browser on a withheld redirect, so the entry just self-expires unreachable), but there's no reason to write it before we know the login actually proceeds. Move the write to after withheld=false; derivation of code/codeChallenge/nonce/ authorizeRedirectURI is unchanged, and code/nonce are still needed earlier to build params for the non-withheld response. --- internal/http_handlers/oauth_callback.go | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/internal/http_handlers/oauth_callback.go b/internal/http_handlers/oauth_callback.go index a89cda967..430aa6cab 100644 --- a/internal/http_handlers/oauth_callback.go +++ b/internal/http_handlers/oauth_callback.go @@ -334,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 @@ -380,6 +371,21 @@ func (h *httpProvider) OAuthCallbackHandler() gin.HandlerFunc { 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) From 8ec584c55d1f4c6f4dfe3963972e608fbfba1c47 Mon Sep 17 00:00:00 2001 From: Lakhan Samani Date: Wed, 15 Jul 2026 08:05:46 +0530 Subject: [PATCH 16/28] test(mfa): complete SMS-OTP full-chain MFA setup test (M4a) TestSMSOTPMFASetupViaMfaSessionCookie stopped after the setup leg (asserted the unverified Authenticator row was created, never verified, never checked a token was issued). Extend it to close the same withheld-login -> cookie-authenticated-setup -> verify_otp -> token-issued chain the email-OTP twin already proves. --- .../integration_tests/otp_mfa_setup_test.go | 30 +++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/internal/integration_tests/otp_mfa_setup_test.go b/internal/integration_tests/otp_mfa_setup_test.go index 4b4ba54f2..331c041d5 100644 --- a/internal/integration_tests/otp_mfa_setup_test.go +++ b/internal/integration_tests/otp_mfa_setup_test.go @@ -200,7 +200,9 @@ func TestEmailOTPMFASetupViaMfaSessionCookie(t *testing.T) { // 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. +// 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" @@ -249,7 +251,31 @@ func TestSMSOTPMFASetupViaMfaSessionCookie(t *testing.T) { authenticator, err := ts.StorageProvider.GetAuthenticatorDetailsByUserId(ctx, user.ID, constants.EnvKeySMSOTPAuthenticator) require.NoError(t, err) require.NotNil(t, authenticator) - assert.Nil(t, authenticator.VerifiedAt) + 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 From a26fa8292c8791b21aff93a523bfdacdea4420bf Mon Sep 17 00:00:00 2001 From: Lakhan Samani Date: Wed, 15 Jul 2026 08:05:50 +0530 Subject: [PATCH 17/28] refactor(mfa): consolidate 3 near-duplicate OTP-generation closures (M4b) login.go's local generateOTP closure, resend_otp.go's local generateOTP closure, and otp_mfa_setup.go's generateAndStoreOTP method all did the same thing: generate an OTP, HMAC it, UpsertOTP, restore plaintext on the returned struct. Verified this before consolidating. All three call sites (login's email/phone-verification and TOTP-alternative branches, resend, and OTP MFA setup) now call the one generateAndStoreOTP method. No behavior change -- the closures' internal error logging was redundant, every call site already logs on error. --- internal/service/login.go | 35 ++++--------------------------- internal/service/otp_mfa_setup.go | 13 ++++++------ internal/service/resend_otp.go | 25 +--------------------- 3 files changed, 12 insertions(+), 61 deletions(-) diff --git a/internal/service/login.go b/internal/service/login.go index 9e2e1ad65..815b86a68 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" @@ -153,32 +152,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 +184,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 +225,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 @@ -335,7 +308,7 @@ func (p *provider) Login(ctx context.Context, meta RequestMetadata, params *mode emailOTPEnrolled := emailOTPAuthenticator != nil && emailOTPAuthenticator.VerifiedAt != nil if effectiveMFAEnabled(p.Config, user) && isMFAEnabled && isMailOTPEnabled && isEmailServiceEnabled && isEmailLogin && 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 @@ -366,7 +339,7 @@ func (p *provider) Login(ctx context.Context, meta RequestMetadata, params *mode smsOTPEnrolled := smsOTPAuthenticator != nil && smsOTPAuthenticator.VerifiedAt != nil if effectiveMFAEnabled(p.Config, user) && isMFAEnabled && isSMSOTPEnabled && isSMSServiceEnabled && isMobileLogin && 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 diff --git a/internal/service/otp_mfa_setup.go b/internal/service/otp_mfa_setup.go index be402e946..fe570e372 100644 --- a/internal/service/otp_mfa_setup.go +++ b/internal/service/otp_mfa_setup.go @@ -184,12 +184,13 @@ func (p *provider) SMSOTPMFASetup(ctx context.Context, meta RequestMetadata, par return &model.Response{Message: "Check your phone for the verification code"}, nil, nil } -// generateAndStoreOTP mirrors the local `generateOTP` closures in login.go / -// resend_otp.go: 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. Not shared with those -// closures directly since they capture per-call locals (log, ctx); this is -// the package-level equivalent for the setup mutations. +// 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 { diff --git a/internal/service/resend_otp.go b/internal/service/resend_otp.go index 6bfd64008..ababe635c 100644 --- a/internal/service/resend_otp.go +++ b/internal/service/resend_otp.go @@ -11,7 +11,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/refs" "github.com/authorizerdev/authorizer/internal/storage/schemas" @@ -105,28 +104,6 @@ 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) @@ -140,7 +117,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 From 03b05c81b7c032ec1d2b3164ec05044f738e4608 Mon Sep 17 00:00:00 2001 From: Lakhan Samani Date: Wed, 15 Jul 2026 10:21:13 +0530 Subject: [PATCH 18/28] fix(meta): is_webauthn_enabled honors --disable-webauthn-mfa Was hardcoded true from before --disable-webauthn-mfa existed, so an operator disabling WebAuthn as an MFA factor still saw it advertised as available via the meta query. --- internal/integration_tests/meta_test.go | 24 ++++++++++++++++++++---- internal/service/meta.go | 6 ++++-- 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/internal/integration_tests/meta_test.go b/internal/integration_tests/meta_test.go index 8fd498091..ea3234f04 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/service/meta.go b/internal/service/meta.go index 4f73b1430..7213f49ad 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 } From 974cc85c3ec44bd5af026eaaf2a3de8cff6f7883 Mon Sep 17 00:00:00 2001 From: Lakhan Samani Date: Wed, 15 Jul 2026 11:11:30 +0530 Subject: [PATCH 19/28] feat(grpc): add REST/gRPC parity for MFA behavior redesign (PR #686) GraphQL-only MFA redesign (skip_mfa_setup, lock_mfa, email/sms_otp_mfa_setup, plus AuthResponse/Meta/User MFA fields, UpdateUserRequest.reset_mfa) had no REST/gRPC surface. Mirrors VerifyOtp's pattern exactly: public=true RPCs, service layer resolves the caller itself (MFA session cookie and/or bearer token) rather than the auth interceptor. - types.proto: AuthResponse +4 should_offer_* fields, Meta +is_mfa_enforced, User +has_skipped_mfa_setup_at/+mfa_locked_at - admin.proto: UpdateUserRequest +reset_mfa - authorizer.proto: SkipMfaSetup, LockMfa, EmailOtpMfaSetup, SmsOtpMfaSetup RPCs + request/response messages - gen/: regenerated (buf.build BSR token in this env is expired; generated locally with version-pinned protoc-gen-go/-go-grpc/-grpc-gateway/-openapiv2 matching the committed buf.gen.yaml's remote plugin versions, diff verified scoped to only the touched proto files) - handlers: 4 new gRPC handlers, Meta/UpdateUser/projectUser/ projectAuthResponse updated to carry the new fields through - tests: end-to-end gRPC coverage for SkipMfaSetup, LockMfa, and UpdateUser.reset_mfa --- gen/go/authorizer/v1/admin.pb.go | 43 +- gen/go/authorizer/v1/authorizer.pb.go | 1358 +++++++++++------ gen/go/authorizer/v1/authorizer.pb.gw.go | 308 ++++ gen/go/authorizer/v1/authorizer_grpc.pb.go | 201 ++- gen/go/authorizer/v1/types.pb.go | 425 ++++-- gen/openapi/authorizer.swagger.json | 241 +++ internal/grpcsrv/handlers/admin.go | 1 + internal/grpcsrv/handlers/authorizer.go | 63 + internal/grpcsrv/handlers/project.go | 30 +- .../admin_users_grpc_test.go | 24 + .../integration_tests/grpc_mfa_gate_test.go | 176 +++ proto/authorizer/v1/admin.proto | 5 + proto/authorizer/v1/authorizer.proto | 99 +- proto/authorizer/v1/types.proto | 14 + 14 files changed, 2337 insertions(+), 651 deletions(-) create mode 100644 internal/integration_tests/grpc_mfa_gate_test.go diff --git a/gen/go/authorizer/v1/admin.pb.go b/gen/go/authorizer/v1/admin.pb.go index 52e446406..858888e62 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 1f7e5881b..cb6d6d576 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 174389271..c59817ed7 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 6ae658f33..079fab00b 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 ded81f1b6..36d4d32a7 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 dbeca2098..7c20feeca 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/internal/grpcsrv/handlers/admin.go b/internal/grpcsrv/handlers/admin.go index b6c3de08e..ccc6e1d8e 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 3f9977bd9..dec82c9f0 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 42c406e2d..b314ee1d5 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/integration_tests/admin_users_grpc_test.go b/internal/integration_tests/admin_users_grpc_test.go index 51c952185..4a16d7297 100644 --- a/internal/integration_tests/admin_users_grpc_test.go +++ b/internal/integration_tests/admin_users_grpc_test.go @@ -6,6 +6,7 @@ import ( "testing" "github.com/google/uuid" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "google.golang.org/grpc" "google.golang.org/grpc/codes" @@ -165,6 +166,29 @@ func TestAdminUpdateUserGRPC(t *testing.T) { _, err := client.UpdateUser(adminCtx(cfg.AdminSecret), &authorizerv1.UpdateUserRequest{Id: id}) require.Error(t, err) }) + + t.Run("reset_mfa clears locked/enabled/skipped MFA state", func(t *testing.T) { + now := int64(1) + user, err := ts.StorageProvider.AddUser(context.Background(), &schemas.User{ + Email: refs.NewStringRef("admin-users-grpc-reset-mfa-" + uuid.New().String() + "@authorizer.test"), + SignupMethods: constants.AuthRecipeMethodBasicAuth, + EmailVerifiedAt: &now, + IsMultiFactorAuthEnabled: refs.NewBoolRef(true), + MFALockedAt: &now, + HasSkippedMFASetupAt: &now, + }) + require.NoError(t, err) + + resp, err := client.UpdateUser(adminCtx(cfg.AdminSecret), &authorizerv1.UpdateUserRequest{ + Id: user.ID, + ResetMfa: refs.NewBoolRef(true), + }) + require.NoError(t, err) + require.NotNil(t, resp.User) + assert.False(t, resp.User.IsMultiFactorAuthEnabled) + assert.Zero(t, resp.User.MfaLockedAt) + assert.Zero(t, resp.User.HasSkippedMfaSetupAt) + }) } // TestAdminDeleteUserGRPC exercises AuthorizerAdminService.DeleteUser over gRPC: diff --git a/internal/integration_tests/grpc_mfa_gate_test.go b/internal/integration_tests/grpc_mfa_gate_test.go new file mode 100644 index 000000000..6b8cc096d --- /dev/null +++ b/internal/integration_tests/grpc_mfa_gate_test.go @@ -0,0 +1,176 @@ +package integration_tests + +import ( + "context" + "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, 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()) + }) +} diff --git a/proto/authorizer/v1/admin.proto b/proto/authorizer/v1/admin.proto index 638121c71..cc829ce77 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 8ad913b70..bfafe41f3 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 2af9a995f..f4f7fc0df 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; } From 8b32b103a90147183713e92d9e86d645c6f3c184 Mon Sep 17 00:00:00 2001 From: Lakhan Samani Date: Wed, 15 Jul 2026 11:22:27 +0530 Subject: [PATCH 20/28] test(grpc): cover EmailOtpMfaSetup/SmsOtpMfaSetup RPCs end-to-end 974cc85c added these gRPC handlers with only go build/go vet as coverage. Port the two auth-mode scenarios already proven at the GraphQL layer (otp_mfa_setup_test.go) onto the gRPC transport: MFA session cookie + email/phone_number fallback, and ordinary bearer token, plus the unauthenticated-caller rejection case. --- .../integration_tests/grpc_mfa_gate_test.go | 188 ++++++++++++++++++ 1 file changed, 188 insertions(+) diff --git a/internal/integration_tests/grpc_mfa_gate_test.go b/internal/integration_tests/grpc_mfa_gate_test.go index 6b8cc096d..4881e0919 100644 --- a/internal/integration_tests/grpc_mfa_gate_test.go +++ b/internal/integration_tests/grpc_mfa_gate_test.go @@ -2,6 +2,7 @@ package integration_tests import ( "context" + "fmt" "net" "testing" "time" @@ -174,3 +175,190 @@ func TestLockMfaGRPC(t *testing.T) { 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()) + }) +} From b7028900c1a91d06d981a7a4a478f9e040dcec25 Mon Sep 17 00:00:00 2001 From: Lakhan Samani Date: Wed, 15 Jul 2026 22:45:28 +0530 Subject: [PATCH 21/28] chore(dev): allow localhost:5174 origin for local dev --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 310798fb6..928d9eae6 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) From 2e6c91795fd6092531e896351261fd766b64b2a4 Mon Sep 17 00:00:00 2001 From: Lakhan Samani Date: Wed, 15 Jul 2026 22:45:32 +0530 Subject: [PATCH 22/28] chore(build): bump go toolchain to 1.26.5 govulncheck flagged GO-2026-5856 (crypto/tls ECH privacy leak), fixed upstream in go1.26.5. No code changes needed. --- go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go.mod b/go.mod index 7bcb2317c..e4563a756 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 From 0752e7c0d6df95bbe3bc2e06f015a8fbdb2106ab Mon Sep 17 00:00:00 2001 From: Lakhan Samani Date: Wed, 15 Jul 2026 22:45:36 +0530 Subject: [PATCH 23/28] chore(docker): apk upgrade in final stage for patched openssl trivy flagged 15 CVEs (2 CRITICAL, 13 HIGH) in libssl3/libcrypto3 3.5.5-r0 baked into the alpine:3.23.3 base image. The stable repo already carries 3.5.7-r0; a plain apk upgrade picks it up without touching the edge busybox pin. Re-scan: 0 vulnerabilities. --- Dockerfile | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 2e95ce449..1f1097db8 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 From 536c1e0ecd7dba098dbbfae9885cce6d8a7c12b8 Mon Sep 17 00:00:00 2001 From: Lakhan Samani Date: Wed, 15 Jul 2026 22:45:52 +0530 Subject: [PATCH 24/28] fix(mfa): close pre-auth account-takeover via unauthenticated MFA session mint ResendOTP and the mobile branch of ForgotPassword are unauthenticated (only need a victim's email/phone) yet minted the same MFA session cookie that login/signup/webauthn/oauth mint after actually verifying a first factor. SkipMFASetup and LockMFA trusted any valid session for a user ID as proof of that first factor, with no OTP code check: resend_otp{email:victim} -> session cookie for victim.ID skip_mfa_setup{email:victim} + cookie -> victim's access/refresh/id tokens The same chain into lock_mfa gave an unauthenticated account-lockout DoS. Fixes: - tag MFA sessions with a purpose (verified vs challenge); ResendOTP and ForgotPassword's mobile branch mint challenge, everything else mints verified - SkipMFASetup/LockMFA reject challenge sessions - SkipMFASetup recomputes the MFA gate and only proceeds on a genuine mfaGateOfferAll, so a user with an already-verified second factor can't skip past it - EnforceMFA is now absolute: a user's persisted opt-out no longer exempts them once the org enforces MFA, and admin UpdateUser can no longer persist that opt-out while enforcement is on (update_profile already guarded this; admin_users.go did not) - MFA sessions are single-use (deleted on consumption) New regression tests exercise the attack chain directly: a ResendOTP/ForgotPassword-minted session is rejected by SkipMFASetup and LockMFA, a verified-factor user can't skip, and admin UpdateUser can't disable MFA under enforcement. --- internal/constants/mfa_session.go | 16 ++++ .../oauth_authorize_state_test.go | 22 ++--- .../admin_update_user_enforce_mfa_test.go | 53 ++++++++++++ .../integration_tests/grpc_mfa_gate_test.go | 2 +- internal/integration_tests/lock_mfa_test.go | 38 ++++++++- .../integration_tests/otp_mfa_setup_test.go | 2 +- .../integration_tests/skip_mfa_setup_test.go | 81 ++++++++++++++++++- internal/integration_tests/verify_otp_test.go | 1 + .../verify_otp_totp_lockout_test.go | 1 + .../integration_tests/verify_otp_totp_test.go | 1 + .../webauthn_enforce_mfa_test.go | 16 ++-- internal/integration_tests/webauthn_test.go | 2 + internal/memory_store/db/provider.go | 66 ++++++++++----- internal/memory_store/db/provider_test.go | 4 +- internal/memory_store/in_memory/store.go | 6 +- internal/memory_store/provider.go | 8 +- internal/memory_store/provider_test.go | 4 +- internal/memory_store/redis/store.go | 6 +- internal/service/admin_users.go | 7 ++ internal/service/forgot_password.go | 4 +- internal/service/lock_mfa.go | 9 ++- internal/service/login.go | 5 +- internal/service/mfa_gate.go | 29 ++++++- internal/service/mfa_gate_test.go | 2 +- internal/service/resend_otp.go | 5 +- internal/service/signup.go | 4 +- internal/service/skip_mfa_setup.go | 27 +++++-- internal/service/verify_email.go | 4 +- internal/service/verify_otp.go | 4 + 29 files changed, 362 insertions(+), 67 deletions(-) create mode 100644 internal/constants/mfa_session.go create mode 100644 internal/integration_tests/admin_update_user_enforce_mfa_test.go diff --git a/internal/constants/mfa_session.go b/internal/constants/mfa_session.go new file mode 100644 index 000000000..fc135aad0 --- /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/http_handlers/oauth_authorize_state_test.go b/internal/http_handlers/oauth_authorize_state_test.go index ed5adcf29..22fd5786d 100644 --- a/internal/http_handlers/oauth_authorize_state_test.go +++ b/internal/http_handlers/oauth_authorize_state_test.go @@ -213,16 +213,18 @@ 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) 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/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 000000000..56b0509d5 --- /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/grpc_mfa_gate_test.go b/internal/integration_tests/grpc_mfa_gate_test.go index 4881e0919..863527b23 100644 --- a/internal/integration_tests/grpc_mfa_gate_test.go +++ b/internal/integration_tests/grpc_mfa_gate_test.go @@ -155,7 +155,7 @@ func TestLockMfaGRPC(t *testing.T) { require.NoError(t, err) 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())) t.Run("locks the account with a valid mfa session", func(t *testing.T) { resp, err := c.LockMfa(mfaSessionCookieCtx(mfaSession), &authorizerv1.LockMfaRequest{Email: email}) diff --git a/internal/integration_tests/lock_mfa_test.go b/internal/integration_tests/lock_mfa_test.go index 5af7ffaf9..efaa8c89a 100644 --- a/internal/integration_tests/lock_mfa_test.go +++ b/internal/integration_tests/lock_mfa_test.go @@ -47,7 +47,7 @@ func TestLockMFA(t *testing.T) { // 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, 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)) lockRes, err := ts.GraphQLProvider.LockMFA(ctx, &model.LockMfaRequest{Email: &email}) @@ -97,4 +97,40 @@ func TestLockMFA(t *testing.T) { 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/otp_mfa_setup_test.go b/internal/integration_tests/otp_mfa_setup_test.go index 331c041d5..5fac9fd70 100644 --- a/internal/integration_tests/otp_mfa_setup_test.go +++ b/internal/integration_tests/otp_mfa_setup_test.go @@ -100,7 +100,7 @@ func TestEmailOTPMFAEnrollment(t *testing.T) { // token -- arm a fresh session directly (same approach as // TestVerifyOTPNoRecord) rather than threading the earlier one through. verifySession := uuid.NewString() - require.NoError(t, ts.MemoryStoreProvider.SetMfaSession(user.ID, verifySession, time.Now().Add(5*time.Minute).Unix())) + require.NoError(t, ts.MemoryStoreProvider.SetMfaSession(user.ID, verifySession, constants.MFASessionPurposeVerified, time.Now().Add(5*time.Minute).Unix())) req.Header.Set("Cookie", fmt.Sprintf("%s=%s", constants.MfaCookieName+"_session", verifySession)) verifyRes, err := ts.GraphQLProvider.VerifyOTP(ctx, &model.VerifyOTPRequest{Email: &email, Otp: knownPlainOTP}) require.NoError(t, err) diff --git a/internal/integration_tests/skip_mfa_setup_test.go b/internal/integration_tests/skip_mfa_setup_test.go index fe0e2f870..f28990a54 100644 --- a/internal/integration_tests/skip_mfa_setup_test.go +++ b/internal/integration_tests/skip_mfa_setup_test.go @@ -97,7 +97,7 @@ func TestSkipMFASetup(t *testing.T) { require.NoError(t, err) 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)) skipRes, err := ts.GraphQLProvider.SkipMFASetup(ctx, &model.SkipMfaSetupRequest{Email: &email}) @@ -161,4 +161,83 @@ func TestSkipMFASetup(t *testing.T) { require.True(t, errors.As(err, &svcErr)) assert.Equal(t, service.KindInvalidArgument, 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/verify_otp_test.go b/internal/integration_tests/verify_otp_test.go index d99178bd1..9022bc76b 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 bbdd182ad..b3dd420bf 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 1ed75c4be..20f2ade84 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 088b4a08f..4691bacfe 100644 --- a/internal/integration_tests/webauthn_enforce_mfa_test.go +++ b/internal/integration_tests/webauthn_enforce_mfa_test.go @@ -90,23 +90,25 @@ func TestWebauthnLoginVerifyEnforceMFA(t *testing.T) { 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 b95132f2f..54b66d85a 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 ca683603d..f82998fcb 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,30 @@ 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 } // SetState sets the login state (key, value form) in the session store @@ -364,10 +394,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 a5a431405..ba0686491 100644 --- a/internal/memory_store/db/provider_test.go +++ b/internal/memory_store/db/provider_test.go @@ -89,12 +89,12 @@ 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) 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 ab03cec31..0278c907b 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 } diff --git a/internal/memory_store/provider.go b/internal/memory_store/provider.go index 4b7c16d6d..8b0f3fab7 100644 --- a/internal/memory_store/provider.go +++ b/internal/memory_store/provider.go @@ -48,9 +48,11 @@ 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) diff --git a/internal/memory_store/provider_test.go b/internal/memory_store/provider_test.go index 1cb070108..b386583e8 100644 --- a/internal/memory_store/provider_test.go +++ b/internal/memory_store/provider_test.go @@ -171,11 +171,11 @@ 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) err = p.DeleteMfaSession("auth_provider:123", "session123") assert.NoError(t, err) key, err = p.GetMfaSession("auth_provider:123", "session123") diff --git a/internal/memory_store/redis/store.go b/internal/memory_store/redis/store.go index 67760b59f..5e5ba0a04 100644 --- a/internal/memory_store/redis/store.go +++ b/internal/memory_store/redis/store.go @@ -110,12 +110,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 diff --git a/internal/service/admin_users.go b/internal/service/admin_users.go index 69f6c338c..910e7c3fd 100644 --- a/internal/service/admin_users.go +++ b/internal/service/admin_users.go @@ -182,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 } diff --git a/internal/service/forgot_password.go b/internal/service/forgot_password.go index 55065df1b..cb026c77a 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 index 083e06797..d1f18953a 100644 --- a/internal/service/lock_mfa.go +++ b/internal/service/lock_mfa.go @@ -48,7 +48,12 @@ func (p *provider) LockMFA(ctx context.Context, meta RequestMetadata, params *mo return nil, nil, NotFound("invalid request") } - if _, err := p.MemoryStoreProvider.GetMfaSession(user.ID, mfaSession); err != nil { + // 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`) } @@ -64,6 +69,8 @@ func (p *provider) LockMFA(ctx context.Context, meta RequestMetadata, params *mo 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, diff --git a/internal/service/login.go b/internal/service/login.go index 815b86a68..eace6f494 100644 --- a/internal/service/login.go +++ b/internal/service/login.go @@ -37,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) { diff --git a/internal/service/mfa_gate.go b/internal/service/mfa_gate.go index 2f6c87236..414e5d148 100644 --- a/internal/service/mfa_gate.go +++ b/internal/service/mfa_gate.go @@ -2,7 +2,10 @@ 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" ) @@ -48,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 { @@ -77,3 +83,24 @@ func effectiveMFAEnabled(cfg *config.Config, user *schemas.User) bool { } 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 24f401232..095f0a8af 100644 --- a/internal/service/mfa_gate_test.go +++ b/internal/service/mfa_gate_test.go @@ -18,7 +18,7 @@ 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}, diff --git a/internal/service/resend_otp.go b/internal/service/resend_otp.go index ababe635c..4119e2440 100644 --- a/internal/service/resend_otp.go +++ b/internal/service/resend_otp.go @@ -106,7 +106,10 @@ func (p *provider) ResendOTP(ctx context.Context, meta RequestMetadata, params * } setOTPMFaSession := func(expiresAt int64) error { mfaSession := uuid.NewString() - err = p.MemoryStoreProvider.SetMfaSession(user.ID, mfaSession, expiresAt) + // The caller only proved they can trigger an OTP send to this + // email/phone — no first factor. Challenge, NOT Verified, so this + // session can never skip MFA setup or lock the account. + err = p.MemoryStoreProvider.SetMfaSession(user.ID, mfaSession, constants.MFASessionPurposeChallenge, expiresAt) if err != nil { log.Debug().Msg("Failed to set mfa session") return err diff --git a/internal/service/signup.go b/internal/service/signup.go index f97887139..130119009 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 diff --git a/internal/service/skip_mfa_setup.go b/internal/service/skip_mfa_setup.go index 50cfddec2..66170d45f 100644 --- a/internal/service/skip_mfa_setup.go +++ b/internal/service/skip_mfa_setup.go @@ -51,15 +51,30 @@ func (p *provider) SkipMFASetup(ctx context.Context, meta RequestMetadata, param // 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. - if _, err := p.MemoryStoreProvider.GetMfaSession(user.ID, mfaSession); err != nil { + // 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`) } - 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") + // 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() @@ -69,6 +84,8 @@ func (p *provider) SkipMFASetup(ctx context.Context, meta RequestMetadata, param 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) // Known simplification: issueAuthResponse always stamps loginMethod into // the audit/webhook trail. The caller may have actually arrived via diff --git a/internal/service/verify_email.go b/internal/service/verify_email.go index 1c1f1914e..abcf54417 100644 --- a/internal/service/verify_email.go +++ b/internal/service/verify_email.go @@ -69,7 +69,9 @@ func (p *provider) VerifyEmail(ctx context.Context, meta RequestMetadata, params setOTPMFaSession := func(expiresAt int64) error { mfaSession := uuid.NewString() - err = p.MemoryStoreProvider.SetMfaSession(user.ID, mfaSession, expiresAt) + // Reached only via a signed verification token mailed to the user's + // inbox — a possession proof of this exact account, so Verified. + err = p.MemoryStoreProvider.SetMfaSession(user.ID, mfaSession, constants.MFASessionPurposeVerified, expiresAt) if err != nil { log.Debug().Err(err).Msg("Failed to set mfa session") return err diff --git a/internal/service/verify_otp.go b/internal/service/verify_otp.go index 3b4dbeccb..455f16a17 100644 --- a/internal/service/verify_otp.go +++ b/internal/service/verify_otp.go @@ -249,6 +249,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 From 4b5980e36c69bee80c1f763d535f61cb218ea224 Mon Sep 17 00:00:00 2001 From: Lakhan Samani Date: Thu, 16 Jul 2026 05:27:19 +0530 Subject: [PATCH 25/28] fix(e2e): disable MFA in the release smoke server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TestReleaseSmoke asserts FGA permission checks, not MFA — but MFA is on by default (TOTP/WebAuthn need no external provider configured), so signup's token was withheld behind the MFA-setup gate instead of returned directly, panicking on the nil access_token type assertion. Failing since a26fa829, unrelated to the session/CVE work in this PR. --disable-mfa keeps WebAuthn/passkey as a primary login method while turning off its MFA-factor role, matching what this scenario needs. --- internal/e2e/smoke_test.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/internal/e2e/smoke_test.go b/internal/e2e/smoke_test.go index 948f8b4ef..3daa55460 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) From a351d83a877f7273a3f614c5e4595c3e04e6b258 Mon Sep 17 00:00:00 2001 From: Lakhan Samani Date: Thu, 16 Jul 2026 05:50:41 +0530 Subject: [PATCH 26/28] chore(deps): bump golang.org/x/net and x/text for CVE fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit x/net v0.55.0->v0.56.0 fixes CVE-2026-46600 (panic parsing an invalid SVCB/HTTPS RR in dns/dnsmessage). x/text v0.37.0->v0.39.0 fixes CVE-2026-56852 (infinite loop on invalid input). go mod tidy pulled in matching transitive bumps (x/crypto, x/mod, x/sys, x/tools). golang.org/x/crypto flags GO-2026-5932 (openpgp unmaintained, no fix) but we don't import x/crypto/openpgp anywhere and govulncheck already confirms it's unreachable from our code — left as-is. --- go.mod | 12 ++++++------ go.sum | 24 ++++++++++++------------ 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/go.mod b/go.mod index e4563a756..58133fa89 100644 --- a/go.mod +++ b/go.mod @@ -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 c9a0635da..cd4f41ebc 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= From bce9a560219d6b2f9c27d297255805caf13b2ecc Mon Sep 17 00:00:00 2001 From: Lakhan Samani Date: Thu, 16 Jul 2026 06:35:04 +0530 Subject: [PATCH 27/28] fix(mfa): close two gate gaps found in UI-flow spec verification oauth_mfa_gate.go offered TOTP as an MFA option even when --disable-totp-mfa was set -- every other method checked its own config flag, TOTP didn't. Gate it the same way. login.go's inline email/SMS-OTP MFA challenge required the login identifier to match the enrolled method: a user who signs up with email, later verifies a phone number, and enrolls SMS-OTP as their only factor was never challenged for it on an email+password login -- it fell through to "offer fresh setup" instead of being blocked to verify the factor they already opted into. Not a corner case: any basic-auth signup that later verifies a phone and picks SMS-OTP hits this. The challenge now fires on enrollment alone, sending the code to the account's own stored contact (user.Email/user.PhoneNumber) rather than the login params, which are empty for the non-matching identifier. Also closes coverage gaps for already-correct but unverified behavior: lock_mfa's refusal to lock when a verified email/SMS-OTP fallback exists had zero test coverage, and updates its doc comment (referenced an unlanded "Task 6" that has since shipped). --- internal/integration_tests/lock_mfa_test.go | 42 +++++++ .../login_mfa_cross_identifier_test.go | 107 ++++++++++++++++++ .../integration_tests/oauth_mfa_gate_test.go | 30 +++++ internal/service/lock_mfa.go | 10 +- internal/service/login.go | 25 ++-- internal/service/oauth_mfa_gate.go | 4 +- 6 files changed, 203 insertions(+), 15 deletions(-) create mode 100644 internal/integration_tests/login_mfa_cross_identifier_test.go diff --git a/internal/integration_tests/lock_mfa_test.go b/internal/integration_tests/lock_mfa_test.go index efaa8c89a..68edd7446 100644 --- a/internal/integration_tests/lock_mfa_test.go +++ b/internal/integration_tests/lock_mfa_test.go @@ -70,6 +70,48 @@ func TestLockMFA(t *testing.T) { 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() 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 000000000..142ab296b --- /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/oauth_mfa_gate_test.go b/internal/integration_tests/oauth_mfa_gate_test.go index 6ed3cc8dc..ba4ec2175 100644 --- a/internal/integration_tests/oauth_mfa_gate_test.go +++ b/internal/integration_tests/oauth_mfa_gate_test.go @@ -103,6 +103,36 @@ func TestEvaluateMFAGateForOAuth(t *testing.T) { 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 diff --git a/internal/service/lock_mfa.go b/internal/service/lock_mfa.go index d1f18953a..61a11c723 100644 --- a/internal/service/lock_mfa.go +++ b/internal/service/lock_mfa.go @@ -87,12 +87,10 @@ func (p *provider) LockMFA(ctx context.Context, meta RequestMetadata, params *mo } // hasVerifiedOTPFallback reports whether userID has a verified Email-OTP or -// SMS-OTP MFA enrollment — the one case where locking is refused because a -// working recovery path already exists. Depends on Task 6's Email/SMS-OTP -// Authenticator rows (constants.EnvKeyEmailOTPAuthenticator / -// constants.EnvKeySMSOTPAuthenticator) — until Task 6 lands, this always -// returns false (no such rows can exist yet), which is safe: it just means -// lock_mfa is never refused, matching this task's standalone test scope. +// 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) diff --git a/internal/service/login.go b/internal/service/login.go index eace6f494..1a0f8e8b7 100644 --- a/internal/service/login.go +++ b/internal/service/login.go @@ -306,10 +306,16 @@ func (p *provider) Login(ctx context.Context, meta RequestMetadata, params *mode return nil, nil, FailedPrecondition("your account's multi-factor authentication is locked; contact your administrator to regain access") } - // If multi factor authentication is enabled and is email based login and email otp is enabled + // 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 && isEmailLogin && emailOTPEnrolled { + if effectiveMFAEnabled(p.Config, user) && isMFAEnabled && isMailOTPEnabled && isEmailServiceEnabled && emailOTPEnrolled { expiresAt := time.Now().Add(1 * time.Minute).Unix() otpData, err := p.generateAndStoreOTP(ctx, user, expiresAt) if err != nil { @@ -323,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, @@ -334,13 +340,16 @@ 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 + // 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 && isMobileLogin && smsOTPEnrolled { + if effectiveMFAEnabled(p.Config, user) && isMFAEnabled && isSMSOTPEnabled && isSMSServiceEnabled && smsOTPEnrolled { expiresAt := time.Now().Add(1 * time.Minute).Unix() otpData, err := p.generateAndStoreOTP(ctx, user, expiresAt) if err != nil { @@ -357,13 +366,13 @@ 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 } // Gate runs whenever MFA applies at all -- NOT scoped to "TOTP diff --git a/internal/service/oauth_mfa_gate.go b/internal/service/oauth_mfa_gate.go index a44489c89..0c9eb6e76 100644 --- a/internal/service/oauth_mfa_gate.go +++ b/internal/service/oauth_mfa_gate.go @@ -66,7 +66,9 @@ func (p *provider) EvaluateMFAGateForOAuth(ctx context.Context, meta RequestMeta methods = append(methods, constants.EnvKeySMSOTPAuthenticator) } case mfaGateBlockEnroll, mfaGateOfferAll: - methods = append(methods, constants.EnvKeyTOTPAuthenticator) + if p.Config.EnableTOTPLogin { + methods = append(methods, constants.EnvKeyTOTPAuthenticator) + } if p.Config.EnableWebauthnMFA { methods = append(methods, constants.AuthRecipeMethodWebauthn) } From 9a59bce51215d253d68d9c929eb6fdb312dbfe15 Mon Sep 17 00:00:00 2001 From: Lakhan Samani Date: Thu, 16 Jul 2026 10:27:14 +0530 Subject: [PATCH 28/28] feat(mfa): resolve OAuth-return MFA continuation from session alone The OAuth callback redirect only carries mfa_required=1&mfa_methods=... on a withheld gate outcome -- no email/phone, since embedding an identifier in a redirect URL risks referrer leakage to third-party scripts, CDN/proxy access logs, and browser history. But VerifyOTP/SkipMFASetup/LockMFA/resolveOTPSetupCaller (shared by EmailOTPMFASetup/SMSOTPMFASetup) all required an email/phone param to resolve the account before checking the MFA session -- a dead end for an OAuth-return caller, who never has one. Add MemoryStoreProvider.GetMfaSessionOwner(key), a reverse lookup (session key -> userID + purpose) across all three backends, and wire it into each of the four resolvers as a fallback used only when the caller supplies no identifier: resolve the owning user directly from the session, applying the same purpose checks (Verified-only for skip/lock) the identifier path already enforces. ResendOTP gets the same fallback, gated to a Verified session only -- a bare Challenge session still can't spawn further resends without an identifier, preserving the existing account-lockout-DoS guarantee. Every identifier-supplied path is byte-for-byte unchanged; this only adds a new path taken when email and phone are both empty. --- .../oauth_authorize_state_test.go | 7 +- .../mfa_session_only_test.go | 249 ++++++++++++++++++ .../integration_tests/skip_mfa_setup_test.go | 15 +- internal/memory_store/db/provider.go | 26 ++ internal/memory_store/db/provider_test.go | 20 ++ internal/memory_store/in_memory/store.go | 9 + .../in_memory/stores/session_store.go | 22 ++ internal/memory_store/provider.go | 6 + internal/memory_store/provider_test.go | 31 +++ internal/memory_store/redis/store.go | 24 ++ internal/service/lock_mfa.go | 53 ++-- internal/service/oauth_mfa_gate.go | 6 + internal/service/otp_mfa_setup.go | 12 +- internal/service/resend_otp.go | 51 +++- internal/service/skip_mfa_setup.go | 57 ++-- internal/service/verify_otp.go | 40 ++- 16 files changed, 557 insertions(+), 71 deletions(-) create mode 100644 internal/integration_tests/mfa_session_only_test.go diff --git a/internal/http_handlers/oauth_authorize_state_test.go b/internal/http_handlers/oauth_authorize_state_test.go index 22fd5786d..ad5e79408 100644 --- a/internal/http_handlers/oauth_authorize_state_test.go +++ b/internal/http_handlers/oauth_authorize_state_test.go @@ -223,8 +223,11 @@ func (f *fakeMemoryStore) SetMfaSession(userId, key, purpose string, expiration 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) 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/integration_tests/mfa_session_only_test.go b/internal/integration_tests/mfa_session_only_test.go new file mode 100644 index 000000000..719d968ac --- /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/skip_mfa_setup_test.go b/internal/integration_tests/skip_mfa_setup_test.go index f28990a54..08d69c38d 100644 --- a/internal/integration_tests/skip_mfa_setup_test.go +++ b/internal/integration_tests/skip_mfa_setup_test.go @@ -138,19 +138,18 @@ func TestSkipMFASetup(t *testing.T) { }) } - t.Run("rejects with InvalidArgument when neither email nor phone_number is given", func(t *testing.T) { + 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) - // SkipMFASetup reads the mfa session cookie before validating - // email/phone_number (mirrors VerifyOTP's ordering), so a cookie - // must be present here or the call short-circuits on Unauthenticated - // before ever reaching the check this subtest targets. The value - // itself need not resolve to a real session — no user lookup happens - // before the email/phone_number check runs. + // 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{}) @@ -159,7 +158,7 @@ func TestSkipMFASetup(t *testing.T) { var svcErr *service.Error require.True(t, errors.As(err, &svcErr)) - assert.Equal(t, service.KindInvalidArgument, svcErr.Kind) + assert.Equal(t, service.KindUnauthenticated, svcErr.Kind) }) t.Run("rejects a Challenge session (ResendOTP/ForgotPassword) with Unauthenticated", func(t *testing.T) { diff --git a/internal/memory_store/db/provider.go b/internal/memory_store/db/provider.go index f82998fcb..80f4db5db 100644 --- a/internal/memory_store/db/provider.go +++ b/internal/memory_store/db/provider.go @@ -250,6 +250,32 @@ 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. 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 func (p *provider) SetState(key, state string) error { ctx := context.Background() diff --git a/internal/memory_store/db/provider_test.go b/internal/memory_store/db/provider_test.go index ba0686491..7eaf0a703 100644 --- a/internal/memory_store/db/provider_test.go +++ b/internal/memory_store/db/provider_test.go @@ -96,6 +96,26 @@ func TestDBMemoryStoreProvider(t *testing.T) { assert.NoError(t, err) 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 0278c907b..b2ae5250c 100644 --- a/internal/memory_store/in_memory/store.go +++ b/internal/memory_store/in_memory/store.go @@ -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 00279ba68..4dbd04398 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 8b0f3fab7..4c1ff0b97 100644 --- a/internal/memory_store/provider.go +++ b/internal/memory_store/provider.go @@ -58,6 +58,12 @@ type Provider interface { 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 b386583e8..e747d74ff 100644 --- a/internal/memory_store/provider_test.go +++ b/internal/memory_store/provider_test.go @@ -176,11 +176,42 @@ func TestMemoryStoreProvider(t *testing.T) { key, err = p.GetMfaSession("auth_provider:123", "session123") assert.NoError(t, err) 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 5e5ba0a04..6704e085e 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" @@ -155,6 +156,29 @@ func (p *provider) DeleteMfaSession(userId, key string) error { return nil } +// GetMfaSessionOwner resolves the userID and purpose for a bare mfa session +// key, without knowing the owning userID. Session keys are UUIDs (no colons), +// so the "*:" glob matches exactly one user's entry. +func (p *provider) GetMfaSessionOwner(key string) (string, string, error) { + res := p.store.Keys(p.ctx, fmt.Sprintf("%s*:%s", mfaSessionPrefix, key)) + if res.Err() != nil { + p.dependencies.Log.Debug().Err(res.Err()).Msg("Error getting mfa session owner from redis") + return "", "", res.Err() + } + keys := res.Val() + if len(keys) != 1 { + p.dependencies.Log.Debug().Int("matches", len(keys)).Msg("Expected exactly one mfa session owner match") + return "", "", fmt.Errorf("not found") + } + matchedKey := keys[0] + userID := strings.TrimSuffix(strings.TrimPrefix(matchedKey, mfaSessionPrefix), ":"+key) + purpose, err := p.store.Get(p.ctx, matchedKey).Result() + if err != nil { + return "", "", err + } + return userID, purpose, nil +} + // SetState sets the state in redis store. func (p *provider) SetState(key, value string) error { // OAuth state should be short-lived; keeping it forever leaks keys in Redis. diff --git a/internal/service/lock_mfa.go b/internal/service/lock_mfa.go index 61a11c723..ff6fba5ed 100644 --- a/internal/service/lock_mfa.go +++ b/internal/service/lock_mfa.go @@ -32,30 +32,43 @@ func (p *provider) LockMFA(ctx context.Context, meta RequestMetadata, params *mo 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`) - } var user *schemas.User - if email != "" { - user, err = p.StorageProvider.GetUserByEmail(ctx, email) + 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 { - 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") - } + 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`) + // 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) { diff --git a/internal/service/oauth_mfa_gate.go b/internal/service/oauth_mfa_gate.go index 0c9eb6e76..04047e80a 100644 --- a/internal/service/oauth_mfa_gate.go +++ b/internal/service/oauth_mfa_gate.go @@ -82,5 +82,11 @@ func (p *provider) EvaluateMFAGateForOAuth(ctx context.Context, meta RequestMeta 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 index fe570e372..3ab8d17b0 100644 --- a/internal/service/otp_mfa_setup.go +++ b/internal/service/otp_mfa_setup.go @@ -50,7 +50,17 @@ func (p *provider) resolveOTPSetupCaller(ctx context.Context, meta RequestMetada phoneNumber = strings.TrimSpace(refs.StringValue(params.PhoneNumber)) } if email == "" && phoneNumber == "" { - return nil, Unauthenticated(`unauthorized`) + // 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 diff --git a/internal/service/resend_otp.go b/internal/service/resend_otp.go index 4119e2440..23b06a4b5 100644 --- a/internal/service/resend_otp.go +++ b/internal/service/resend_otp.go @@ -6,6 +6,7 @@ import ( "strings" "time" + "github.com/gin-gonic/gin" "github.com/google/uuid" "github.com/authorizerdev/authorizer/internal/audit" @@ -26,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 @@ -106,10 +139,12 @@ func (p *provider) ResendOTP(ctx context.Context, meta RequestMetadata, params * } setOTPMFaSession := func(expiresAt int64) error { mfaSession := uuid.NewString() - // The caller only proved they can trigger an OTP send to this - // email/phone — no first factor. Challenge, NOT Verified, so this - // session can never skip MFA setup or lock the account. - err = p.MemoryStoreProvider.SetMfaSession(user.ID, mfaSession, constants.MFASessionPurposeChallenge, 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 diff --git a/internal/service/skip_mfa_setup.go b/internal/service/skip_mfa_setup.go index 66170d45f..5afb3c33a 100644 --- a/internal/service/skip_mfa_setup.go +++ b/internal/service/skip_mfa_setup.go @@ -33,32 +33,45 @@ func (p *provider) SkipMFASetup(ctx context.Context, meta RequestMetadata, param 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`) - } var user *schemas.User - if email != "" { - user, err = p.StorageProvider.GetUserByEmail(ctx, email) + 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 { - 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") - } + 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`) + // 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`) + } } // Recompute the gate: only a genuine mfaGateOfferAll offer (MFA available, diff --git a/internal/service/verify_otp.go b/internal/service/verify_otp.go index 455f16a17..f84f72fa5 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") }