From af92fd6b60b55b2c14be0ec490e5e101b6589ab1 Mon Sep 17 00:00:00 2001 From: Lakhan Samani Date: Mon, 13 Jul 2026 08:59:21 +0530 Subject: [PATCH 01/20] fix(auth): surface config-load errors, fix condition/state bugs, and stop the auto-firing passkey prompt - AuthorizerRoot/AuthorizerContext: surface a configLoadError banner instead of silently rendering a blank login screen when the backend meta fetch fails. - AuthorizerRoot: gate AuthorizerSocialLogin behind the Login view like its siblings, instead of rendering on every view. - AuthorizerBasicAuthLogin/AuthorizerSignup: switch validation effects to functional state updates and null-guard pristine fields, so simultaneous effects can't clobber each other's error state on mount. - AuthorizerBasicAuthLogin: defer the WebAuthn conditional-mediation (passkey autofill) ceremony from mount to the email field's first focus, so it no longer fires a native passkey prompt on every page load. - example app: drop noisy console.log callbacks (onStateChangeCallback, onLogin/onMagicLinkLogin/onSignup) that spammed the console on every state dispatch. --- example/src/index.tsx | 3 -- example/src/pages/login.tsx | 12 +---- src/components/AuthorizerBasicAuthLogin.tsx | 48 +++++++++++------ src/components/AuthorizerRoot.tsx | 15 ++++-- src/components/AuthorizerSignup.tsx | 57 ++++++++++++--------- src/contexts/AuthorizerContext.tsx | 13 +++++ src/types/index.ts | 2 + 7 files changed, 91 insertions(+), 59 deletions(-) diff --git a/example/src/index.tsx b/example/src/index.tsx index 82c2fc4..e8b5989 100644 --- a/example/src/index.tsx +++ b/example/src/index.tsx @@ -25,9 +25,6 @@ ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( authorizerURL: 'http://localhost:8080', redirectURL: window.location.origin, }} - onStateChangeCallback={async ({ user, token }: any) => { - console.log(user, token); - }} > diff --git a/example/src/pages/login.tsx b/example/src/pages/login.tsx index 9c0bde9..60470ba 100644 --- a/example/src/pages/login.tsx +++ b/example/src/pages/login.tsx @@ -6,17 +6,7 @@ const Login: React.FC = () => { <>

Welcome to Authorizer


- { - console.log({ loginData }); - }} - onMagicLinkLogin={(mData: any) => { - console.log({ mData }); - }} - onSignup={(sData: any) => { - console.log({ sData }); - }} - /> + ); }; diff --git a/src/components/AuthorizerBasicAuthLogin.tsx b/src/components/AuthorizerBasicAuthLogin.tsx index 1a38654..7706d88 100644 --- a/src/components/AuthorizerBasicAuthLogin.tsx +++ b/src/components/AuthorizerBasicAuthLogin.tsx @@ -1,4 +1,4 @@ -import { FC, useEffect, useState } from 'react'; +import { FC, useEffect, useRef, useState } from 'react'; import { AuthToken, LoginRequest } from '@authorizerdev/authorizer-js'; import validator from 'validator'; const { isEmail, isMobilePhone } = validator; @@ -157,28 +157,29 @@ export const AuthorizerBasicAuthLogin: FC<{ useEffect(() => { if (formData.email_or_phone_number === '') { - setErrorData({ - ...errorData, + setErrorData(prev => ({ + ...prev, email_or_phone_number: 'Email OR Phone Number is required', - }); + })); } else if ( + formData.email_or_phone_number !== null && !isEmail(formData.email_or_phone_number || '') && !isMobilePhone(formData.email_or_phone_number || '') ) { - setErrorData({ - ...errorData, + setErrorData(prev => ({ + ...prev, email_or_phone_number: 'Invalid Email OR Phone Number', - }); + })); } else { - setErrorData({ ...errorData, email_or_phone_number: null }); + setErrorData(prev => ({ ...prev, email_or_phone_number: null })); } }, [formData.email_or_phone_number]); useEffect(() => { if (formData.password === '') { - setErrorData({ ...errorData, password: 'Password is required' }); + setErrorData(prev => ({ ...prev, password: 'Password is required' })); } else { - setErrorData({ ...errorData, password: null }); + setErrorData(prev => ({ ...prev, password: null })); } }, [formData.password]); @@ -186,14 +187,23 @@ export const AuthorizerBasicAuthLogin: FC<{ // user's device support it, discoverable passkeys are offered inline in the // email/username field's autofill dropdown. Best-effort and silent - the // explicit "Sign in with a passkey" button remains the primary path, and any - // unsupported/cancelled/aborted ceremony is ignored. Started once on mount - // and aborted on unmount. - useEffect(() => { - let active = true; + // unsupported/cancelled/aborted ceremony is ignored. + // Started on the email/phone field's first focus rather than on mount: some + // browser/platform authenticator combinations (e.g. Chrome + macOS Touch ID) + // don't wait for the field to actually be focused before surfacing a native + // passkey prompt, which showed up as an unprompted popup on every page load. + // Deferring to focus keeps the ceremony tied to a real user interaction. + const passkeyAutofillActive = useRef(false); + + const startPasskeyAutofill = () => { + if (passkeyAutofillActive.current) { + return; + } + passkeyAutofillActive.current = true; authorizerRef .loginWithPasskeyAutofill() .then(({ data, errors }) => { - if (!active || (errors && errors.length) || !data) { + if (!passkeyAutofillActive.current || (errors && errors.length) || !data) { return; } setAuthData({ @@ -209,8 +219,11 @@ export const AuthorizerBasicAuthLogin: FC<{ .catch(() => { // Autofill is best-effort; ignore unsupported/cancelled ceremonies. }); + }; + + useEffect(() => { return () => { - active = false; + passkeyAutofillActive.current = false; authorizerRef.cancelPasskeyAutofill(); }; // eslint-disable-next-line react-hooks/exhaustive-deps @@ -274,12 +287,13 @@ export const AuthorizerBasicAuthLogin: FC<{ type="text" // Enables WebAuthn passkey autofill: browsers offer discoverable // passkeys inline in this field's autofill dropdown. Paired with - // the loginWithPasskeyAutofill() ceremony started on mount below. + // the loginWithPasskeyAutofill() ceremony started on focus below. autoComplete="username webauthn" value={formData.email_or_phone_number || ''} onChange={e => onInputChange('email_or_phone_number', e.target.value) } + onFocus={startPasskeyAutofill} /> {errorData.email_or_phone_number && (
diff --git a/src/components/AuthorizerRoot.tsx b/src/components/AuthorizerRoot.tsx index 7c2e9c8..d06fc86 100644 --- a/src/components/AuthorizerRoot.tsx +++ b/src/components/AuthorizerRoot.tsx @@ -4,13 +4,14 @@ import { AuthToken } from '@authorizerdev/authorizer-js'; import { AuthorizerBasicAuthLogin } from './AuthorizerBasicAuthLogin'; import { useAuthorizer } from '../contexts/AuthorizerContext'; import { StyledWrapper } from '../styledComponents'; -import { Views } from '../constants'; +import { Views, MessageType } from '../constants'; import { AuthorizerSignup } from './AuthorizerSignup'; import type { FormFieldsOverrides } from './AuthorizerSignup'; import { AuthorizerForgotPassword } from './AuthorizerForgotPassword'; import { AuthorizerSocialLogin } from './AuthorizerSocialLogin'; import { AuthorizerPasskeyLogin } from './AuthorizerPasskeyLogin'; import { AuthorizerMagicLinkLogin } from './AuthorizerMagicLinkLogin'; +import { Message } from './Message'; import { createRandomString } from '../utils/common'; import { hasWindow } from '../utils/window'; @@ -32,7 +33,7 @@ export const AuthorizerRoot: FC<{ signupFieldsOverrides }) => { const [view, setView] = useState(Views.Login); - const { config } = useAuthorizer(); + const { config, configLoadError } = useAuthorizer(); const searchParams = new URLSearchParams( hasWindow() ? window.location.search : `` ); @@ -60,7 +61,15 @@ export const AuthorizerRoot: FC<{ urlProps.redirect_uri = urlProps.redirectURL; return ( - + {configLoadError && ( + + )} + {view === Views.Login && ( + + )} {view === Views.Login && } {view === Views.Login && (config.is_basic_authentication_enabled || diff --git a/src/components/AuthorizerSignup.tsx b/src/components/AuthorizerSignup.tsx index ee00475..d45444d 100644 --- a/src/components/AuthorizerSignup.tsx +++ b/src/components/AuthorizerSignup.tsx @@ -168,10 +168,13 @@ export const AuthorizerSignup: FC<{ ) { return; } - if ((formData.given_name || '').trim() === '') { - setErrorData({ ...errorData, given_name: 'First Name is required' }); + if ( + formData.given_name !== null && + (formData.given_name || '').trim() === '' + ) { + setErrorData(prev => ({ ...prev, given_name: 'First Name is required' })); } else { - setErrorData({ ...errorData, given_name: null }); + setErrorData(prev => ({ ...prev, given_name: null })); } }, [formData.given_name]); @@ -182,65 +185,69 @@ export const AuthorizerSignup: FC<{ ) { return; } - if ((formData.family_name || '').trim() === '') { - setErrorData({ ...errorData, family_name: 'Last Name is required' }); + if ( + formData.family_name !== null && + (formData.family_name || '').trim() === '' + ) { + setErrorData(prev => ({ ...prev, family_name: 'Last Name is required' })); } else { - setErrorData({ ...errorData, family_name: null }); + setErrorData(prev => ({ ...prev, family_name: null })); } }, [formData.family_name]); useEffect(() => { if (formData.email_or_phone_number === '') { - setErrorData({ - ...errorData, + setErrorData(prev => ({ + ...prev, email_or_phone_number: 'Email OR Phone Number is required', - }); + })); } else if ( + formData.email_or_phone_number !== null && !isEmail(formData.email_or_phone_number || '') && !isMobilePhone(formData.email_or_phone_number || '') ) { - setErrorData({ - ...errorData, + setErrorData(prev => ({ + ...prev, email_or_phone_number: 'Invalid Email OR Phone Number', - }); + })); } else { - setErrorData({ ...errorData, email_or_phone_number: null }); + setErrorData(prev => ({ ...prev, email_or_phone_number: null })); } }, [formData.email_or_phone_number]); useEffect(() => { if (formData.password === '') { - setErrorData({ ...errorData, password: 'Password is required' }); + setErrorData(prev => ({ ...prev, password: 'Password is required' })); } else { - setErrorData({ ...errorData, password: null }); + setErrorData(prev => ({ ...prev, password: null })); } }, [formData.password]); useEffect(() => { if (formData.confirmPassword === '') { - setErrorData({ - ...errorData, + setErrorData(prev => ({ + ...prev, confirmPassword: 'Confirm password is required', - }); + })); } else { - setErrorData({ ...errorData, confirmPassword: null }); + setErrorData(prev => ({ ...prev, confirmPassword: null })); } }, [formData.confirmPassword]); useEffect(() => { if (formData.password && formData.confirmPassword) { if (formData.confirmPassword !== formData.password) { - setErrorData({ - ...errorData, + setErrorData(prev => ({ + ...prev, password: `Password and confirm passwords don't match`, confirmPassword: `Password and confirm passwords don't match`, - }); + })); } else { - setErrorData({ - ...errorData, + setErrorData(prev => ({ + ...prev, password: null, confirmPassword: null, - }); + })); } } }, [formData.password, formData.confirmPassword]); diff --git a/src/contexts/AuthorizerContext.tsx b/src/contexts/AuthorizerContext.tsx index 612f4f1..30f6bba 100644 --- a/src/contexts/AuthorizerContext.tsx +++ b/src/contexts/AuthorizerContext.tsx @@ -40,6 +40,7 @@ const AuthorizerContext = createContext({ is_mobile_basic_authentication_enabled: false, is_phone_verification_enabled: false, }, + configLoadError: null, user: null, token: null, loading: false, @@ -91,6 +92,7 @@ let initialState: AuthorizerState = { user: null, token: null, loading: true, + configLoadError: null, config: { authorizerURL: '', redirectURL: '/', @@ -159,6 +161,13 @@ export const AuthorizerProvider: FC<{ const getToken = async () => { const { data: metaRes, errors: metaResErrors } = await authorizer.getMetaData(); + const configLoadError = + metaResErrors && metaResErrors.length ? metaResErrors[0].message : null; + if (configLoadError) { + console.error( + `authorizer-react: failed to load config from ${state.config.authorizerURL}/graphql - ${configLoadError}. Login methods that depend on this config (basic auth, signup, social login, magic link) will not render until it succeeds.` + ); + } try { if (metaResErrors && metaResErrors.length) { throw new Error(metaResErrors[0].message); @@ -178,6 +187,7 @@ export const AuthorizerProvider: FC<{ ...state.config, ...metaRes, }, + configLoadError, loading: false, }, }); @@ -206,6 +216,7 @@ export const AuthorizerProvider: FC<{ ...state.config, ...metaRes, }, + configLoadError, loading: false, }, }); @@ -221,6 +232,7 @@ export const AuthorizerProvider: FC<{ ...state.config, ...metaRes, }, + configLoadError, loading: false, }, }); @@ -303,6 +315,7 @@ export const AuthorizerProvider: FC<{ token: null, loading: false, config: state.config, + configLoadError: state.configLoadError, }; dispatch({ type: AuthorizerProviderActionType.SET_AUTH_DATA, diff --git a/src/types/index.ts b/src/types/index.ts index b8dfd78..6b38ead 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -29,6 +29,7 @@ export type AuthorizerState = { token: AuthToken | null; loading: boolean; config: AuthorizerConfig; + configLoadError?: string | null; }; export type AuthorizerProviderAction = { @@ -62,6 +63,7 @@ export type AuthorizerContextPropsType = { user: null | User; token: null | AuthToken; loading: boolean; + configLoadError?: string | null; logout: () => Promise; setLoading: (data: boolean) => void; setUser: (data: null | User) => void; From ff81756bcddf9c135b8b263b2f4c84b9992dc791 Mon Sep 17 00:00:00 2001 From: Lakhan Samani Date: Mon, 13 Jul 2026 09:00:48 +0530 Subject: [PATCH 02/20] chore: ignore .worktrees/ (local git-worktree scratch dir) --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 8041469..0ffafa8 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,5 @@ dist .parcel-cache .yalc *storybook.log -storybook-static \ No newline at end of file +storybook-static +.worktrees/ From 9d91c4c769c0115f7f227b1c74ace36c1d896144 Mon Sep 17 00:00:00 2001 From: Lakhan Samani Date: Mon, 13 Jul 2026 12:47:42 +0530 Subject: [PATCH 03/20] chore(deps): bump @authorizerdev/authorizer-js to 3.4.0-rc.0 (optional MFA setup) --- package-lock.json | 134 ++++++++++++++++++++++++---------------------- package.json | 2 +- 2 files changed, 70 insertions(+), 66 deletions(-) diff --git a/package-lock.json b/package-lock.json index dc80ace..95a2cdd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "2.2.0-rc.0", "license": "MIT", "dependencies": { - "@authorizerdev/authorizer-js": "^3.3.0-rc.1", + "@authorizerdev/authorizer-js": "file:../../../../authorizer-js/.worktrees/feat/optional-mfa-setup", "@storybook/preset-scss": "^1.0.3", "validator": "^13.11.0" }, @@ -52,6 +52,72 @@ "react": ">=16" } }, + "../../../../authorizer-js": { + "name": "@authorizerdev/authorizer-js", + "version": "3.3.0-rc.1", + "license": "MIT", + "dependencies": { + "cross-fetch": "^4.1.0" + }, + "devDependencies": { + "@antfu/eslint-config": "^2.27.3", + "@swc/core": "^1.15.24", + "@types/jest": "^29.5.14", + "@types/node": "^20.19.39", + "@typescript-eslint/eslint-plugin": "^8.58.0", + "@typescript-eslint/parser": "^8.58.0", + "bumpp": "^9.11.1", + "eslint": "^8.57.1", + "husky": "^8.0.3", + "jest": "^29.7.0", + "jest-environment-jsdom": "^29.7.0", + "lint-staged": "^15.5.2", + "testcontainers": "^10.28.0", + "ts-jest": "^29.4.9", + "tslib": "^2.8.1", + "tsup": "^8.5.1", + "typescript": "^5.9.3" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/authorizerdev" + } + }, + "../../../../authorizer-js/.worktrees/feat/optional-mfa-setup": { + "name": "@authorizerdev/authorizer-js", + "version": "3.4.0-rc.0", + "license": "MIT", + "dependencies": { + "cross-fetch": "^4.1.0" + }, + "devDependencies": { + "@antfu/eslint-config": "^2.27.3", + "@swc/core": "^1.15.24", + "@types/jest": "^29.5.14", + "@types/node": "^20.19.39", + "@typescript-eslint/eslint-plugin": "^8.58.0", + "@typescript-eslint/parser": "^8.58.0", + "bumpp": "^9.11.1", + "eslint": "^8.57.1", + "husky": "^8.0.3", + "jest": "^29.7.0", + "jest-environment-jsdom": "^29.7.0", + "lint-staged": "^15.5.2", + "testcontainers": "^10.28.0", + "ts-jest": "^29.4.9", + "tslib": "^2.8.1", + "tsup": "^8.5.1", + "typescript": "^5.9.3" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/authorizerdev" + } + }, "node_modules/@adobe/css-tools": { "version": "4.4.4", "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", @@ -73,19 +139,8 @@ } }, "node_modules/@authorizerdev/authorizer-js": { - "version": "3.3.0-rc.1", - "resolved": "https://registry.npmjs.org/@authorizerdev/authorizer-js/-/authorizer-js-3.3.0-rc.1.tgz", - "integrity": "sha512-L6S2BzHIP7cPa4nD3FT2RxRiQM5w6jauCNtEX+5t+8sUr4DrrcZON2MhYhVSuBT8+8/Y9TcKO2ZamZwuDX0pVg==", - "license": "MIT", - "dependencies": { - "cross-fetch": "^4.1.0" - }, - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/authorizerdev" - } + "resolved": "../../../../authorizer-js/.worktrees/feat/optional-mfa-setup", + "link": true }, "node_modules/@babel/code-frame": { "version": "7.29.0", @@ -4387,15 +4442,6 @@ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "dev": true }, - "node_modules/cross-fetch": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.1.0.tgz", - "integrity": "sha512-uKm5PU+MHTootlWEY+mZ4vvXoCn4fLQxT9dSc1sXVMSFkINTJVN8cAQROpwcKm8bJ/c7rgZVIBWzH5T78sNZZw==", - "license": "MIT", - "dependencies": { - "node-fetch": "^2.7.0" - } - }, "node_modules/css-loader": { "version": "6.11.0", "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-6.11.0.tgz", @@ -7434,26 +7480,6 @@ "dev": true, "license": "MIT" }, - "node_modules/node-fetch": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", - "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", - "license": "MIT", - "dependencies": { - "whatwg-url": "^5.0.0" - }, - "engines": { - "node": "4.x || >=6.0.0" - }, - "peerDependencies": { - "encoding": "^0.1.0" - }, - "peerDependenciesMeta": { - "encoding": { - "optional": true - } - } - }, "node_modules/node-releases": { "version": "2.0.36", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", @@ -9147,12 +9173,6 @@ "node": ">=8.0" } }, - "node_modules/tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", - "license": "MIT" - }, "node_modules/tree-kill": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", @@ -10136,12 +10156,6 @@ "node": ">=10.13.0" } }, - "node_modules/webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", - "license": "BSD-2-Clause" - }, "node_modules/webpack": { "version": "5.105.4", "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.105.4.tgz", @@ -10253,16 +10267,6 @@ "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", "license": "MIT" }, - "node_modules/whatwg-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", - "license": "MIT", - "dependencies": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" - } - }, "node_modules/which-boxed-primitive": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", diff --git a/package.json b/package.json index 3aea20a..8cda37f 100644 --- a/package.json +++ b/package.json @@ -91,7 +91,7 @@ "typescript": "^5.7.2" }, "dependencies": { - "@authorizerdev/authorizer-js": "^3.3.0-rc.1", + "@authorizerdev/authorizer-js": "file:../../../../authorizer-js/.worktrees/feat/optional-mfa-setup", "@storybook/preset-scss": "^1.0.3", "validator": "^13.11.0" } From e6e0ffad63833badf94c40550af94e7361b885bc Mon Sep 17 00:00:00 2001 From: Lakhan Samani Date: Mon, 13 Jul 2026 12:52:43 +0530 Subject: [PATCH 04/20] feat(mfa): log in immediately and surface should_offer_mfa_setup instead of blocking on optional TOTP setup --- src/components/AuthorizerBasicAuthLogin.tsx | 64 +++++++++++---------- 1 file changed, 33 insertions(+), 31 deletions(-) diff --git a/src/components/AuthorizerBasicAuthLogin.tsx b/src/components/AuthorizerBasicAuthLogin.tsx index 7706d88..64965a8 100644 --- a/src/components/AuthorizerBasicAuthLogin.tsx +++ b/src/components/AuthorizerBasicAuthLogin.tsx @@ -98,37 +98,39 @@ export const AuthorizerBasicAuthLogin: FC<{ setError(errors[0].message); return; } - // if totp is enabled for the first time show totp screen with scanner - if ( - res && - res.should_show_totp_screen && - res.authenticator_scanner_image && - res.authenticator_secret && - res.authenticator_recovery_codes - ) { - setTotpData({ - is_screen_visible: true, - email: data.email || ``, - phone_number: data.phone_number || ``, - authenticator_scanner_image: res.authenticator_scanner_image, - authenticator_secret: res.authenticator_secret, - authenticator_recovery_codes: res.authenticator_recovery_codes, - }); - return; - } - if ( - res && - (res?.should_show_email_otp_screen || - res?.should_show_mobile_otp_screen || - res?.should_show_totp_screen) - ) { - setOtpData({ - is_screen_visible: true, - email: data.email || ``, - phone_number: data.phone_number || ``, - is_totp: res?.should_show_totp_screen || false, - }); - return; + // res.access_token is only absent when MFA is enforced/blocking (see + // resolveMFAGate in the backend) — in every other case, including the + // optional-setup-offer case, the user is already logged in. + if (res && !res.access_token) { + if ( + res.should_show_totp_screen && + res.authenticator_scanner_image && + res.authenticator_secret && + res.authenticator_recovery_codes + ) { + setTotpData({ + is_screen_visible: true, + email: data.email || ``, + phone_number: data.phone_number || ``, + authenticator_scanner_image: res.authenticator_scanner_image, + authenticator_secret: res.authenticator_secret, + authenticator_recovery_codes: res.authenticator_recovery_codes, + }); + return; + } + if ( + res.should_show_email_otp_screen || + res.should_show_mobile_otp_screen || + res.should_show_totp_screen + ) { + setOtpData({ + is_screen_visible: true, + email: data.email || ``, + phone_number: data.phone_number || ``, + is_totp: res.should_show_totp_screen || false, + }); + return; + } } if (res) { From 88888f471e525bb5f2a95c55332a96fdd2307331 Mon Sep 17 00:00:00 2001 From: Lakhan Samani Date: Mon, 13 Jul 2026 12:58:23 +0530 Subject: [PATCH 05/20] feat(mfa): add Skip action to AuthorizerMFASetup and demonstrate host-app wiring AuthorizerMFASetup gains an optional onSkip prop rendering a "Skip for now" button in the method-list view. The example app's login.tsx captures should_offer_mfa_setup + authenticator_* fields from onLogin into sessionStorage, and dashboard.tsx renders AuthorizerMFASetup with onSkip wired to authorizerRef.skipMfaSetup() when an offer is pending, falling back to the normal dashboard otherwise. --- example/src/pages/dashboard.tsx | 26 ++++++++++++++++++++++++-- example/src/pages/login.tsx | 15 ++++++++++++++- src/components/AuthorizerMFASetup.tsx | 15 +++++++++++++++ 3 files changed, 53 insertions(+), 3 deletions(-) diff --git a/example/src/pages/dashboard.tsx b/example/src/pages/dashboard.tsx index 0910114..5582244 100644 --- a/example/src/pages/dashboard.tsx +++ b/example/src/pages/dashboard.tsx @@ -1,8 +1,30 @@ import * as React from 'react'; -import { useAuthorizer } from 'authorizer-react'; +import { AuthorizerMFASetup, useAuthorizer } from 'authorizer-react'; const Dashboard: React.FC = () => { - const { user, loading, logout } = useAuthorizer(); + const { user, loading, logout, authorizerRef } = useAuthorizer(); + const [mfaOffer, setMfaOffer] = React.useState(() => { + const raw = sessionStorage.getItem('mfaSetupOffer'); + return raw ? JSON.parse(raw) : null; + }); + + const dismissMfaOffer = () => { + sessionStorage.removeItem('mfaSetupOffer'); + setMfaOffer(null); + }; + + if (mfaOffer) { + return ( + { + await authorizerRef.skipMfaSetup(); + dismissMfaOffer(); + }} + /> + ); + } return (
diff --git a/example/src/pages/login.tsx b/example/src/pages/login.tsx index 60470ba..7780139 100644 --- a/example/src/pages/login.tsx +++ b/example/src/pages/login.tsx @@ -6,7 +6,20 @@ const Login: React.FC = () => { <>

Welcome to Authorizer


- + { + if (data?.should_offer_mfa_setup) { + sessionStorage.setItem( + 'mfaSetupOffer', + JSON.stringify({ + authenticator_scanner_image: data.authenticator_scanner_image, + authenticator_secret: data.authenticator_secret, + authenticator_recovery_codes: data.authenticator_recovery_codes, + }) + ); + } + }} + /> ); }; diff --git a/src/components/AuthorizerMFASetup.tsx b/src/components/AuthorizerMFASetup.tsx index 20c3361..eecfda2 100644 --- a/src/components/AuthorizerMFASetup.tsx +++ b/src/components/AuthorizerMFASetup.tsx @@ -65,11 +65,16 @@ export const AuthorizerMFASetup: FC<{ // Fired when a method is chosen that this component can't complete on its // own (email/SMS OTP, or TOTP without an enrolment payload). onSetupMethod?: (method: MfaMethod) => void; + // Fired when the user explicitly declines to set up MFA right now. Absent + // when MFA is organization-enforced — the host app must not render this + // component with onSkip set in that case (check config before rendering). + onSkip?: () => void; heading?: string; }> = ({ availableMfaMethods, totpEnrollment, onSetupMethod, + onSkip, heading = 'Add a second step to sign in', }) => { const [selected, setSelected] = useState(null); @@ -213,6 +218,16 @@ export const AuthorizerMFASetup: FC<{ ))} )} + {onSkip && ( + + )} ); }; From 785a27a4963e8d2acbae922619dd53596246a707 Mon Sep 17 00:00:00 2001 From: Lakhan Samani Date: Tue, 14 Jul 2026 10:24:08 +0530 Subject: [PATCH 06/20] fix(mfa): guard mfaSetupOffer JSON.parse against corrupt sessionStorage Malformed sessionStorage data would throw and crash the dashboard on mount instead of falling back to the normal authenticated view. --- example/src/pages/dashboard.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/example/src/pages/dashboard.tsx b/example/src/pages/dashboard.tsx index 5582244..ee3850b 100644 --- a/example/src/pages/dashboard.tsx +++ b/example/src/pages/dashboard.tsx @@ -5,7 +5,11 @@ const Dashboard: React.FC = () => { const { user, loading, logout, authorizerRef } = useAuthorizer(); const [mfaOffer, setMfaOffer] = React.useState(() => { const raw = sessionStorage.getItem('mfaSetupOffer'); - return raw ? JSON.parse(raw) : null; + try { + return raw ? JSON.parse(raw) : null; + } catch { + return null; + } }); const dismissMfaOffer = () => { From b4fa0c4bdc38333a9a78579bbb32557bd399369a Mon Sep 17 00:00:00 2001 From: Lakhan Samani Date: Tue, 14 Jul 2026 10:24:37 +0530 Subject: [PATCH 07/20] docs: add optional-mfa-with-skip implementation plan Records the 8-task cross-repo plan (authorizer, authorizer-js, authorizer-react) this branch implements, plus deferred work (passkey-as-MFA, email/SMS-OTP enrollment parity) for reference. --- .../2026-07-13-optional-mfa-with-skip.md | 972 ++++++++++++++++++ 1 file changed, 972 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-13-optional-mfa-with-skip.md diff --git a/docs/superpowers/plans/2026-07-13-optional-mfa-with-skip.md b/docs/superpowers/plans/2026-07-13-optional-mfa-with-skip.md new file mode 100644 index 0000000..bb2f3af --- /dev/null +++ b/docs/superpowers/plans/2026-07-13-optional-mfa-with-skip.md @@ -0,0 +1,972 @@ +# Optional MFA Setup With Skip Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** When MFA is available but not organization-enforced, a user who hasn't set up MFA gets logged in immediately and is offered (not forced into) MFA setup, with a Skip action that's remembered so they aren't nagged again — while `--enforce-mfa` keeps working exactly as it does today (no token until MFA is complete, never skippable). + +**Architecture:** Three repos, in dependency order: `authorizer` (Go backend — new schema field, new decision logic, new mutations) → `authorizer-js` (TS API client — exposes the new response fields and mutations) → `authorizer-react` (React UI — wires the login form and the existing-but-unused `AuthorizerMFASetup` hub into the actual login flow with a working Skip button). + +**Tech Stack:** Go 1.x / gqlgen / GORM (+ driver-specific code for Cassandra, DynamoDB, Couchbase) for the backend; TypeScript for `authorizer-js`; React + TypeScript for `authorizer-react`. + +## Global Constraints + +- **`EnforceMFA` is absolute.** When `p.Config.EnforceMFA` is true, behavior is unchanged from today: no access token is issued until MFA is completed, and skip is never offered. This must be enforced server-side in every new code path — never trust a client-supplied "skip me" request. +- **A user's own opted-in MFA is never skippable.** If a user has a *verified* authenticator (or has otherwise completed MFA setup), every login requires that verification, regardless of `EnforceMFA`. Skip only applies to the *first-time setup* decision, not to already-configured MFA. +- **M2M / service-account / client-credentials / gRPC workload-identity flows are out of scope and must not be touched.** Confirmed in research: `service.Login` (this plan's surface) is reached only via the interactive GraphQL `login` mutation and the gRPC `authorizer.Login` handler, both driven by `model.LoginRequest` (email+password). Machine-to-machine token issuance (`internal/http_handlers/token.go:handleClientCredentialsGrant`) operates on a completely separate `schemas.Client` type and never reads `IsMultiFactorAuthEnabled`. Do not add any MFA check reachable from that path. +- **Passkey-as-MFA and Email/SMS-OTP "not yet enrolled" parity are explicitly OUT of scope for this plan** — see "Deferred Work" at the end. Do not attempt them as part of these tasks. +- Every schema change must update all backends the codebase currently supports: SQL (GORM auto-migrates), MongoDB, ArangoDB (both pick up new struct fields automatically — no separate migration file), Cassandra/ScyllaDB (needs an explicit `ALTER TABLE` plus updates to every hand-written `SELECT`/`Scan`), DynamoDB (needs a nil-attribute-removal branch), Couchbase (needs updates to every hand-written `SELECT` column list). Per the codebase's own convention comment in `internal/storage/schemas/user.go:11`: "any change here should be reflected in providers/casandra/provider.go as it does not have model support in collection creation." +- Local verification happens against the SQLite-backed dev server started by `authorizer/../examples/with-auth-recipes/run-server.sh` (already running with `--enforce-mfa=false`, `--enable-mfa`, `--enable-totp-login` — this is the exact server and the exact stuck account, `lakhan@yopmail.com`, that surfaced this bug). + +--- + +## Backend (`authorizer` repo) + +### Task 1: Add `HasSkippedMFASetupAt` field across all storage backends + +**Files:** +- Modify: `internal/storage/schemas/user.go:36` (insert new field), `:82` (AsAPIUser mapping) +- Modify: `internal/graph/schema.graphqls:82` (User type), `internal/graph/model/models_gen.go` (regenerated) +- Modify: `internal/storage/db/cassandradb/provider.go` (near line 178-183, ALTER TABLE) +- Modify: `internal/storage/db/cassandradb/user.go` (lines ~156, 227-228, 238-239, 316-317, 328-329 — column lists and `Scan` args) +- Modify: `internal/storage/db/dynamodb/user.go` (near line 112-113, nil-attribute removal) +- Modify: `internal/storage/db/couchbase/user.go` (lines ~125, 158, 182, 204, 260 — SELECT column lists) +- Test: `internal/storage/schemas/user_test.go` (create if it doesn't exist) + +**Interfaces:** +- Produces: `schemas.User.HasSkippedMFASetupAt *int64` — a nullable Unix-seconds timestamp. `nil` means "never skipped, and not yet known whether they'll set up MFA." Non-nil means "user explicitly chose Skip at this time." +- Produces: `model.User.HasSkippedMfaSetupAt *int64` (GraphQL-exposed, exact name matches gqlgen's Go-casing convention — check the generated name for `is_multi_factor_auth_enabled` → `IsMultiFactorAuthEnabled` and mirror it: `has_skipped_mfa_setup_at` → `HasSkippedMfaSetupAt`). + +- [ ] **Step 1: Add the field to the canonical schema struct** + +Edit `internal/storage/schemas/user.go`, insert after line 36 (`IsMultiFactorAuthEnabled`): + +```go + IsMultiFactorAuthEnabled *bool `json:"is_multi_factor_auth_enabled" bson:"is_multi_factor_auth_enabled" cql:"is_multi_factor_auth_enabled" dynamo:"is_multi_factor_auth_enabled"` + // 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"` +``` + +- [ ] **Step 2: Map it into the API-facing user in `AsAPIUser`** + +Edit `internal/storage/schemas/user.go` inside `AsAPIUser` (around line 82), add after the `IsMultiFactorAuthEnabled:` line: + +```go + IsMultiFactorAuthEnabled: user.IsMultiFactorAuthEnabled, + HasSkippedMfaSetupAt: user.HasSkippedMFASetupAt, +``` + +- [ ] **Step 3: Add the field to the GraphQL schema** + +Edit `internal/graph/schema.graphqls`, in the `type User {` block (line 61-84), add after `is_multi_factor_auth_enabled: Boolean` (line 82): + +```graphql + is_multi_factor_auth_enabled: Boolean + # 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 + app_data: Map +``` + +- [ ] **Step 4: Regenerate GraphQL code** + +Run: `cd /Users/lakhansamani/projects/authorizer/authorizer && go run github.com/99designs/gqlgen --verbose generate && go mod tidy` + +Expected: `internal/graph/model/models_gen.go`'s `User` struct now has a `HasSkippedMfaSetupAt *int64` field with a `has_skipped_mfa_setup_at,omitempty` json tag. Confirm with: +`grep -n "HasSkippedMfaSetupAt" internal/graph/model/models_gen.go` + +- [ ] **Step 5: Cassandra — add the column and wire every hand-written query** + +Edit `internal/storage/db/cassandradb/provider.go` near line 178-183, add a sibling `ALTER TABLE` immediately after the existing `is_multi_factor_auth_enabled` one: + +```go + userTableAlterQuery = fmt.Sprintf(`ALTER TABLE %s.%s ADD has_skipped_mfa_setup_at bigint`, KeySpace, schemas.Collections.User) + err = session.Query(userTableAlterQuery).Exec() + if err != nil { + deps.Log.Debug().Err(err).Msg("Failed to alter table as has_skipped_mfa_setup_at column exists") + // continue + } +``` + +Then in `internal/storage/db/cassandradb/user.go`, add `has_skipped_mfa_setup_at` to the column list and a corresponding `&user.HasSkippedMFASetupAt` to the `.Scan(...)` args at each of lines ~156, 227-228, 238-239, 316-317, 328-329 (wherever `is_multi_factor_auth_enabled` currently appears in that file's SELECTs — add the new column immediately after it, and the new `Scan` arg immediately after `&user.IsMultiFactorAuthEnabled`). + +- [ ] **Step 6: DynamoDB — nil-attribute removal** + +Edit `internal/storage/db/dynamodb/user.go` near line 112-113, add a sibling branch: + +```go + if u.IsMultiFactorAuthEnabled == nil { + remove = append(remove, "is_multi_factor_auth_enabled") + } + if u.HasSkippedMFASetupAt == nil { + remove = append(remove, "has_skipped_mfa_setup_at") + } +``` + +- [ ] **Step 7: Couchbase — add the column to every SELECT** + +Edit `internal/storage/db/couchbase/user.go` at lines ~125, 158, 182, 204, 260: add `has_skipped_mfa_setup_at` to each SELECT's column list, immediately after `is_multi_factor_auth_enabled`. + +- [ ] **Step 8: Verify SQL/Mongo/Arango need no extra edits** + +SQL: GORM's `AutoMigrate(&schemas.User{}, ...)` (`internal/storage/db/sql/provider.go:98`) picks up the new struct field automatically on next boot — no action needed. +MongoDB/ArangoDB: both marshal `*schemas.User` directly — no action needed. + +Run: `cd /Users/lakhansamani/projects/authorizer/authorizer && go build ./...` +Expected: builds clean. + +- [ ] **Step 9: Commit** + +```bash +git add internal/storage/schemas/user.go internal/graph/schema.graphqls internal/graph/model/models_gen.go internal/graph/generated/generated.go internal/storage/db/cassandradb/provider.go internal/storage/db/cassandradb/user.go internal/storage/db/dynamodb/user.go internal/storage/db/couchbase/user.go +git commit -m "feat(mfa): add has_skipped_mfa_setup_at to the user record" +``` + +--- + +### Task 2: Extract the MFA-gate decision as a pure, unit-tested function + +**Files:** +- Create: `internal/service/mfa_gate.go` +- Test: `internal/service/mfa_gate_test.go` + +**Interfaces:** +- Consumes: nothing from other tasks — pure function, no DB/network access. +- Produces: `type mfaGateDecision int` with values `mfaGateNone`, `mfaGateBlockVerify`, `mfaGateBlockEnroll`, `mfaGateOfferSetup`, `mfaGateSkippedSetup` — and `func resolveMFAGate(userMFAEnabled bool, enforceMFA bool, authenticatorVerified bool, hasSkippedSetup bool) mfaGateDecision`, consumed by Task 3. + +- [ ] **Step 1: Write the failing test** + +```go +// internal/service/mfa_gate_test.go +package service + +import "testing" + +func TestResolveMFAGate(t *testing.T) { + cases := []struct { + name string + userMFAEnabled bool + enforceMFA bool + authenticatorVerified bool + hasSkippedSetup bool + 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}, + {"enforced, not yet enrolled", true, true, false, false, mfaGateBlockEnroll}, + {"enforced, already verified", true, true, true, false, mfaGateBlockVerify}, + {"enforced, skip flag present but ignored", true, true, false, true, mfaGateBlockEnroll}, + {"optional, already verified -> still verify every time", true, false, true, false, mfaGateBlockVerify}, + {"optional, already verified, skip flag stale -> still verify", true, false, true, true, mfaGateBlockVerify}, + {"optional, not enrolled, never skipped -> offer", true, false, false, false, mfaGateOfferSetup}, + {"optional, not enrolled, already skipped -> quiet login", true, false, false, true, mfaGateSkippedSetup}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got := resolveMFAGate(c.userMFAEnabled, c.enforceMFA, c.authenticatorVerified, c.hasSkippedSetup) + if got != c.want { + t.Errorf("resolveMFAGate(%v,%v,%v,%v) = %v, want %v", c.userMFAEnabled, c.enforceMFA, c.authenticatorVerified, c.hasSkippedSetup, got, c.want) + } + }) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd /Users/lakhansamani/projects/authorizer/authorizer && go test ./internal/service/ -run TestResolveMFAGate -v` +Expected: FAIL — `resolveMFAGate`/`mfaGateDecision`/`mfaGateNone` undefined. + +- [ ] **Step 3: Write the implementation** + +```go +// internal/service/mfa_gate.go +package service + +// mfaGateDecision is what login.go should do once it knows a user has MFA +// available. See resolveMFAGate for the truth table. +type mfaGateDecision int + +const ( + // mfaGateNone: user has no MFA to worry about. Issue the token normally. + mfaGateNone mfaGateDecision = iota + // mfaGateBlockVerify: user has a verified/completed MFA method already. + // Withhold the token until they verify it. Never skippable — this is the + // user's own opted-in second factor. + mfaGateBlockVerify + // mfaGateBlockEnroll: MFA is org-enforced and this user hasn't finished + // 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 + // chosen Skip in the past. Issue the token, don't nag. + mfaGateSkippedSetup +) + +// resolveMFAGate decides what login.go does for a user whose +// IsMultiFactorAuthEnabled flag might be set. Only called when the caller +// has already confirmed MFA is available on this server at all +// (Config.EnableMFA) — see login.go call sites. +// +// - userMFAEnabled: schemas.User.IsMultiFactorAuthEnabled +// - enforceMFA: Config.EnforceMFA (org-wide mandate — absolute, never +// bypassed by hasSkippedSetup) +// - authenticatorVerified: true when the user has a completed/verified MFA +// method already (e.g. a verified TOTP authenticator) — their own opted-in +// second factor, always required once true, regardless of enforceMFA or +// hasSkippedSetup +// - hasSkippedSetup: schemas.User.HasSkippedMFASetupAt != nil +func resolveMFAGate(userMFAEnabled, enforceMFA, authenticatorVerified, hasSkippedSetup bool) mfaGateDecision { + if !userMFAEnabled { + return mfaGateNone + } + if authenticatorVerified { + // The user's own completed second factor. Always required, never + // skippable, regardless of current enforcement policy. + return mfaGateBlockVerify + } + if enforceMFA { + return mfaGateBlockEnroll + } + if hasSkippedSetup { + return mfaGateSkippedSetup + } + return mfaGateOfferSetup +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd /Users/lakhansamani/projects/authorizer/authorizer && go test ./internal/service/ -run TestResolveMFAGate -v` +Expected: PASS, all 9 subtests. + +- [ ] **Step 5: Commit** + +```bash +git add internal/service/mfa_gate.go internal/service/mfa_gate_test.go +git commit -m "feat(mfa): add pure MFA-gate decision function with unit tests" +``` + +--- + +### Task 3: Rewire `login.go`'s TOTP branch to use the gate, and add `should_offer_mfa_setup` to `AuthResponse` + +**Files:** +- Modify: `internal/graph/schema.graphqls:117` (AuthResponse), `internal/graph/model/models_gen.go` (regenerated) +- Modify: `internal/service/login.go:349-386` (the TOTP MFA branch) + +**Interfaces:** +- Consumes: `resolveMFAGate` from Task 2. +- Produces: `model.AuthResponse.ShouldOfferMfaSetup *bool` — when true alongside a populated `AccessToken`, the frontend should show (not force) MFA setup. `AuthenticatorScannerImage`/`AuthenticatorSecret`/`AuthenticatorRecoveryCodes` are populated on the SAME response in this case, so the frontend can render the TOTP QR immediately if the user chooses to set it up. + +- [ ] **Step 1: Add the field to the GraphQL schema** + +Edit `internal/graph/schema.graphqls`, inside `type AuthResponse {` (line 113-129), add after `should_show_totp_screen: Boolean` (line 117): + +```graphql + should_show_totp_screen: 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: Boolean +``` + +Regenerate: `cd /Users/lakhansamani/projects/authorizer/authorizer && go run github.com/99designs/gqlgen --verbose generate && go mod tidy` + +- [ ] **Step 2: Rewrite the TOTP branch in `login.go`** + +Replace the block at `internal/service/login.go:349-386` (the `if refs.BoolValue(user.IsMultiFactorAuthEnabled) && isMFAEnabled && isTOTPLoginEnabled {` block): + +```go + // If mfa enabled and also totp enabled + if isMFAEnabled && isTOTPLoginEnabled { + authenticator, authErr := p.StorageProvider.GetAuthenticatorDetailsByUserId(ctx, user.ID, constants.EnvKeyTOTPAuthenticator) + authenticatorVerified := authErr == nil && authenticator != nil && authenticator.VerifiedAt != nil + gate := resolveMFAGate( + refs.BoolValue(user.IsMultiFactorAuthEnabled), + p.Config.EnforceMFA, + authenticatorVerified, + user.HasSkippedMFASetupAt != nil, + ) + switch gate { + case mfaGateBlockVerify: + expiresAt := time.Now().Add(3 * time.Minute).Unix() + if err := setOTPMFaSession(expiresAt); err != nil { + log.Debug().Msg("Failed to set mfa session") + return nil, nil, err + } + return &model.AuthResponse{ + Message: `Proceed to totp screen`, + ShouldShowTotpScreen: refs.NewBoolRef(true), + }, side, nil + case mfaGateBlockEnroll: + expiresAt := time.Now().Add(3 * time.Minute).Unix() + if err := setOTPMFaSession(expiresAt); err != nil { + log.Debug().Msg("Failed to set mfa session") + return nil, nil, err + } + authConfig, err := p.AuthenticatorProvider.Generate(ctx, user.ID) + if err != nil { + log.Debug().Msg("Failed to generate totp") + return nil, nil, err + } + recoveryCodes := []*string{} + for _, code := range authConfig.RecoveryCodes { + recoveryCodes = append(recoveryCodes, refs.NewStringRef(code)) + } + return &model.AuthResponse{ + Message: `Proceed to totp verification screen`, + ShouldShowTotpScreen: refs.NewBoolRef(true), + AuthenticatorScannerImage: refs.NewStringRef(authConfig.ScannerImage), + AuthenticatorSecret: refs.NewStringRef(authConfig.Secret), + AuthenticatorRecoveryCodes: recoveryCodes, + }, side, nil + case mfaGateOfferSetup: + authConfig, err := p.AuthenticatorProvider.Generate(ctx, user.ID) + if err != nil { + log.Debug().Msg("Failed to generate totp for optional setup") + return nil, nil, err + } + recoveryCodes := []*string{} + for _, code := range authConfig.RecoveryCodes { + recoveryCodes = append(recoveryCodes, refs.NewStringRef(code)) + } + // Falls through to normal token issuance below, with the offer + // flag and enrollment payload attached after CreateAuthToken. + side.PendingTOTPOffer = &pendingTOTPOffer{ + ScannerImage: authConfig.ScannerImage, + Secret: authConfig.Secret, + RecoveryCodes: recoveryCodes, + } + case mfaGateSkippedSetup: + side.OfferMFASetupQuiet = true + case mfaGateNone: + // fall through, nothing to do + } + } +``` + +This introduces two new fields on `ResponseSideEffects` (`internal/service/sideeffects.go` — find this file and add): + +```go + // 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 *pendingTOTPOffer + // OfferMFASetupQuiet is true when the MFA gate decided the user already + // skipped setup before — no enrollment payload, no offer flag, just a + // normal login. + OfferMFASetupQuiet bool +} + +type pendingTOTPOffer struct { + ScannerImage string + Secret string + RecoveryCodes []*string +} +``` + +- [ ] **Step 3: Attach the offer to the final success response** + +In `internal/service/login.go`, find the final success response construction (around line 453, `res := &model.AuthResponse{...}`). Immediately after it, before the `for _, c := range cookie.BuildSessionCookies` loop, add: + +```go + res := &model.AuthResponse{ + Message: `Logged in successfully`, + AccessToken: &authToken.AccessToken.Token, + IDToken: &authToken.IDToken.Token, + 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 + } +``` + +- [ ] **Step 4: Build and run the unit tests** + +Run: `cd /Users/lakhansamani/projects/authorizer/authorizer && go build ./... && go test ./internal/service/ -run TestResolveMFAGate -v` +Expected: builds clean, tests still pass. + +- [ ] **Step 5: Manual local verification** + +With the recipe server running (`--enforce-mfa=false`, per `examples/with-auth-recipes/run-server.sh`), log in as `lakhan@yopmail.com` / `Test@123#` via the raw GraphQL call used earlier in this session: + +```bash +curl -s http://localhost:8080/graphql -X POST -H 'Content-Type: application/json' -d '{"query":"mutation login($data: LoginRequest!) { login(params: $data) { message access_token should_show_totp_screen should_offer_mfa_setup authenticator_secret } }","variables":{"data":{"email":"lakhan@yopmail.com","password":"Test@123#"}}}' +``` + +Expected: `access_token` is now populated (not null), `should_show_totp_screen` is null/false, `should_offer_mfa_setup` is true, `authenticator_secret` is a fresh TOTP secret. This confirms the exact stuck-login bug from this session is fixed. + +- [ ] **Step 6: Commit** + +```bash +git add internal/graph/schema.graphqls internal/graph/model/models_gen.go internal/graph/generated/generated.go internal/service/login.go internal/service/sideeffects.go +git commit -m "feat(mfa): issue a session immediately and offer (not force) TOTP setup when MFA isn't enforced" +``` + +--- + +### Task 4: Authenticated `skip_mfa_setup` mutation + +**Files:** +- Modify: `internal/graph/schema.graphqls` (Mutation block, line ~1319) +- Create: `internal/service/skip_mfa_setup.go` +- Modify: `internal/service/provider.go` (interface) +- Create: `internal/graphql/skip_mfa_setup.go` +- Modify: `internal/graphql/provider.go` (interface) +- Modify: `internal/graph/schema.resolvers.go` (delegating stub, auto-inserted by codegen — hand-fill the body) + +**Interfaces:** +- Consumes: `p.callerTokenData` pattern from `internal/service/deactivate_account.go:21` for authenticated-user extraction. +- Produces: GraphQL mutation `skip_mfa_setup: Response!`, consumed by `authorizer-js` Task 5 and `authorizer-react` Task 8. + +- [ ] **Step 1: Add the mutation to the schema** + +Edit `internal/graph/schema.graphqls`, in the `Mutation` block, add after `resend_otp(params: ResendOTPRequest!): Response!` (line ~1319): + +```graphql + 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! +``` + +- [ ] **Step 2: Write the service-layer implementation** + +```go +// internal/service/skip_mfa_setup.go +package service + +import ( + "context" + "time" + + "github.com/authorizerdev/authorizer/internal/graph/model" +) + +// SkipMFASetup records that the authenticated caller explicitly declined the +// optional MFA setup prompt shown at login. Never allowed when MFA is +// org-enforced — that path never offers a skip in the first place +// (resolveMFAGate never returns mfaGateOfferSetup when EnforceMFA is true), +// but this is re-checked here server-side so a client can never forge the +// request to bypass enforcement. +// +// Permissions: authenticated user (bearer token or session cookie). +func (p *provider) SkipMFASetup(ctx context.Context, meta RequestMetadata) (*model.Response, *ResponseSideEffects, error) { + log := p.Log.With().Str("func", "SkipMFASetup").Logger() + + 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") + } + + 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.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 { + log.Debug().Err(err).Msg("Failed to update user") + return nil, nil, err + } + return &model.Response{Message: "MFA setup skipped"}, nil, nil +} +``` + +- [ ] **Step 3: Add to the service provider interface** + +Edit `internal/service/provider.go`, add near `DeactivateAccount` (line ~101): + +```go + 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) +``` + +- [ ] **Step 4: Write the GraphQL-transport adapter** + +```go +// internal/graphql/skip_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" +) + +func (g *graphqlProvider) SkipMFASetup(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.SkipMFASetup(ctx, service.MetaFromGin(gc)) + if err != nil { + return nil, err + } + service.ApplyToGin(gc, side) + return res, nil +} +``` + +- [ ] **Step 5: Add to the GraphQL provider interface** + +Edit `internal/graphql/provider.go`, add near `DeactivateAccount` (line ~98): + +```go + DeactivateAccount(ctx context.Context) (*model.Response, error) + // SkipMFASetup is the method to skip optional MFA setup. + SkipMFASetup(ctx context.Context) (*model.Response, error) +``` + +- [ ] **Step 6: Regenerate and fill the resolver stub** + +Run: `cd /Users/lakhansamani/projects/authorizer/authorizer && go run github.com/99designs/gqlgen --verbose generate && go mod tidy` + +This inserts a stub in `internal/graph/schema.resolvers.go`. Fill it to match the existing `DeactivateAccount` resolver pattern: + +```go +func (r *mutationResolver) SkipMfaSetup(ctx context.Context) (*model.Response, error) { + return r.GraphQLProvider.SkipMFASetup(ctx) +} +``` + +- [ ] **Step 7: Build** + +Run: `cd /Users/lakhansamani/projects/authorizer/authorizer && go build ./...` +Expected: builds clean. + +- [ ] **Step 8: Manual local verification** + +Log in as `lakhan@yopmail.com` (Task 3's curl), capture the `access_token`, then: + +```bash +curl -s http://localhost:8080/graphql -X POST \ + -H 'Content-Type: application/json' \ + -H "Authorization: Bearer " \ + -d '{"query":"mutation { skip_mfa_setup { message } }"}' +``` + +Expected: `{"data":{"skip_mfa_setup":{"message":"MFA setup skipped"}}}`. Re-run the Task 3 login curl again: `should_offer_mfa_setup` should now be false/null (quiet login). + +- [ ] **Step 9: Commit** + +```bash +git add internal/graph/schema.graphqls internal/graph/model/models_gen.go internal/graph/generated/generated.go internal/graph/schema.resolvers.go internal/service/skip_mfa_setup.go internal/service/provider.go internal/graphql/skip_mfa_setup.go internal/graphql/provider.go +git commit -m "feat(mfa): add skip_mfa_setup mutation, blocked when MFA is enforced" +``` + +--- + +## SDK (`authorizer-js` repo) + +### Task 5: Expose the new response fields and mutation in the TypeScript client + +**Files:** +- Modify: `/Users/lakhansamani/projects/authorizer/authorizer-js/src/types.ts` (`AuthResponse`, `User`) +- Modify: `/Users/lakhansamani/projects/authorizer/authorizer-js/src/index.ts` (new `skipMfaSetup` method) +- Modify: `/Users/lakhansamani/projects/authorizer/authorizer-js/package.json` (version bump) + +**Interfaces:** +- Consumes: backend fields/mutation from Tasks 1-4. +- Produces: `Types.AuthResponse.should_offer_mfa_setup: boolean | null`, `Types.User.has_skipped_mfa_setup_at: number | null`, `authorizer.skipMfaSetup(): Promise>` — consumed by `authorizer-react` Tasks 9-10. + +- [ ] **Step 1: Add the new fields to `types.ts`** + +Edit `/Users/lakhansamani/projects/authorizer/authorizer-js/src/types.ts`, in `interface User` (line 80-101), add after `is_multi_factor_auth_enabled: boolean | null;` (line 99): + +```typescript + is_multi_factor_auth_enabled: boolean | null; + has_skipped_mfa_setup_at: number | null; + app_data: Record | null; +``` + +In `interface AuthResponse` (line 135-148), add after `should_show_totp_screen: boolean | null;` (line 139): + +```typescript + should_show_totp_screen: boolean | null; + should_offer_mfa_setup: boolean | null; +``` + +- [ ] **Step 2: Add the `skipMfaSetup` method** + +Edit `/Users/lakhansamani/projects/authorizer/authorizer-js/src/index.ts`, add a new method mirroring `resendOtp` (line 595-618) — place it near `verifyOtp`/`resendOtp`: + +```typescript + skipMfaSetup = async (): Promise> => { + try { + const res = await this.dispatch( + 'skipMfaSetup', + ['graphql'], + { + query: 'mutation skip_mfa_setup { skip_mfa_setup { message } }', + operationName: 'skip_mfa_setup', + op: 'skip_mfa_setup', + }, + { method: 'POST', path: '/v1/skip_mfa_setup', body: {} }, + {}, + ); + + return res?.errors?.length + ? this.errorResponse(res.errors) + : this.okResponse(res.data); + } catch (err) { + return this.errorResponse([err]); + } + }; +``` + +Note: this mutation is authenticated via cookie/bearer token already attached by `dispatch` for other authenticated calls (check how `deactivateAccount` — if present — sends auth; mirror that exactly rather than `resendOtp`'s unauthenticated pattern if they differ). + +- [ ] **Step 3: Build and bump version** + +Run: `cd /Users/lakhansamani/projects/authorizer/authorizer-js && npm run build` +Expected: builds clean, `lib/index.js` and `lib/index.d.ts` regenerated with the new field/method. + +Edit `package.json`: bump `"version"` from `3.3.0-rc.1` to `3.4.0-rc.0` (new feature, prerelease — matches the existing prerelease convention this repo already uses for `authorizer-react` integration, per this session's earlier `3.3.0-rc.1` bump). + +- [ ] **Step 4: Commit** + +```bash +git add src/types.ts src/index.ts package.json lib/ +git commit -m "feat: expose should_offer_mfa_setup, has_skipped_mfa_setup_at, and skipMfaSetup()" +``` + +--- + +## Frontend (`authorizer-react` repo) + +### Task 6: Point `authorizer-react` at the new local SDK build + +**Files:** +- Modify: `/Users/lakhansamani/projects/authorizer/authorizer-react/package.json` + +- [ ] **Step 1: Link the local SDK build** + +Run: `cd /Users/lakhansamani/projects/authorizer/authorizer-react && npm install /Users/lakhansamani/projects/authorizer/authorizer-js` + +This updates `package.json`'s `@authorizerdev/authorizer-js` dependency to the local `3.4.0-rc.0` build (same mechanism as the existing `3.3.0-rc.1` dependency from the passkey-autofill work). + +- [ ] **Step 2: Typecheck** + +Run: `cd /Users/lakhansamani/projects/authorizer/authorizer-react && npx tsc --noEmit -p tsconfig.json` +Expected: clean (no errors — this step just confirms the new SDK types are visible; nothing consumes them yet). + +- [ ] **Step 3: Commit** + +```bash +git add package.json package-lock.json +git commit -m "chore(deps): bump @authorizerdev/authorizer-js to 3.4.0-rc.0 (optional MFA setup)" +``` + +--- + +### Task 7: Wire `AuthorizerBasicAuthLogin` to log in immediately and offer MFA setup + +**Files:** +- Modify: `src/components/AuthorizerBasicAuthLogin.tsx` (the `onSubmit` TOTP branch, current lines ~102-118, and the render method) + +**Interfaces:** +- Consumes: `res.should_offer_mfa_setup`, `res.authenticator_scanner_image/secret/recovery_codes`, `res.access_token` from Task 5's `AuthResponse`. +- Produces: nothing new — `res` (with `should_offer_mfa_setup` and the `authenticator_*` fields already on it once Task 6 is done) continues to flow to the existing `onLogin(res)` callback unchanged. The host app reads `should_offer_mfa_setup` off the argument it already receives — no new component state, no new prop. This is why `AuthorizerMFASetup` deliberately lives outside the login flow (Task 8): `AuthorizerBasicAuthLogin` unmounts the moment `token` is set (the host app's own router swaps away from the login screen, e.g. `example/src/App.tsx`'s `if (token) return `), so any state held *inside* this component past that point would never render. Surfacing the offer as data on `res`, for the host app to act on wherever it renders next, sidesteps that entirely. + +- [ ] **Step 1: Replace the forced-TOTP branch with the gate-aware branch** + +In `src/components/AuthorizerBasicAuthLogin.tsx`, replace the block currently at (post-earlier-session-edits) roughly lines 101-118: + +```typescript + // if totp is enabled for the first time show totp screen with scanner + if ( + res && + res.should_show_totp_screen && + res.authenticator_scanner_image && + res.authenticator_secret && + res.authenticator_recovery_codes + ) { + setTotpData({ + is_screen_visible: true, + email: data.email || ``, + phone_number: data.phone_number || ``, + authenticator_scanner_image: res.authenticator_scanner_image, + authenticator_secret: res.authenticator_secret, + authenticator_recovery_codes: res.authenticator_recovery_codes, + }); + return; + } + if ( + res && + (res?.should_show_email_otp_screen || + res?.should_show_mobile_otp_screen || + res?.should_show_totp_screen) + ) { + setOtpData({ + is_screen_visible: true, + email: data.email || ``, + phone_number: data.phone_number || ``, + is_totp: res?.should_show_totp_screen || false, + }); + return; + } + + if (res) { + setError(``); + setAuthData({ + user: res.user || null, + token: res, + config, + loading: false, + }); + } + + if (onLogin) { + onLogin(res); + } +``` + +with: + +```typescript + // res.access_token is only absent when MFA is enforced/blocking (see + // resolveMFAGate in the backend) — in every other case, including the + // optional-setup-offer case, the user is already logged in. + if (res && !res.access_token) { + if ( + res.should_show_totp_screen && + res.authenticator_scanner_image && + res.authenticator_secret && + res.authenticator_recovery_codes + ) { + setTotpData({ + is_screen_visible: true, + email: data.email || ``, + phone_number: data.phone_number || ``, + authenticator_scanner_image: res.authenticator_scanner_image, + authenticator_secret: res.authenticator_secret, + authenticator_recovery_codes: res.authenticator_recovery_codes, + }); + return; + } + if ( + res.should_show_email_otp_screen || + res.should_show_mobile_otp_screen || + res.should_show_totp_screen + ) { + setOtpData({ + is_screen_visible: true, + email: data.email || ``, + phone_number: data.phone_number || ``, + is_totp: res.should_show_totp_screen || false, + }); + return; + } + } + + if (res) { + setError(``); + setAuthData({ + user: res.user || null, + token: res, + config, + loading: false, + }); + } + + if (onLogin) { + onLogin(res); + } +``` + +Nothing else in this component changes — `res` (now carrying `should_offer_mfa_setup` and the `authenticator_*` fields per Task 6's type update) is already passed to `onLogin` unmodified by the existing `if (onLogin) { onLogin(res); }` call. + +- [ ] **Step 2: Typecheck** + +Run: `cd /Users/lakhansamani/projects/authorizer/authorizer-react && npx tsc --noEmit -p tsconfig.json` +Expected: clean. + +- [ ] **Step 3: Commit** + +```bash +git add src/components/AuthorizerBasicAuthLogin.tsx +git commit -m "feat(mfa): log in immediately and surface should_offer_mfa_setup instead of blocking on optional TOTP setup" +``` + +--- + +### Task 8: Add a working Skip button to `AuthorizerMFASetup` and document the host-app integration + +**Files:** +- Modify: `src/components/AuthorizerMFASetup.tsx` +- Modify: `example/src/pages/login.tsx` (demonstrate the new `onLogin` shape) and `example/src/pages/dashboard.tsx` (add a "Set up MFA" prompt, sourced from AuthorizerMFASetup — this is how a host app is expected to use it) + +**Interfaces:** +- Consumes: `authorizerRef.skipMfaSetup()` from Task 5. +- Produces: `AuthorizerMFASetup` gains an `onSkip?: () => void` prop and a "Skip for now" link in the method-list view. + +- [ ] **Step 1: Add the Skip action to the component** + +In `src/components/AuthorizerMFASetup.tsx`, add `onSkip` to the props type (near `onSetupMethod`, line 67): + +```typescript + // Fired when a method is chosen that this component can't complete on its + // own (email/SMS OTP, or TOTP without an enrolment payload). + onSetupMethod?: (method: MfaMethod) => void; + // Fired when the user explicitly declines to set up MFA right now. Absent + // when MFA is organization-enforced — the host app must not render this + // component with onSkip set in that case (check config before rendering). + onSkip?: () => void; + heading?: string; +``` + +Add it to the destructured props (line 69-74) and render a skip link at the bottom of the method-list view (after the `
    ` block, before the closing `` around line 214-216): + +```typescript + )} + {onSkip && ( + + )} + + ); +}; +``` + +- [ ] **Step 2: Wire an example usage in the demo app** + +`AuthorizerMFASetup` is a standalone hub the *host app* renders wherever it wants (a settings page, a post-login interstitial) — it is intentionally NOT auto-rendered inside the login flow (confirmed in this session: its `availableMfaMethods`/`totpEnrollment` props require the host to already have this data, which now comes from `res.should_offer_mfa_setup` + `res.authenticator_*` on the `onLogin` callback argument, per Task 7). + +Edit `example/src/pages/login.tsx` to capture the offer: + +```typescript +import * as React from 'react'; +import { useNavigate } from 'react-router-dom'; +import { Authorizer } from 'authorizer-react'; + +const Login: React.FC = () => { + const navigate = useNavigate(); + return ( + <> +

    Welcome to Authorizer

    +
    + { + if (loginData?.should_offer_mfa_setup) { + sessionStorage.setItem( + 'mfaSetupOffer', + JSON.stringify({ + authenticator_scanner_image: loginData.authenticator_scanner_image, + authenticator_secret: loginData.authenticator_secret, + authenticator_recovery_codes: loginData.authenticator_recovery_codes, + }) + ); + } + }} + /> + + ); +}; + +export default Login; +``` + +Edit `example/src/pages/dashboard.tsx` to render the offer once, using `AuthorizerMFASetup` + `useAuthorizer()`'s `authorizerRef.skipMfaSetup()`: + +```typescript +import * as React from 'react'; +import { AuthorizerMFASetup, useAuthorizer } from 'authorizer-react'; + +const Dashboard: React.FC = () => { + const { authorizerRef, logout } = useAuthorizer(); + const [offer, setOffer] = React.useState(() => { + const raw = sessionStorage.getItem('mfaSetupOffer'); + return raw ? JSON.parse(raw) : null; + }); + + const dismiss = () => { + sessionStorage.removeItem('mfaSetupOffer'); + setOffer(null); + }; + + if (offer) { + return ( + { + await authorizerRef.skipMfaSetup(); + dismiss(); + }} + /> + ); + } + + return ( +
    +

    Dashboard

    + +
    + ); +}; + +export default Dashboard; +``` + +- [ ] **Step 3: Typecheck and build** + +Run: `cd /Users/lakhansamani/projects/authorizer/authorizer-react && npx tsc --noEmit -p tsconfig.json && npm run build` +Expected: clean. + +Run: `cd /Users/lakhansamani/projects/authorizer/authorizer-react/example && npx tsc --noEmit -p tsconfig.json` +Expected: clean. + +- [ ] **Step 4: End-to-end local verification** + +With the recipe server running and `lakhan@yopmail.com`'s `HasSkippedMFASetupAt` cleared (fresh state — re-run Task 3's curl to confirm `should_offer_mfa_setup: true` server-side first), start the example app (`npm start` in `example/`) and log in as `lakhan@yopmail.com` / `Test@123#` through the actual UI: + +Expected: immediate login to the Dashboard (no forced TOTP screen), followed by the `AuthorizerMFASetup` hub showing the TOTP QR with a working "Skip for now" link. Click Skip — expected: returns to the plain dashboard. Log out and log back in — expected: straight to dashboard, no MFA prompt (quiet, per `mfaGateSkippedSetup`). + +- [ ] **Step 5: Commit** + +```bash +git add src/components/AuthorizerMFASetup.tsx example/src/pages/login.tsx example/src/pages/dashboard.tsx +git commit -m "feat(mfa): add Skip action to AuthorizerMFASetup and demonstrate host-app wiring" +``` + +--- + +## Deferred Work (documented, not built in this plan) + +**Passkey-as-MFA (second-factor WebAuthn, distinct from today's passkey-as-primary-login).** Confirmed absent from the backend entirely: no `EnvKeyWebauthnAuthenticator` constant (only `EnvKeyTOTPAuthenticator` exists in `internal/constants/authenticator_method.go`), no enrollment-during-MFA-gate logic. This is a legitimate MFA pattern (Okta and Auth0 both support "security key/biometric" as a second factor, distinct from passwordless primary login) but is its own project: new authenticator-type constant, WebAuthn ceremony wiring inside `resolveMFAGate`'s `mfaGateBlockEnroll`/`mfaGateOfferSetup` cases, and a fourth option in `AuthorizerMFASetup`'s method list (which already has the UI scaffolding — `availableMfaMethods.passkey` — just no backend behind it for the MFA case). Should be its own plan once this one has shipped and been used for a while. + +**Email/SMS OTP "not yet enrolled" parity.** Unlike TOTP, email and SMS OTP have no persistent enrollment artifact to pre-register — each login simply sends a fresh code. There is no equivalent to "scan this QR once" for these methods, so the enforced/optional/skip distinction this plan builds for TOTP doesn't map onto them the same way. The top-level "does this user have MFA turned on at all" decision (Tasks 3-4) already covers email/SMS OTP correctly; per-method "enrollment" simply isn't a concept that applies to them. No further work needed here unless a future requirement specifically calls for it. From 38eaa6628c827f8a2dea2b58077636600ccf0358 Mon Sep 17 00:00:00 2001 From: Lakhan Samani Date: Tue, 14 Jul 2026 11:07:03 +0530 Subject: [PATCH 08/20] docs: add passkey-as-MFA design spec Covers wiring WebAuthn credentials into resolveMFAGate as a second verified factor, reusing the already-shipped scoped webauthn login flow and AuthorizerMFASetup enrollment hub instead of building new mutations. Forced-enrollment-via-passkey and delete-your-only-factor guard are recorded as deferred, mirroring the prior optional-MFA plan. --- .../specs/2026-07-14-passkey-as-mfa-design.md | 89 +++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-14-passkey-as-mfa-design.md diff --git a/docs/superpowers/specs/2026-07-14-passkey-as-mfa-design.md b/docs/superpowers/specs/2026-07-14-passkey-as-mfa-design.md new file mode 100644 index 0000000..f4c8c05 --- /dev/null +++ b/docs/superpowers/specs/2026-07-14-passkey-as-mfa-design.md @@ -0,0 +1,89 @@ +# Passkey as a Second MFA Factor — Design + +**Status:** Approved, ready for implementation planning. + +**Goal:** Let a user who already has a registered passkey use it to satisfy an MFA challenge — instead of, or alongside, TOTP — when logging in with email + password. Passkey continues to also work as today's separate passwordless *primary* login method; this plan does not touch that. + +**Relationship to prior work:** This is the "Passkey-as-MFA" item explicitly deferred at the end of `docs/superpowers/plans/2026-07-13-optional-mfa-with-skip.md`, which shipped optional TOTP-based MFA with a skip action. That plan's `resolveMFAGate` decision function, `has_skipped_mfa_setup_at` field, and MFA-session-cookie mechanism are reused as-is here. + +**Architecture, in one sentence:** almost everything needed already exists — `webauthn_login_options`(scoped by email) + `webauthn_login_verify` already run as an MFA-session-gated "prove your passkey" ceremony, and `AuthorizerMFASetup`'s Passkey tile already enrolls credentials post-login — so this plan is mostly *wiring the MFA gate to recognize a WebAuthn credential as a verified factor* and *exposing one new response flag* to tell the frontend which verify option(s) to render. + +## Credential scoping decision + +No "purpose" field on `WebauthnCredential`. Any registered passkey — whether the user registered it for passwordless primary login or explicitly for MFA — satisfies the passkey-MFA factor. This is deliberately simple because the passkey-primary-login feature itself is still pre-release (no installed base to break by changing its semantics). If that assumption stops holding after release, a `purpose` field can be added later without disturbing this plan's gate logic (`authenticatorVerified` would just become `hasWebauthnCredential(purpose=mfa)`). + +## Backend (`authorizer` repo) + +### Task: Broaden the MFA branch guard and wire WebAuthn into `resolveMFAGate` + +**Files:** +- Modify: `internal/service/login.go` (the MFA branch — currently guarded by `isMFAEnabled && isTOTPLoginEnabled`, and the TOTP-specific `authenticatorVerified` computation) +- Modify: `internal/graph/schema.graphqls` (`AuthResponse` — new field) +- Modify: `internal/graph/model/models_gen.go` (regenerated) + +**Changes:** + +1. Guard: change `if isMFAEnabled && isTOTPLoginEnabled {` to also enter when WebAuthn MFA is available: `if isMFAEnabled && (isTOTPLoginEnabled || isWebauthnEnabled) {`. `isWebauthnEnabled` mirrors the existing config flag the `meta` query already exposes as `is_webauthn_enabled` — reuse it, do not invent a second flag. This lets a server configured with *only* WebAuthn MFA (no TOTP) still reach the gate. +2. `authenticatorVerified` computation: currently `authErr == nil && authenticator != nil && authenticator.VerifiedAt != nil` (TOTP only). Change to: + ```go + totpVerified := authErr == nil && authenticator != nil && authenticator.VerifiedAt != nil + webauthnCreds, _ := p.StorageProvider.ListWebauthnCredentialsByUserID(ctx, user.ID) + hasWebauthnCredential := len(webauthnCreds) > 0 + authenticatorVerified := totpVerified || hasWebauthnCredential + ``` + (Ignore the WebAuthn list-credentials error rather than failing login on it — treat "couldn't check" as "no credential found," matching how a fresh/never-enrolled user is handled today. Same fail-open-to-"not verified" posture as a missing TOTP authenticator row.) +3. `resolveMFAGate` itself is unchanged — it already takes a single `authenticatorVerified` bool and doesn't care which factor produced it. +4. On the `mfaGateBlockVerify` case (today: unconditionally `ShouldShowTotpScreen: true`), branch by which factor(s) the user actually has, so a passkey-only user isn't forced through a TOTP screen they never set up, and a dual-enrolled user is offered a choice: + ```go + case mfaGateBlockVerify: + expiresAt := time.Now().Add(3 * time.Minute).Unix() + if err := setOTPMFaSession(expiresAt); err != nil { ... } + res := &model.AuthResponse{Message: `Proceed to mfa verification`} + if totpVerified { + res.ShouldShowTotpScreen = refs.NewBoolRef(true) + } + if hasWebauthnCredential { + res.ShouldOfferWebauthnMfaVerify = refs.NewBoolRef(true) + } + return res, side, nil + ``` +5. `mfaGateBlockEnroll` (org-*enforced*, first-time, no factor yet at all) is **unchanged** — stays TOTP-only. See "Deferred" below. +6. `mfaGateOfferSetup` / `mfaGateSkippedSetup` are **unchanged** — no code here even knows about WebAuthn, because enrollment for these two states happens later, post-token, through the already-shipped `AuthorizerMFASetup` hub. +7. New `AuthResponse` field, added the same way `should_show_totp_screen` was: + ```graphql + should_show_totp_screen: Boolean + # should_offer_webauthn_mfa_verify is true when the user has a registered + # passkey and MFA verification (not enrollment) is required at login. The + # frontend should offer a "verify with your passkey" action in addition to + # (or instead of) should_show_totp_screen's code-entry form. + should_offer_webauthn_mfa_verify: Boolean + ``` + +**Testing:** extend the existing TOTP branch's integration test coverage with cases for: WebAuthn-only user (no TOTP) reaching `mfaGateBlockVerify` and getting only `should_offer_webauthn_mfa_verify`; a dual-enrolled user getting both flags; a server with only WebAuthn MFA configured (no TOTP) still entering the branch. + +## SDK (`authorizer-js` repo) + +### Task: Expose the new field + +**Files:** `src/types.ts` (`AuthResponse` interface — add `should_offer_webauthn_mfa_verify: boolean | null;` next to `should_show_totp_screen`). + +No new methods. `loginWithPasskey(email)` (already shipped, drives `webauthn_login_options` → browser ceremony → `webauthn_login_verify`) is reused verbatim for the verify step — passing the user's email is what makes the backend treat it as the scoped/MFA flow instead of a discoverable primary login. + +## Frontend (`authorizer-react` repo) + +### Task: Offer "verify with passkey" during MFA challenge + +**Files:** +- `src/components/AuthorizerBasicAuthLogin.tsx` — the `res && !res.access_token` branch that currently checks `should_show_totp_screen`/`should_show_email_otp_screen`/`should_show_mobile_otp_screen` gains a check for `should_offer_webauthn_mfa_verify`, routing to `AuthorizerVerifyOtp` with a new prop (e.g. `offerWebauthnVerify`) instead of (or alongside) the existing OTP-screen routing. +- `src/components/AuthorizerVerifyOtp.tsx` — when `offerWebauthnVerify` is true, render a "Verify with your passkey" button above/alongside the code-entry form (hide the form entirely when the user has *only* a passkey and no TOTP, i.e. `should_show_totp_screen` was false). Clicking it calls `authorizerRef.loginWithPasskey(email)` directly and, on success, follows the exact same `setAuthData` + `onLogin(res)` path the form's `onSubmit` already uses. +- No changes needed to `AuthorizerMFASetup.tsx` (enrollment already works) or `AuthorizerPasskeyRegister.tsx`. + +**Error handling:** a cancelled/failed passkey ceremony (`NotAllowedError`/`AbortError`, same as `AuthorizerPasskeyLogin`'s existing handling) should silently return the user to the code-entry form when one is available (dual-enrolled case), or show a dismissible message when it's the only option (passkey-only case) — never a raw browser exception. + +**Testing:** one assert-based smoke check that `should_offer_webauthn_mfa_verify: true` on the login response results in the passkey verify affordance being offered (component-level), matching the existing pattern for `should_show_totp_screen`. + +## Deferred (explicitly out of scope) + +- **Passkey enrollment during org-*enforced* first-time MFA** (`mfaGateBlockEnroll`, no factor enrolled yet, no token issued yet). Today's `webauthn_registration_options`/`_verify` mutations require a real access token (`callerTokenData`), which doesn't exist at this point — only an MFA-session cookie does. Offering passkey here would need new MFA-session-gated (not bearer-token-gated) registration mutations, a materially bigger and higher-risk change (unauthenticated-but-scoped credential registration). TOTP remains the only forced-enrollment path until this is tackled as its own plan. +- **Blocking/warning when deleting your only remaining verified MFA factor** (e.g. `WebauthnDeleteCredential` leaving a user with zero verified factors while MFA is enabled for them). Not handled for TOTP today either; left as future work. +- **`AvailableMfaMethods.passkey` sourced from a real backend capability field** instead of a host-app-supplied prop. Pre-existing gap in `AuthorizerMFASetup`, orthogonal to this plan. From 0bd503ad1fd1141e2cf27e0e8a9ac525c6545680 Mon Sep 17 00:00:00 2001 From: Lakhan Samani Date: Tue, 14 Jul 2026 11:17:26 +0530 Subject: [PATCH 09/20] fix(spec): keep MFA branch guard TOTP-gated, drop bad webauthn OR-arm is_webauthn_enabled is hardcoded true (no operator toggle), so OR-ing it into the branch guard would have fired mfaGateBlockEnroll's unconditional TOTP-secret generation even on servers with TOTP disabled. Scope passkey-as-MFA to servers that already have TOTP MFA enabled instead; standalone passkey-only MFA stays deferred. --- docs/superpowers/specs/2026-07-14-passkey-as-mfa-design.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/specs/2026-07-14-passkey-as-mfa-design.md b/docs/superpowers/specs/2026-07-14-passkey-as-mfa-design.md index f4c8c05..ed08f74 100644 --- a/docs/superpowers/specs/2026-07-14-passkey-as-mfa-design.md +++ b/docs/superpowers/specs/2026-07-14-passkey-as-mfa-design.md @@ -23,7 +23,7 @@ No "purpose" field on `WebauthnCredential`. Any registered passkey — whether t **Changes:** -1. Guard: change `if isMFAEnabled && isTOTPLoginEnabled {` to also enter when WebAuthn MFA is available: `if isMFAEnabled && (isTOTPLoginEnabled || isWebauthnEnabled) {`. `isWebauthnEnabled` mirrors the existing config flag the `meta` query already exposes as `is_webauthn_enabled` — reuse it, do not invent a second flag. This lets a server configured with *only* WebAuthn MFA (no TOTP) still reach the gate. +1. Guard: **unchanged** — stays `if isMFAEnabled && isTOTPLoginEnabled {`. `is_webauthn_enabled` in the GraphQL schema is hardcoded `true` (no operator toggle — see `schema.graphqls:44`'s comment), so it can't be used as a second `||` arm the way `isTOTPLoginEnabled` is: doing so would make this branch (and `mfaGateBlockEnroll`'s unconditional TOTP-secret generation, see below) fire even on servers where TOTP is deliberately disabled. Passkey-as-MFA in this plan is therefore scoped to *servers that already have TOTP MFA enabled* — it adds passkey as an alternative verify method within that existing umbrella, not a standalone way to turn MFA on. See "Deferred" for the TOTP-independent case. 2. `authenticatorVerified` computation: currently `authErr == nil && authenticator != nil && authenticator.VerifiedAt != nil` (TOTP only). Change to: ```go totpVerified := authErr == nil && authenticator != nil && authenticator.VerifiedAt != nil @@ -59,7 +59,7 @@ No "purpose" field on `WebauthnCredential`. Any registered passkey — whether t should_offer_webauthn_mfa_verify: Boolean ``` -**Testing:** extend the existing TOTP branch's integration test coverage with cases for: WebAuthn-only user (no TOTP) reaching `mfaGateBlockVerify` and getting only `should_offer_webauthn_mfa_verify`; a dual-enrolled user getting both flags; a server with only WebAuthn MFA configured (no TOTP) still entering the branch. +**Testing:** extend the existing TOTP branch's integration test coverage with cases for: a user who enrolled only a passkey (no verified TOTP) reaching `mfaGateBlockVerify` and getting only `should_offer_webauthn_mfa_verify`; a dual-enrolled user getting both flags; confirm a server with `isTOTPLoginEnabled=false` still skips this whole branch unchanged (guard untouched). ## SDK (`authorizer-js` repo) From 62b40fa925bd99a4e19bd2bfa32d23475b745474 Mon Sep 17 00:00:00 2001 From: Lakhan Samani Date: Tue, 14 Jul 2026 11:21:57 +0530 Subject: [PATCH 10/20] docs: add passkey-as-MFA implementation plan 5 tasks across authorizer, authorizer-js, authorizer-react: wire WebAuthn credentials into resolveMFAGate's verify path, expose the new AuthResponse flag through the SDK, and offer a passkey-verify button alongside the existing TOTP code form. --- .../plans/2026-07-14-passkey-as-mfa.md | 595 ++++++++++++++++++ 1 file changed, 595 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-14-passkey-as-mfa.md diff --git a/docs/superpowers/plans/2026-07-14-passkey-as-mfa.md b/docs/superpowers/plans/2026-07-14-passkey-as-mfa.md new file mode 100644 index 0000000..baaf4a2 --- /dev/null +++ b/docs/superpowers/plans/2026-07-14-passkey-as-mfa.md @@ -0,0 +1,595 @@ +# Passkey as a Second MFA Factor Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** A user who has registered a passkey can use it to satisfy an MFA challenge at login (email + password), as an alternative to entering a TOTP code, without changing today's separate passwordless-primary-login passkey flow. + +**Architecture:** `resolveMFAGate`'s `authenticatorVerified` input becomes "TOTP verified OR has a registered WebAuthn credential." The already-shipped, MFA-session-cookie-gated "scoped" `webauthn_login_options(email)` + `webauthn_login_verify` mutations (`internal/service/webauthn.go`) do the actual verification — no new backend mutation is needed. One new `AuthResponse` boolean (`should_offer_webauthn_mfa_verify`) tells the frontend a passkey option exists; the frontend calls the already-shipped SDK method `authorizerRef.loginWithPasskey(email)` to complete it. + +**Tech Stack:** Go 1.x / gqlgen / GORM (SQL auto-migrates; no new schema field in this plan, so no per-backend migration work) for `authorizer`; TypeScript for `authorizer-js`; React + TypeScript for `authorizer-react`. + +**Spec:** `docs/superpowers/specs/2026-07-14-passkey-as-mfa-design.md` + +## Global Constraints + +- **Base branch is `feat/optional-mfa-setup`, not `main`, in all three repos.** This plan's `authenticatorVerified` computation reads `resolveMFAGate`, `HasSkippedMFASetupAt`, and the `mfaGateBlockVerify` case — none of which exist on `main` yet in any of the three repos as of this writing. Branch `feat/passkey-mfa` from the tip of each repo's `feat/optional-mfa-setup` work (worktree paths: `authorizer/.worktrees/feat/optional-mfa-setup`, `authorizer-js/.worktrees/feat/optional-mfa-setup`, `authorizer-react/.worktrees/feat/optional-mfa-setup`). +- **The MFA branch guard in `login.go` (`if isMFAEnabled && isTOTPLoginEnabled`) is NOT changed.** `is_webauthn_enabled` is a hardcoded-`true` GraphQL field, not an operator config toggle — OR-ing it into the guard would make `mfaGateBlockEnroll`'s unconditional TOTP-secret generation fire even on servers with TOTP disabled. Passkey-as-MFA in this plan only becomes available on servers that already have TOTP MFA enabled (`EnableMFA && EnableTOTPLogin`); it adds passkey as an alternative *verify* method within that existing umbrella. +- **No `purpose` field on `WebauthnCredential`.** Any registered passkey — however it was registered — satisfies the passkey-MFA factor. `authenticatorVerified` (passkey) = `len(ListWebauthnCredentialsByUserID(userID)) > 0`, full stop. +- **`mfaGateBlockEnroll` (org-enforced, zero factors enrolled yet) is untouched.** Passkey enrollment in that pre-token state needs new MFA-session-gated registration mutations (today's `webauthn_registration_options`/`_verify` require a real access token) — out of scope for this plan. +- **No new frontend test framework.** This repo's `"test"` script is `tsc --noEmit` — there is no Jest/RTL component-test setup. "Testing" for `authorizer-react` tasks means: library `tsc --noEmit`, library `npm run build`, and (where noted) the `example/` app's `tsc --noEmit`. Do not add a test runner as part of this plan. + +--- + +## Backend (`authorizer` repo) + +### Task 1: Wire WebAuthn credentials into the MFA gate's verify path + +**Files:** +- Modify: `internal/graph/schema.graphqls:116-131` (`AuthResponse` type — new field) +- Modify: `internal/graph/model/models_gen.go` (regenerated) +- Modify: `internal/service/login.go:377-430` (the TOTP/MFA branch) +- Test: `internal/integration_tests/mfa_gate_login_test.go` (extend `TestLoginMFAGateTokenWithholding`) + +**Interfaces:** +- Consumes: `resolveMFAGate` (unchanged signature, from the already-shipped `internal/service/mfa_gate.go`), `p.StorageProvider.ListWebauthnCredentialsByUserID(ctx, userID) ([]*schemas.WebauthnCredential, error)` (already shipped, `internal/storage/provider.go:254`). +- Produces: `model.AuthResponse.ShouldOfferWebauthnMfaVerify *bool` — true on the `mfaGateBlockVerify` response when the user has ≥1 registered WebAuthn credential. Consumed by `authorizer-js` Task 2 and `authorizer-react` Tasks 4-5. + +- [ ] **Step 1: Add the field to the GraphQL schema** + +Edit `internal/graph/schema.graphqls`, inside `type AuthResponse {` (line 116-131), add after `should_show_totp_screen: Boolean` (line 120): + +```graphql + should_show_totp_screen: Boolean + # should_offer_webauthn_mfa_verify is true when the authenticated-with- + # password user has a registered passkey and MFA verification (not + # enrollment) is required before a token is issued. The frontend should + # offer a "verify with your passkey" action — calling the existing scoped + # 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 +``` + +Regenerate: `cd /Users/lakhansamani/projects/authorizer/authorizer && go run github.com/99designs/gqlgen --verbose generate && go mod tidy` + +Confirm: `grep -n "ShouldOfferWebauthnMfaVerify" internal/graph/model/models_gen.go` — expect a `*bool` field with `should_offer_webauthn_mfa_verify,omitempty` json tag. + +- [ ] **Step 2: Write the failing integration tests** + +Edit `internal/integration_tests/mfa_gate_login_test.go`. Add a helper next to `addVerifiedAuthenticator` (after line 64): + +```go + // addWebauthnCredential gives the user a registered passkey, the + // condition login.go reads (via ListWebauthnCredentialsByUserID) as an + // alternative authenticatorVerified=true source alongside TOTP. + addWebauthnCredential := func(t *testing.T, ts *testSetup, ctx context.Context, userID string) { + t.Helper() + _, err := ts.StorageProvider.AddWebauthnCredential(ctx, &schemas.WebauthnCredential{ + UserID: userID, + CredentialID: uuid.NewString(), + PublicKey: "dummy-public-key-for-gate-test", + }) + require.NoError(t, err) + } +``` + +Add two new subtests inside `TestLoginMFAGateTokenWithholding`, after the `"mfaGateBlockVerify withholds the token"` subtest (after line 84): + +```go + t.Run("mfaGateBlockVerify offers passkey verify for a passkey-only user", func(t *testing.T) { + cfg := getTestConfig() + cfg.EnableMFA = true + cfg.EnableTOTPLogin = 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) + addWebauthnCredential(t, ts, ctx, user.ID) + // No TOTP authenticator enrolled — passkey is this user's only factor. + + 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 user with a registered passkey must not receive a token before verifying it") + assert.False(t, refs.BoolValue(res.ShouldShowTotpScreen), "must not force a TOTP screen on a user who never enrolled TOTP") + assert.True(t, refs.BoolValue(res.ShouldOfferWebauthnMfaVerify)) + }) + + t.Run("mfaGateBlockVerify offers both methods for a dual-enrolled user", func(t *testing.T) { + cfg := getTestConfig() + cfg.EnableMFA = true + cfg.EnableTOTPLogin = 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) + addVerifiedAuthenticator(t, ts, ctx, user.ID) + addWebauthnCredential(t, ts, ctx, user.ID) + + 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) + assert.True(t, refs.BoolValue(res.ShouldShowTotpScreen)) + assert.True(t, refs.BoolValue(res.ShouldOfferWebauthnMfaVerify)) + }) +``` + +Also extend the existing `"mfaGateBlockVerify withholds the token"` subtest (lines 66-84) with one more assertion right after `assert.True(t, refs.BoolValue(res.ShouldShowTotpScreen))` (line 83): + +```go + assert.False(t, refs.BoolValue(res.ShouldOfferWebauthnMfaVerify), "a TOTP-only user must not be offered a passkey verify option they never registered") +``` + +- [ ] **Step 3: Run the tests to verify they fail** + +Run: `cd /Users/lakhansamani/projects/authorizer/authorizer && go test ./internal/integration_tests/ -run TestLoginMFAGateTokenWithholding -v` +Expected: the two new subtests FAIL (`ShouldOfferWebauthnMfaVerify` is always nil/false because `login.go` doesn't set it yet); the extended assertion in the first subtest passes trivially (field is already nil). + +- [ ] **Step 4: Implement the gate wiring in `login.go`** + +Edit `internal/service/login.go`, replace lines 378-397 (the branch guard through the end of `case mfaGateBlockVerify:`): + +```go + if isMFAEnabled && isTOTPLoginEnabled { + authenticator, authErr := p.StorageProvider.GetAuthenticatorDetailsByUserId(ctx, user.ID, constants.EnvKeyTOTPAuthenticator) + totpVerified := authErr == nil && authenticator != nil && authenticator.VerifiedAt != nil + // A WebAuthn credential registered for ANY purpose (passwordless + // primary login or explicit MFA setup — there is no `purpose` field) + // counts as a verified second factor. Ignore a list error rather than + // failing login on it: treat "couldn't check" the same as "found + // none," matching how a missing TOTP authenticator row is handled. + webauthnCreds, _ := p.StorageProvider.ListWebauthnCredentialsByUserID(ctx, user.ID) + hasWebauthnCredential := len(webauthnCreds) > 0 + authenticatorVerified := totpVerified || hasWebauthnCredential + gate := resolveMFAGate( + refs.BoolValue(user.IsMultiFactorAuthEnabled), + p.Config.EnforceMFA, + authenticatorVerified, + user.HasSkippedMFASetupAt != nil, + ) + switch gate { + case mfaGateBlockVerify: + expiresAt := time.Now().Add(3 * time.Minute).Unix() + if err := setOTPMFaSession(expiresAt); err != nil { + log.Debug().Msg("Failed to set mfa session") + return nil, nil, err + } + res := &model.AuthResponse{Message: `Proceed to mfa verification`} + if totpVerified { + res.ShouldShowTotpScreen = refs.NewBoolRef(true) + } + if hasWebauthnCredential { + res.ShouldOfferWebauthnMfaVerify = refs.NewBoolRef(true) + } + return res, side, nil +``` + +Leave `case mfaGateBlockEnroll:` (originally starting at line 398) and everything after it exactly as-is — only the code above it changes. + +- [ ] **Step 5: Run the tests to verify they pass** + +Run: `cd /Users/lakhansamani/projects/authorizer/authorizer && go test ./internal/integration_tests/ -run TestLoginMFAGateTokenWithholding -v` +Expected: PASS, all 7 subtests (5 original + 2 new). + +- [ ] **Step 6: Build and vet the whole repo** + +Run: `cd /Users/lakhansamani/projects/authorizer/authorizer && go build ./... && go vet ./... && go test ./internal/service/ -run TestResolveMFAGate -v` +Expected: builds clean, vet clean, `TestResolveMFAGate`'s 9 subtests still pass unchanged (that function itself was not touched). + +- [ ] **Step 7: Commit** + +```bash +git add internal/graph/schema.graphqls internal/graph/model/models_gen.go internal/graph/generated/generated.go internal/service/login.go internal/integration_tests/mfa_gate_login_test.go +git commit -m "feat(mfa): let a registered passkey satisfy MFA verification" +``` + +--- + +## SDK (`authorizer-js` repo) + +### Task 2: Expose the new response field + +**Files:** +- Modify: `/Users/lakhansamani/projects/authorizer/authorizer-js/src/types.ts` (`AuthResponse` interface) +- Modify: `/Users/lakhansamani/projects/authorizer/authorizer-js/package.json` (version bump) + +**Interfaces:** +- Consumes: `should_offer_webauthn_mfa_verify` from Task 1's `AuthResponse`. +- Produces: `Types.AuthResponse.should_offer_webauthn_mfa_verify: boolean | null` — consumed by `authorizer-react` Tasks 4-5. `loginWithPasskey(email?, opts?)` (already shipped, `src/index.ts:782`) needs no changes — it already accepts an `email` to scope the ceremony to one account, which is exactly the MFA-verify usage this plan needs. + +- [ ] **Step 1: Add the field to `types.ts`** + +Edit `/Users/lakhansamani/projects/authorizer/authorizer-js/src/types.ts`, in `interface AuthResponse`, add after `should_show_totp_screen: boolean | null;` (line 140): + +```typescript + should_show_totp_screen: boolean | null; + should_offer_webauthn_mfa_verify: boolean | null; +``` + +- [ ] **Step 2: Build and bump version** + +Run: `cd /Users/lakhansamani/projects/authorizer/authorizer-js && npm run build` +Expected: builds clean, `lib/index.d.ts` regenerated with the new field. + +Edit `package.json`: bump `"version"` from `3.4.0-rc.0` to `3.5.0-rc.0` (new feature, matches this repo's existing prerelease convention). + +- [ ] **Step 3: Commit** + +```bash +git add src/types.ts package.json lib/ +git commit -m "feat: expose should_offer_webauthn_mfa_verify on AuthResponse" +``` + +--- + +## Frontend (`authorizer-react` repo) + +### Task 3: Point `authorizer-react` at the new local SDK build + +**Files:** +- Modify: `/Users/lakhansamani/projects/authorizer/authorizer-react/package.json` + +- [ ] **Step 1: Link the local SDK build** + +Run: `cd /Users/lakhansamani/projects/authorizer/authorizer-react && npm install /Users/lakhansamani/projects/authorizer/authorizer-js` + +This updates `package.json`'s `@authorizerdev/authorizer-js` dependency to the local `3.5.0-rc.0` build (same mechanism used for every prior local-SDK bump in this codebase). + +- [ ] **Step 2: Typecheck** + +Run: `cd /Users/lakhansamani/projects/authorizer/authorizer-react && npx tsc --noEmit -p tsconfig.json` +Expected: clean (confirms the new SDK type is visible; nothing consumes it yet). + +- [ ] **Step 3: Commit** + +```bash +git add package.json package-lock.json +git commit -m "chore(deps): bump @authorizerdev/authorizer-js to 3.5.0-rc.0 (passkey MFA)" +``` + +--- + +### Task 4: Route the passkey-verify offer from `AuthorizerBasicAuthLogin` to `AuthorizerVerifyOtp` + +**Files:** +- Modify: `src/types/index.ts:75-80` (`OtpDataType`) +- Modify: `src/components/AuthorizerBasicAuthLogin.tsx:121-133,252-265` + +**Interfaces:** +- Consumes: `res.should_offer_webauthn_mfa_verify` from Task 3's linked SDK. +- Produces: `OtpDataType.offer_webauthn_verify?: boolean`, threaded through to `AuthorizerVerifyOtp`'s new `offerWebauthnVerify` prop (consumed by Task 5). + +- [ ] **Step 1: Add the field to `OtpDataType`** + +Edit `src/types/index.ts`, in `OtpDataType` (lines 75-80), add after `is_totp?: boolean;`: + +```typescript +export type OtpDataType = { + is_screen_visible: boolean; + email?: string; + phone_number?: string; + is_totp?: boolean; + offer_webauthn_verify?: boolean; +}; +``` + +- [ ] **Step 2: Recognize the new flag in the login branch** + +Edit `src/components/AuthorizerBasicAuthLogin.tsx`, replace the second `if` block inside the `res && !res.access_token` guard (lines 121-133): + +```typescript + if ( + res.should_show_email_otp_screen || + res.should_show_mobile_otp_screen || + res.should_show_totp_screen || + res.should_offer_webauthn_mfa_verify + ) { + setOtpData({ + is_screen_visible: true, + email: data.email || ``, + phone_number: data.phone_number || ``, + is_totp: res.should_show_totp_screen || false, + offer_webauthn_verify: res.should_offer_webauthn_mfa_verify || false, + }); + return; + } +``` + +- [ ] **Step 3: Pass it through to `AuthorizerVerifyOtp`** + +Edit `src/components/AuthorizerBasicAuthLogin.tsx`, in the `otpData.is_screen_visible` render block (lines 252-265): + +```typescript + if (otpData.is_screen_visible) { + return ( + + ); + } +``` + +- [ ] **Step 4: Typecheck** + +Run: `cd /Users/lakhansamani/projects/authorizer/authorizer-react && npx tsc --noEmit -p tsconfig.json` +Expected: an error that `offerWebauthnVerify` is not a known prop on `AuthorizerVerifyOtp` — that's Task 5's job. This is expected and confirms the wiring compiles as far as it can before Task 5. + +- [ ] **Step 5: Commit** + +```bash +git add src/types/index.ts src/components/AuthorizerBasicAuthLogin.tsx +git commit -m "feat(mfa): thread should_offer_webauthn_mfa_verify to the OTP screen" +``` + +--- + +### Task 5: Offer "verify with your passkey" in `AuthorizerVerifyOtp` + +**Files:** +- Modify: `src/components/AuthorizerVerifyOtp.tsx` + +**Interfaces:** +- Consumes: `offerWebauthnVerify` prop from Task 4, `authorizerRef.loginWithPasskey(email)` (already shipped SDK method), `isWebauthnSupported()` and `IconPasskey` (already shipped/exported — `@authorizerdev/authorizer-js` and `../icons/mfa` respectively, both already used by `AuthorizerPasskeyLogin.tsx`/`AuthorizerMFASetup.tsx`). +- Produces: nothing new — `res` flows to `onLogin`/`setAuthData` exactly like the existing code-entry `onSubmit` does. + +- [ ] **Step 1: Add imports and the new prop** + +Edit `src/components/AuthorizerVerifyOtp.tsx`, add to the imports at the top (after line 2): + +```typescript +import { VerifyOTPRequest, isWebauthnSupported } from '@authorizerdev/authorizer-js'; +import '../styles/default.css'; + +import { ButtonAppearance, MessageType, Views } from '../constants'; +import { useAuthorizer } from '../contexts/AuthorizerContext'; +import { StyledButton, StyledFooter, StyledLink } from '../styledComponents'; +import { Message } from './Message'; +import { TotpDataType } from '../types'; +import { AuthorizerTOTPScanner } from './AuthorizerTOTPScanner'; +import { IconPasskey } from '../icons/mfa'; +``` + +Add `offerWebauthnVerify?: boolean;` to the component's prop type (line 25-32): + +```typescript +export const AuthorizerVerifyOtp: FC<{ + setView?: (v: Views) => void; + onLogin?: (data: any) => void; + email?: string; + phone_number?: string; + urlProps?: Record; + is_totp?: boolean; + offerWebauthnVerify?: boolean; +}> = ({ setView, onLogin, email, phone_number, urlProps, is_totp, offerWebauthnVerify }) => { +``` + +- [ ] **Step 2: Add passkey-verify state and handler** + +Inside the component body, after the existing `useEffect` that checks `email`/`phone_number` (after line 50), add: + +```typescript + const [webauthnError, setWebauthnError] = useState(``); + const [webauthnLoading, setWebauthnLoading] = useState(false); + const passkeySupported = isWebauthnSupported(); + + // A cancelled ceremony surfaces as NotAllowedError/AbortError (same + // browser behavior AuthorizerPasskeyLogin already handles) - dismiss + // silently and let the user fall back to the code form when one exists. + const isUserDismissed = (e?: { code?: string }): boolean => + e?.code === `NotAllowedError` || e?.code === `AbortError`; + + const onVerifyWithPasskey = async () => { + setWebauthnError(``); + try { + setWebauthnLoading(true); + const { data: res, errors } = await authorizerRef.loginWithPasskey(email); + if (errors && errors.length) { + if (!isUserDismissed(errors[0])) { + setWebauthnError(errors[0]?.message || ``); + } + return; + } + if (res) { + setError(``); + setAuthData({ + user: res.user || null, + token: res, + config, + loading: false, + }); + } + if (onLogin) { + onLogin(res); + } + } catch (err) { + if (!isUserDismissed(err as { code?: string })) { + setWebauthnError((err as Error).message); + } + } finally { + setWebauthnLoading(false); + } + }; +``` + +- [ ] **Step 3: Render the passkey option and hide the code form when it's the only method** + +Edit the final return block (lines 191-272). Replace the intro paragraph and form (lines 207-241) plus the `StyledFooter`'s resend-link condition: + +```typescript + const showCodeForm = !(offerWebauthnVerify && !is_totp); + + return ( + <> + {successMessage && ( + + )} + {error && ( + + )} + {webauthnError && ( + setWebauthnError(``)} + /> + )} + {offerWebauthnVerify && passkeySupported && ( + <> +

    + Verify with your passkey +

    + + + + {webauthnLoading ? `Waiting for passkey ...` : `Verify with a passkey`} + + +
    + + )} + {showCodeForm && ( + <> + {offerWebauthnVerify && passkeySupported && ( +

    + Or enter a code instead +

    + )} +

    + Please enter the OTP sent to your email or phone number or authenticator +

    +
    +
    +
    + + onInputChange('otp', e.target.value)} + disabled={isLockedOut} + /> + {errorData.otp && ( +
    {errorData.otp}
    + )} + {is_totp && ( + + )} +
    +
    + + {loading ? `Processing ...` : `Submit`} + +
    + + )} + {setView && ( + + {!is_totp && + !offerWebauthnVerify && + (sendingOtp ? ( +
    Sending ...
    + ) : ( + + Resend OTP + + ))} + {config.is_sign_up_enabled && ( +
    + Don't have an account?{' '} + setView(Views.Signup)}> + Sign Up + +
    + )} +
    + )} + + ); +}; +``` + +(The `!is_totp && !offerWebauthnVerify` guard on the Resend OTP link prevents it from appearing in the passkey-only case — resending a one-time code makes no sense there, and previously `!is_totp` alone would have incorrectly shown it.) + +- [ ] **Step 4: Typecheck and build** + +Run: `cd /Users/lakhansamani/projects/authorizer/authorizer-react && npx tsc --noEmit -p tsconfig.json && npm run build` +Expected: clean. `dist/index.d.ts` regenerated with `AuthorizerVerifyOtp`'s new `offerWebauthnVerify` prop. + +Run: `cd /Users/lakhansamani/projects/authorizer/authorizer-react/example && npx tsc --noEmit -p tsconfig.json` +Expected: clean. + +- [ ] **Step 5: Manual local verification** + +With a recipe server running (`--enable-mfa`, `--enable-totp-login`, `--enforce-mfa=false`) and a test user who has registered a passkey (via the settings-page `AuthorizerMFASetup` "Passkey" tile, or the primary passkey-login registration flow — either satisfies the factor per this plan's scoping decision): + +1. Log out, log back in with email + password. +2. Expected: no forced TOTP code screen if the user has no verified TOTP; instead, a "Verify with a passkey" button appears. Clicking it and completing the browser ceremony logs the user in. +3. For a user with both TOTP and a passkey registered: expect both the passkey button and the code-entry form to render, either one completing login. +4. Cancel the passkey ceremony (Escape/cancel dialog): expect no error banner when a code form is also available (dual-enrolled case) — the button simply becomes clickable again. + +- [ ] **Step 6: Commit** + +```bash +git add src/components/AuthorizerVerifyOtp.tsx +git commit -m "feat(mfa): offer passkey verification alongside the OTP code form" +``` + +--- + +## Self-Review Notes + +- **Spec coverage:** Backend field + gate wiring (Task 1) ✓, SDK field (Task 2) ✓, frontend wiring through both components (Tasks 3-5) ✓. `mfaGateBlockEnroll` and delete-your-only-factor guard are spec-deferred, correctly absent from this plan. +- **Type consistency:** `ShouldOfferWebauthnMfaVerify` (Go, gqlgen-generated) → `should_offer_webauthn_mfa_verify` (GraphQL/JSON) → `should_offer_webauthn_mfa_verify` (TS `AuthResponse`) → `offer_webauthn_verify` (TS `OtpDataType`, deliberately shorter — internal state, not wire format) → `offerWebauthnVerify` (component prop) — traced end to end, no mismatches. +- **No placeholders:** every step has complete, compilable code. From d52aa4c09bce764b5b2e90aef705052cb8358071 Mon Sep 17 00:00:00 2001 From: Lakhan Samani Date: Tue, 14 Jul 2026 11:36:09 +0530 Subject: [PATCH 11/20] chore(deps): bump @authorizerdev/authorizer-js to 3.5.0-rc.0 (passkey MFA) --- package-lock.json | 38 ++++++++++++++++++++++++++++++++++++-- package.json | 2 +- 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 95a2cdd..736e3f0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "2.2.0-rc.0", "license": "MIT", "dependencies": { - "@authorizerdev/authorizer-js": "file:../../../../authorizer-js/.worktrees/feat/optional-mfa-setup", + "@authorizerdev/authorizer-js": "file:../../../../authorizer-js/.worktrees/feat/passkey-mfa", "@storybook/preset-scss": "^1.0.3", "validator": "^13.11.0" }, @@ -88,6 +88,40 @@ "../../../../authorizer-js/.worktrees/feat/optional-mfa-setup": { "name": "@authorizerdev/authorizer-js", "version": "3.4.0-rc.0", + "extraneous": true, + "license": "MIT", + "dependencies": { + "cross-fetch": "^4.1.0" + }, + "devDependencies": { + "@antfu/eslint-config": "^2.27.3", + "@swc/core": "^1.15.24", + "@types/jest": "^29.5.14", + "@types/node": "^20.19.39", + "@typescript-eslint/eslint-plugin": "^8.58.0", + "@typescript-eslint/parser": "^8.58.0", + "bumpp": "^9.11.1", + "eslint": "^8.57.1", + "husky": "^8.0.3", + "jest": "^29.7.0", + "jest-environment-jsdom": "^29.7.0", + "lint-staged": "^15.5.2", + "testcontainers": "^10.28.0", + "ts-jest": "^29.4.9", + "tslib": "^2.8.1", + "tsup": "^8.5.1", + "typescript": "^5.9.3" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/authorizerdev" + } + }, + "../../../../authorizer-js/.worktrees/feat/passkey-mfa": { + "name": "@authorizerdev/authorizer-js", + "version": "3.5.0-rc.0", "license": "MIT", "dependencies": { "cross-fetch": "^4.1.0" @@ -139,7 +173,7 @@ } }, "node_modules/@authorizerdev/authorizer-js": { - "resolved": "../../../../authorizer-js/.worktrees/feat/optional-mfa-setup", + "resolved": "../../../../authorizer-js/.worktrees/feat/passkey-mfa", "link": true }, "node_modules/@babel/code-frame": { diff --git a/package.json b/package.json index 8cda37f..a6da3ad 100644 --- a/package.json +++ b/package.json @@ -91,7 +91,7 @@ "typescript": "^5.7.2" }, "dependencies": { - "@authorizerdev/authorizer-js": "file:../../../../authorizer-js/.worktrees/feat/optional-mfa-setup", + "@authorizerdev/authorizer-js": "file:../../../../authorizer-js/.worktrees/feat/passkey-mfa", "@storybook/preset-scss": "^1.0.3", "validator": "^13.11.0" } From ae15b31d2e063d71b92e6ee54b2792e8f11e7e5b Mon Sep 17 00:00:00 2001 From: Lakhan Samani Date: Tue, 14 Jul 2026 11:39:51 +0530 Subject: [PATCH 12/20] feat(mfa): thread should_offer_webauthn_mfa_verify to the OTP screen --- src/components/AuthorizerBasicAuthLogin.tsx | 5 ++++- src/types/index.ts | 1 + 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/components/AuthorizerBasicAuthLogin.tsx b/src/components/AuthorizerBasicAuthLogin.tsx index 64965a8..b8ef7d0 100644 --- a/src/components/AuthorizerBasicAuthLogin.tsx +++ b/src/components/AuthorizerBasicAuthLogin.tsx @@ -121,13 +121,15 @@ export const AuthorizerBasicAuthLogin: FC<{ if ( res.should_show_email_otp_screen || res.should_show_mobile_otp_screen || - res.should_show_totp_screen + res.should_show_totp_screen || + res.should_offer_webauthn_mfa_verify ) { setOtpData({ is_screen_visible: true, email: data.email || ``, phone_number: data.phone_number || ``, is_totp: res.should_show_totp_screen || false, + offer_webauthn_verify: res.should_offer_webauthn_mfa_verify || false, }); return; } @@ -258,6 +260,7 @@ export const AuthorizerBasicAuthLogin: FC<{ email: otpData.email || ``, phone_number: otpData.phone_number || ``, is_totp: otpData.is_totp || false, + offerWebauthnVerify: otpData.offer_webauthn_verify || false, }} urlProps={urlProps} /> diff --git a/src/types/index.ts b/src/types/index.ts index 6b38ead..35b4fb8 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -77,6 +77,7 @@ export type OtpDataType = { email?: string; phone_number?: string; is_totp?: boolean; + offer_webauthn_verify?: boolean; }; export type TotpDataType = { From 744fa6e8cdea6a684e1f17c71be4ace62b930754 Mon Sep 17 00:00:00 2001 From: Lakhan Samani Date: Tue, 14 Jul 2026 11:44:23 +0530 Subject: [PATCH 13/20] feat(mfa): offer passkey verification alongside the OTP code form --- src/components/AuthorizerVerifyOtp.tsx | 177 +++++++++++++++++++------ 1 file changed, 133 insertions(+), 44 deletions(-) diff --git a/src/components/AuthorizerVerifyOtp.tsx b/src/components/AuthorizerVerifyOtp.tsx index 4a6c5ae..ff172a6 100644 --- a/src/components/AuthorizerVerifyOtp.tsx +++ b/src/components/AuthorizerVerifyOtp.tsx @@ -1,5 +1,5 @@ import { FC, useEffect, useState } from 'react'; -import { VerifyOTPRequest } from '@authorizerdev/authorizer-js'; +import { VerifyOTPRequest, isWebauthnSupported } from '@authorizerdev/authorizer-js'; import '../styles/default.css'; import { ButtonAppearance, MessageType, Views } from '../constants'; @@ -8,6 +8,7 @@ import { StyledButton, StyledFooter, StyledLink } from '../styledComponents'; import { Message } from './Message'; import { TotpDataType } from '../types'; import { AuthorizerTOTPScanner } from './AuthorizerTOTPScanner'; +import { IconPasskey } from '../icons/mfa'; interface InputDataType { otp: string | null; @@ -29,7 +30,8 @@ export const AuthorizerVerifyOtp: FC<{ phone_number?: string; urlProps?: Record; is_totp?: boolean; -}> = ({ setView, onLogin, email, phone_number, urlProps, is_totp }) => { + offerWebauthnVerify?: boolean; +}> = ({ setView, onLogin, email, phone_number, urlProps, is_totp, offerWebauthnVerify }) => { const [error, setError] = useState(``); const [successMessage, setSuccessMessage] = useState(``); const [loading, setLoading] = useState(false); @@ -49,6 +51,48 @@ export const AuthorizerVerifyOtp: FC<{ } }, []); + const [webauthnError, setWebauthnError] = useState(``); + const [webauthnLoading, setWebauthnLoading] = useState(false); + const passkeySupported = isWebauthnSupported(); + + // A cancelled ceremony surfaces as NotAllowedError/AbortError (same + // browser behavior AuthorizerPasskeyLogin already handles) - dismiss + // silently and let the user fall back to the code form when one exists. + const isUserDismissed = (e?: { code?: string }): boolean => + e?.code === `NotAllowedError` || e?.code === `AbortError`; + + const onVerifyWithPasskey = async () => { + setWebauthnError(``); + try { + setWebauthnLoading(true); + const { data: res, errors } = await authorizerRef.loginWithPasskey(email); + if (errors && errors.length) { + if (!isUserDismissed(errors[0])) { + setWebauthnError(errors[0]?.message || ``); + } + return; + } + if (res) { + setError(``); + setAuthData({ + user: res.user || null, + token: res, + config, + loading: false, + }); + } + if (onLogin) { + onLogin(res); + } + } catch (err) { + if (!isUserDismissed(err as { code?: string })) { + setWebauthnError((err as Error).message); + } + } finally { + setWebauthnLoading(false); + } + }; + const onInputChange = async (field: string, value: string) => { setFormData({ ...formData, [field]: value }); }; @@ -188,6 +232,8 @@ export const AuthorizerVerifyOtp: FC<{ ); } + const showCodeForm = !(offerWebauthnVerify && !is_totp); + return ( <> {successMessage && ( @@ -204,53 +250,96 @@ export const AuthorizerVerifyOtp: FC<{ onClose={isLockedOut ? undefined : onErrorClose} /> )} -

    - Please enter the OTP sent to your email or phone number or authenticator -

    -
    -
    -
    - - onInputChange('otp', e.target.value)} - disabled={isLockedOut} - /> - {errorData.otp && ( -
    {errorData.otp}
    - )} - {is_totp && ( - setWebauthnError(``)} + /> + )} + {offerWebauthnVerify && passkeySupported && ( + <> +

    + Verify with your passkey +

    + + + > + + {webauthnLoading ? `Waiting for passkey ...` : `Verify with a passkey`} + + +
    + + )} + {showCodeForm && ( + <> + {offerWebauthnVerify && passkeySupported && ( +

    + Or enter a code instead +

    )} -
    -
    - - {loading ? `Processing ...` : `Submit`} - -
    +

    + Please enter the OTP sent to your email or phone number or authenticator +

    +
    +
    +
    + + onInputChange('otp', e.target.value)} + disabled={isLockedOut} + /> + {errorData.otp && ( +
    {errorData.otp}
    + )} + {is_totp && ( + + )} +
    +
    + + {loading ? `Processing ...` : `Submit`} + +
    + + )} {setView && ( {!is_totp && + !offerWebauthnVerify && (sendingOtp ? (
    Sending ...
    ) : ( From 32f649aa3f8ae8e15b398268236f47cbf1172fac Mon Sep 17 00:00:00 2001 From: Lakhan Samani Date: Tue, 14 Jul 2026 11:47:55 +0530 Subject: [PATCH 14/20] fix(mfa): don't hide the OTP code form when passkey verify isn't actually supported --- src/components/AuthorizerVerifyOtp.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/AuthorizerVerifyOtp.tsx b/src/components/AuthorizerVerifyOtp.tsx index ff172a6..f4bfb9e 100644 --- a/src/components/AuthorizerVerifyOtp.tsx +++ b/src/components/AuthorizerVerifyOtp.tsx @@ -232,7 +232,7 @@ export const AuthorizerVerifyOtp: FC<{ ); } - const showCodeForm = !(offerWebauthnVerify && !is_totp); + const showCodeForm = !(offerWebauthnVerify && passkeySupported && !is_totp); return ( <> From 9d689f498d8772dc06c26db7d0493602d80e248d Mon Sep 17 00:00:00 2001 From: Lakhan Samani Date: Tue, 14 Jul 2026 11:50:36 +0530 Subject: [PATCH 15/20] fix(mfa): match Resend OTP guard to showCodeForm's passkeySupported check --- src/components/AuthorizerVerifyOtp.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/AuthorizerVerifyOtp.tsx b/src/components/AuthorizerVerifyOtp.tsx index f4bfb9e..8bc7a31 100644 --- a/src/components/AuthorizerVerifyOtp.tsx +++ b/src/components/AuthorizerVerifyOtp.tsx @@ -339,7 +339,7 @@ export const AuthorizerVerifyOtp: FC<{ {setView && ( {!is_totp && - !offerWebauthnVerify && + !(offerWebauthnVerify && passkeySupported) && (sendingOtp ? (
    Sending ...
    ) : ( From 1f3a1d2d13db5fd6a63a93f191a3f8ca5e6b68c5 Mon Sep 17 00:00:00 2001 From: Lakhan Samani Date: Tue, 14 Jul 2026 11:58:56 +0530 Subject: [PATCH 16/20] fix(mfa): explain the unrecoverable state when passkey-only MFA isn't supported --- src/components/AuthorizerVerifyOtp.tsx | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/components/AuthorizerVerifyOtp.tsx b/src/components/AuthorizerVerifyOtp.tsx index 8bc7a31..4ed050d 100644 --- a/src/components/AuthorizerVerifyOtp.tsx +++ b/src/components/AuthorizerVerifyOtp.tsx @@ -233,6 +233,9 @@ export const AuthorizerVerifyOtp: FC<{ } const showCodeForm = !(offerWebauthnVerify && passkeySupported && !is_totp); + // Passkey is the user's only MFA factor but this browser can't do WebAuthn: + // no TOTP, no OTP was ever dispatched, so the code form below is a dead end. + const passkeyOnlyUnsupported = !!offerWebauthnVerify && !passkeySupported && !is_totp; return ( <> @@ -283,6 +286,15 @@ export const AuthorizerVerifyOtp: FC<{
    )} + {passkeyOnlyUnsupported && ( + + )} {showCodeForm && ( <> {offerWebauthnVerify && passkeySupported && ( From 6fbcf48ac9d09f976ad98380d09b3a6427408a88 Mon Sep 17 00:00:00 2001 From: Lakhan Samani Date: Tue, 14 Jul 2026 14:06:41 +0530 Subject: [PATCH 17/20] docs: add design spec for closing the EnforceMFA passkey-login bypass Backend gate on WebauthnLoginVerify (reuses login.go's existing TOTP machinery) plus frontend hide of the primary passkey button when MFA is enforced. This is the deferred "Direction B" scope from the passkey-as-MFA plan, narrowed to close the specific bypass rather than decide whether a passkey itself satisfies MFA. --- ...6-07-14-enforce-mfa-passkey-gate-design.md | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-14-enforce-mfa-passkey-gate-design.md diff --git a/docs/superpowers/specs/2026-07-14-enforce-mfa-passkey-gate-design.md b/docs/superpowers/specs/2026-07-14-enforce-mfa-passkey-gate-design.md new file mode 100644 index 0000000..d38e71c --- /dev/null +++ b/docs/superpowers/specs/2026-07-14-enforce-mfa-passkey-gate-design.md @@ -0,0 +1,37 @@ +# Close the EnforceMFA Bypass via Passkey Primary Login — Design + +**Status:** Approved, ready for implementation planning. + +**Goal:** When an organization has `--enforce-mfa` on, a user cannot bypass password + MFA verification by using the pre-existing "Sign in with a passkey" (primary, passwordless) login button instead. Today they can: `WebauthnLoginVerify` issues a token unconditionally, with no `EnforceMFA` check at all. + +**Relationship to prior work:** Found while manually testing `docs/superpowers/plans/2026-07-14-passkey-as-mfa.md`'s shipped feature. That plan deliberately scoped "passkey as a second factor" only, explicitly declining to touch passkey-as-primary-login's relationship to MFA (framed as "Direction B" during brainstorming and initially deferred). This spec is that deferred work, narrowed to close the specific bypass rather than resolve the broader "is a passkey itself AAL2-equivalent" question — it does not claim a passkey satisfies MFA; it makes passkey-only login *unavailable* when the org demands two factors. + +**Two-part fix, both required:** +1. **Backend (the actual security boundary):** `WebauthnLoginVerify` refuses to issue a token unconditionally when `EnforceMFA=true` — it reuses the same TOTP-verify/enrollment machinery `login.go`'s password path already uses. +2. **Frontend (UX correctness, not a security control on its own):** `AuthorizerPasskeyLogin`'s primary button doesn't render at all when MFA is enforced — matching standard MFA UX (Okta/Auth0-style): authenticator methods surface only *after* a first factor has identified the user, never as a standalone bypass on the anonymous login screen. Hiding the button does **not** replace the backend fix — the GraphQL mutations remain directly callable regardless of what the UI shows. + +## Backend design + +**Gate condition:** `p.Config.EnforceMFA && refs.BoolValue(user.IsMultiFactorAuthEnabled)` — mirrors the exact precondition `login.go`'s TOTP branch and `resolveMFAGate` use, so a user for whom MFA isn't individually enabled is unaffected, consistent with today's password-login behavior. + +**Inside the gate**, in `WebauthnLoginVerify`, after the existing revoked/email-verified checks and before the `issueAuthResponse` call: +- If TOTP login is disabled server-wide (`!p.Config.EnableTOTPLogin`): there is no enrollment-tracked second factor to pair with the passkey today (email/SMS OTP have no enrollment state — confirmed in the original plan's research). Refuse the passkey login outright with a clear, actionable error telling the user to sign in with their password instead. Do not silently fall through to email/SMS OTP. +- Else, check the user's TOTP authenticator (same `GetAuthenticatorDetailsByUserId` call `login.go` makes): + - **Already verified:** set the MFA session cookie (same mechanism `login.go` uses) and respond with `should_show_totp_screen=true`, no token — the user must enter their TOTP code via the *existing* `verify_otp` mutation and `AuthorizerVerifyOtp` screen. + - **Not yet enrolled:** set the MFA session cookie, generate a fresh TOTP secret/QR (same `generateTOTPEnrollment` helper `login.go` uses), and respond with `should_show_totp_screen=true` plus the enrollment payload, no token. +- If the gate condition is false (`EnforceMFA=false`, or this specific user doesn't have MFA individually enabled): **completely unchanged** — passkey login issues a token directly, exactly as it does today. The fast, one-tap passkey convenience stays intact for the common case; only the org-enforced-and-applicable-to-this-user case changes. + +**Refactor needed to support this cleanly:** `login.go`'s `setOTPMFaSession` is currently a closure local to `Login()`, capturing `user`/`meta`/`side`. Extract it into a shared provider method (e.g. `p.setMFASession(meta RequestMetadata, side *ResponseSideEffects, userID string, expiresAt int64) error`) so both `Login` and `WebauthnLoginVerify` can call it — pure mechanical extraction, no behavior change to the password path. + +**New `Meta` field:** `is_mfa_enforced: Boolean!` (mirrors `p.Config.EnforceMFA`), added the same way `is_webauthn_enabled` etc. already are. This is what the frontend uses to decide whether to render the primary passkey button — it's the only new piece of server-exposed configuration this fix needs. + +## Frontend design + +**`AuthorizerPasskeyLogin.tsx`:** its existing early return (`if (!isWebauthnSupported()) return null;`) gains the new condition: also return `null` when `config.is_mfa_enforced` is true. No other component needs to change — `AuthorizerRoot.tsx` already renders this component unconditionally on the login view and lets it self-manage visibility (including its own trailing "OR" separator logic), so this is fully self-contained. + +**Defense in depth in the same component's `onClick` handler:** today it treats any non-error `loginWithPasskey()` result as a successful login and calls `setAuthData`/`onLogin` unconditionally. Once the backend can return a tokenless `should_show_totp_screen` response (reachable only if this component's button somehow rendered despite `is_mfa_enforced` — e.g. a brief pre-config-load window — since the backend is authoritative regardless of the UI), the handler must not misinterpret that as a successful login. Guard on `res.access_token` before calling `setAuthData`/`onLogin`, matching the pattern `AuthorizerBasicAuthLogin` already uses. On a tokenless response, show a message directing the user to sign in with their password instead, rather than building out a full TOTP-verify flow inside this compact button component — the real, fully-built verify path is password login, which this state should never normally be reachable from. + +## Deferred (explicitly out of scope) + +- **The narrower case of a user who has personally enrolled TOTP but `EnforceMFA` is org-wide false.** Per the original passkey-as-MFA plan's constraint ("a user's own opted-in MFA is never skippable"), `resolveMFAGate` already blocks password login for such a user regardless of `EnforceMFA`. This fix does **not** extend that same guarantee to passkey-primary-login — a user who set up TOTP for themselves, in a non-enforced org, could still use "Sign in with a passkey" to skip their own TOTP. Closing this would mean gating on `resolveMFAGate`'s full decision (not just `EnforceMFA`), which also pulls in the offer/skip states that don't make sense for a primary-login button. Left for a future pass if it turns out to matter in practice. +- **Whether a passkey itself should count as AAL2/MFA-equivalent** (the NIST research question from earlier in this session) remains untouched — this fix does not make that determination either way; it only prevents passkey login from silently satisfying an org's explicit two-factor requirement. From 02b558d453013ea5abb6ff0d481256839bae3849 Mon Sep 17 00:00:00 2001 From: Lakhan Samani Date: Tue, 14 Jul 2026 14:09:28 +0530 Subject: [PATCH 18/20] docs: add implementation plan for the enforce-mfa passkey gate 6 tasks: extract a shared MFA-session helper, expose is_mfa_enforced on the meta query, gate WebauthnLoginVerify on EnforceMFA, expose the new field through the SDK, and hide the primary passkey button (plus a defense-in-depth response guard) on the frontend when enforced. --- .../2026-07-14-enforce-mfa-passkey-gate.md | 600 ++++++++++++++++++ 1 file changed, 600 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-14-enforce-mfa-passkey-gate.md diff --git a/docs/superpowers/plans/2026-07-14-enforce-mfa-passkey-gate.md b/docs/superpowers/plans/2026-07-14-enforce-mfa-passkey-gate.md new file mode 100644 index 0000000..2354fb7 --- /dev/null +++ b/docs/superpowers/plans/2026-07-14-enforce-mfa-passkey-gate.md @@ -0,0 +1,600 @@ +# Close the EnforceMFA Passkey-Login Bypass Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** A user cannot bypass an org's `--enforce-mfa` policy by using "Sign in with a passkey" (primary, passwordless login) instead of password + MFA verify. `WebauthnLoginVerify` gains the same TOTP-verify/enrollment gate `login.go`'s password path already has; the frontend stops offering the primary passkey button at all once MFA is enforced. + +**Architecture:** Backend-first, three small Go changes in `authorizer` (extract a shared session-setting helper, expose one new `Meta` field, wire the gate into `WebauthnLoginVerify`), then two small frontend changes (`authorizer-js` exposes the field, `authorizer-react` uses it to hide a button and guard a response handler). + +**Tech Stack:** Go 1.x / gqlgen for `authorizer`; TypeScript for `authorizer-js`; React + TypeScript for `authorizer-react`. + +**Spec:** `docs/superpowers/specs/2026-07-14-enforce-mfa-passkey-gate-design.md` + +## Global Constraints + +- **`EnforceMFA=false` (or `IsMultiFactorAuthEnabled=false` for the specific user) leaves `WebauthnLoginVerify` completely unchanged.** Only the org-enforced-and-applicable-to-this-user case changes behavior. The fast, one-tap passkey login stays intact for everyone else. +- **Do not call `resolveMFAGate` for this fix.** It would pull in the offer/skip states (`mfaGateOfferSetup`/`mfaGateSkippedSetup`), which don't apply to a primary-login button. Write the gate condition and TOTP-verify/enroll branching directly, mirroring `login.go`'s shape without reusing the gate function itself. +- **When TOTP login is disabled server-wide (`!EnableTOTPLogin`) and the gate would otherwise apply, refuse the passkey login outright** with an actionable error pointing at password login — do not silently fall through to email/SMS OTP (those have no enrollment state, per the original passkey-as-MFA plan's research) and do not issue a token. +- **The refactor of `setOTPMFaSession` into a shared method must not change `login.go`'s behavior.** It is a pure extraction — every existing `login.go` test must pass unchanged after Task 1, before any new behavior is added in Task 3. + +--- + +## Backend (`authorizer` repo) + +### Task 1: Extract `setOTPMFaSession` into a shared provider method + +**Files:** +- Modify: `internal/service/login.go:166-176` (closure definition), and its 6 call sites at lines 215, 256, 329, 358, 398, 412 + +**Interfaces:** +- Produces: `func (p *provider) setMFASession(meta RequestMetadata, side *ResponseSideEffects, userID string, expiresAt int64) error` — consumed by Task 3's `WebauthnLoginVerify` change, and by `login.go`'s own 6 existing call sites (updated in place). + +- [ ] **Step 1: Add the shared method** + +Add to `internal/service/login.go` (or a new small file `internal/service/mfa_session.go` — either is fine, this plan uses `login.go` to keep the diff in one place), near the top of the file after the imports: + +```go +// setMFASession arms a short-lived MFA session (memory-store entry + cookie) +// proving the caller already completed a first authentication factor for +// userID. verify_otp and the scoped webauthn_login_options/_verify flow both +// require this session before they'll act. Shared by Login's TOTP branch and +// 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 { + return err + } + for _, c := range cookie.BuildMfaSessionCookies(meta.HostURL, mfaSession, p.Config.AppCookieSecure) { + side.AddCookie(c) + } + return nil +} +``` + +Check `internal/service/login.go`'s existing imports already include `github.com/google/uuid` and `github.com/authorizerdev/authorizer/internal/cookie` (the closure being replaced already used both) — no new imports needed in this file. + +- [ ] **Step 2: Remove the closure and update call sites** + +Delete the closure definition at `internal/service/login.go:166-176`: + +```go + setOTPMFaSession := func(expiresAt int64) error { + mfaSession := uuid.NewString() + err = p.MemoryStoreProvider.SetMfaSession(user.ID, mfaSession, expiresAt) + if err != nil { + log.Debug().Msg("Failed to set mfa session") + return err + } + for _, c := range cookie.BuildMfaSessionCookies(meta.HostURL, mfaSession, p.Config.AppCookieSecure) { + side.AddCookie(c) + } + return nil + } +``` + +Replace every one of its 6 call sites (`internal/service/login.go:215,256,329,358,398,412`) — each currently reads `if err := setOTPMFaSession(expiresAt); err != nil {` — with: + +```go + if err := p.setMFASession(meta, side, user.ID, expiresAt); err != nil { +``` + +(Match each call site's existing indentation — they vary between the email/mobile OTP branches and the TOTP branch's switch cases. Only the function being called changes; the surrounding `if err := ...; err != nil { log.Debug()...; return nil, nil, err }` blocks are untouched.) + +- [ ] **Step 3: Build and run the existing login test suite to confirm no behavior change** + +Run: `cd /Users/lakhansamani/projects/authorizer/authorizer && go build ./... && go test ./internal/integration_tests/ -run 'TestLogin|TestLoginMFAGateTokenWithholding|TestVerifyOtp' -v` + +Expected: builds clean, all existing subtests still pass unchanged — this is the regression guard proving the extraction didn't alter behavior. + +- [ ] **Step 4: Commit** + +```bash +git add internal/service/login.go +git commit -m "refactor(mfa): extract setOTPMFaSession into a shared setMFASession method" +``` + +--- + +### Task 2: Expose `is_mfa_enforced` on the `Meta` query + +**Files:** +- Modify: `internal/graph/schema.graphqls` (`Meta` type) +- Modify: `internal/service/meta.go` +- Modify: `internal/graph/model/models_gen.go` (regenerated) +- Test: `internal/integration_tests/meta_test.go` + +**Interfaces:** +- Produces: `model.Meta.IsMfaEnforced bool` (GraphQL field `is_mfa_enforced`) — consumed by `authorizer-js` Task 4 and `authorizer-react` Task 6. + +- [ ] **Step 1: Add the field to the schema** + +Edit `internal/graph/schema.graphqls`, in `type Meta {`, add after `is_webauthn_enabled: Boolean!`: + +```graphql + # is_webauthn_enabled indicates WebAuthn/passkey enrollment is available (always on; no operator flag). + is_webauthn_enabled: Boolean! + # is_mfa_enforced mirrors EnforceMFA — when true, the frontend must not + # offer a standalone passkey primary-login path (it would bypass the org's + # two-factor requirement); passkey should only be offered as a second + # factor after password/social login. The server enforces this + # independently in webauthn_login_verify regardless of what the frontend + # shows. + is_mfa_enforced: Boolean! +} +``` + +- [ ] **Step 2: Write the failing test** + +Edit `internal/integration_tests/meta_test.go`, add a new subtest inside `TestMeta` (after the `"should reflect disabled basic auth"` subtest, or anywhere in the same `t.Run` sequence): + +```go + t.Run("should reflect enforced MFA", func(t *testing.T) { + cfg2 := getTestConfig() + cfg2.EnforceMFA = true + ts2 := initTestSetup(t, cfg2) + _, ctx2 := createContext(ts2) + + meta, err := ts2.GraphQLProvider.Meta(ctx2) + require.NoError(t, err) + assert.NotNil(t, meta) + assert.True(t, meta.IsMfaEnforced) + }) + + t.Run("should reflect non-enforced MFA by default", func(t *testing.T) { + meta, err := ts.GraphQLProvider.Meta(ctx) + require.NoError(t, err) + assert.False(t, meta.IsMfaEnforced) + }) +``` + +- [ ] **Step 3: Run the test to verify it fails** + +Run: `cd /Users/lakhansamani/projects/authorizer/authorizer && go build ./... 2>&1 | head -20` +Expected: FAIL at compile time — `meta.IsMfaEnforced undefined` (field doesn't exist on `model.Meta` yet). + +- [ ] **Step 4: Regenerate GraphQL code** + +Run: `cd /Users/lakhansamani/projects/authorizer/authorizer && go run github.com/99designs/gqlgen --verbose generate && go mod tidy` + +Confirm: `grep -n "IsMfaEnforced" internal/graph/model/models_gen.go` — expect a `bool` field with `is_mfa_enforced` json tag. + +- [ ] **Step 5: Implement** + +Edit `internal/service/meta.go`, add after `IsWebauthnEnabled: true,`: + +```go + // WebAuthn/passkey ships always-on with no operator flag. + IsWebauthnEnabled: true, + IsMfaEnforced: c.EnforceMFA, +``` + +- [ ] **Step 6: Run the test to verify it passes** + +Run: `cd /Users/lakhansamani/projects/authorizer/authorizer && go test ./internal/integration_tests/ -run TestMeta -v` +Expected: PASS, all subtests including the two new ones. + +- [ ] **Step 7: Commit** + +```bash +git add internal/graph/schema.graphqls internal/graph/model/models_gen.go internal/graph/generated/generated.go internal/service/meta.go internal/integration_tests/meta_test.go +git commit -m "feat(mfa): expose is_mfa_enforced on the meta query" +``` + +--- + +### Task 3: Gate `WebauthnLoginVerify` on `EnforceMFA` + +**Files:** +- Modify: `internal/service/webauthn.go:133-171` (`WebauthnLoginVerify`) +- Test: `internal/integration_tests/webauthn_enforce_mfa_test.go` (new file) + +**Interfaces:** +- Consumes: `p.setMFASession` from Task 1, `p.generateTOTPEnrollment` (already shipped, `internal/service/login.go:67`), `p.StorageProvider.GetAuthenticatorDetailsByUserId` (already shipped). +- Produces: nothing new for other tasks — this is the actual security fix, self-contained. + +- [ ] **Step 1: Write the failing tests** + +Create `internal/integration_tests/webauthn_enforce_mfa_test.go`: + +```go +package integration_tests + +import ( + "testing" + "time" + + "github.com/descope/virtualwebauthn" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/authorizerdev/authorizer/internal/constants" + "github.com/authorizerdev/authorizer/internal/graph/model" + "github.com/authorizerdev/authorizer/internal/refs" + "github.com/authorizerdev/authorizer/internal/storage/schemas" +) + +// registerPasskeyForNewUser signs up a fresh verified user, registers one +// passkey via a simulated ceremony, and returns everything a login-time +// assertion needs. Mirrors the setup in webauthn_test.go's +// TestWebauthnPasskeyRegistrationAndLogin. +func registerPasskeyForNewUser(t *testing.T, ts *testSetup) (*schemas.User, virtualwebauthn.RelyingParty, virtualwebauthn.Authenticator, virtualwebauthn.Credential) { + t.Helper() + req, ctx := createContext(ts) + rp := testRelyingParty(t, ts) + credential := virtualwebauthn.NewCredential(virtualwebauthn.KeyTypeEC2) + + email := "enforce_mfa_" + uuid.New().String() + "@authorizer.dev" + password := "Password@123" + signupRes, err := ts.GraphQLProvider.SignUp(ctx, &model.SignUpRequest{ + Email: &email, Password: password, ConfirmPassword: password, + }) + require.NoError(t, err) + require.NotNil(t, signupRes.AccessToken) + req.Header.Set("Authorization", "Bearer "+*signupRes.AccessToken) + + optRes, err := ts.GraphQLProvider.WebauthnRegistrationOptions(ctx, nil) + require.NoError(t, err) + attOpts, err := virtualwebauthn.ParseAttestationOptions(optRes.Options) + require.NoError(t, err) + authenticator := virtualwebauthn.NewAuthenticatorWithOptions(virtualwebauthn.AuthenticatorOptions{ + UserHandle: []byte(attOpts.UserID), + }) + authenticator.AddCredential(credential) + attResp := virtualwebauthn.CreateAttestationResponse(rp, authenticator, credential, *attOpts) + _, err = ts.GraphQLProvider.WebauthnRegistrationVerify(ctx, &model.WebauthnRegistrationVerifyRequest{Credential: attResp}) + require.NoError(t, err) + + user, err := ts.StorageProvider.GetUserByEmail(ctx, email) + require.NoError(t, err) + req.Header.Del("Authorization") + return user, rp, authenticator, credential +} + +func assertPasskeyLogin(t *testing.T, ts *testSetup, rp virtualwebauthn.RelyingParty, authenticator virtualwebauthn.Authenticator, credential virtualwebauthn.Credential) (*model.AuthResponse, error) { + t.Helper() + _, ctx := createContext(ts) + optRes, err := ts.GraphQLProvider.WebauthnLoginOptions(ctx, nil) + require.NoError(t, err) + assertOpts, err := virtualwebauthn.ParseAssertionOptions(optRes.Options) + require.NoError(t, err) + assertResp := virtualwebauthn.CreateAssertionResponse(rp, authenticator, credential, *assertOpts) + return ts.GraphQLProvider.WebauthnLoginVerify(ctx, &model.WebauthnLoginVerifyRequest{Credential: assertResp}) +} + +func TestWebauthnLoginVerifyEnforceMFA(t *testing.T) { + t.Run("EnforceMFA=false — unchanged, issues token", func(t *testing.T) { + cfg := getTestConfig() + ts := initTestSetup(t, cfg) + user, rp, authenticator, credential := registerPasskeyForNewUser(t, ts) + user.IsMultiFactorAuthEnabled = refs.NewBoolRef(true) + _, err := ts.StorageProvider.UpdateUser(t.Context(), user) + require.NoError(t, err) + + authRes, err := assertPasskeyLogin(t, ts, rp, authenticator, credential) + require.NoError(t, err) + require.NotNil(t, authRes.AccessToken, "EnforceMFA=false must not block passkey login") + }) + + t.Run("EnforceMFA=true, user MFA not individually enabled — unaffected", func(t *testing.T) { + cfg := getTestConfig() + cfg.EnforceMFA = true + ts := initTestSetup(t, cfg) + _, rp, authenticator, credential := registerPasskeyForNewUser(t, ts) + // IsMultiFactorAuthEnabled left false: mirrors resolveMFAGate's own + // precondition, consistent with password login's existing behavior. + + authRes, err := assertPasskeyLogin(t, ts, rp, authenticator, credential) + require.NoError(t, err) + require.NotNil(t, authRes.AccessToken) + }) + + t.Run("EnforceMFA=true, TOTP verified — blocks token, offers totp screen", func(t *testing.T) { + cfg := getTestConfig() + cfg.EnforceMFA = true + ts := initTestSetup(t, cfg) + user, rp, authenticator, credential := registerPasskeyForNewUser(t, ts) + user.IsMultiFactorAuthEnabled = refs.NewBoolRef(true) + _, err := ts.StorageProvider.UpdateUser(t.Context(), user) + require.NoError(t, err) + now := time.Now().Unix() + _, err = ts.StorageProvider.AddAuthenticator(t.Context(), &schemas.Authenticator{ + UserID: user.ID, Method: constants.EnvKeyTOTPAuthenticator, + Secret: "dummy-secret", VerifiedAt: &now, + }) + require.NoError(t, err) + + authRes, err := assertPasskeyLogin(t, ts, rp, authenticator, credential) + require.NoError(t, err) + require.NotNil(t, authRes) + assert.Nil(t, authRes.AccessToken, "a user with verified TOTP must not get a token straight off a passkey login when MFA is enforced") + assert.True(t, refs.BoolValue(authRes.ShouldShowTotpScreen)) + assert.Nil(t, authRes.AuthenticatorSecret, "already-enrolled path must not hand back a fresh enrollment payload") + }) + + t.Run("EnforceMFA=true, TOTP not enrolled — blocks token, returns enrollment payload", func(t *testing.T) { + cfg := getTestConfig() + cfg.EnforceMFA = true + ts := initTestSetup(t, cfg) + user, rp, authenticator, credential := registerPasskeyForNewUser(t, ts) + user.IsMultiFactorAuthEnabled = refs.NewBoolRef(true) + _, err := ts.StorageProvider.UpdateUser(t.Context(), user) + require.NoError(t, err) + + authRes, err := assertPasskeyLogin(t, ts, rp, authenticator, credential) + require.NoError(t, err) + require.NotNil(t, authRes) + assert.Nil(t, authRes.AccessToken) + assert.True(t, refs.BoolValue(authRes.ShouldShowTotpScreen)) + assert.NotNil(t, authRes.AuthenticatorSecret, "not-yet-enrolled path must hand back a fresh TOTP enrollment payload") + }) + + t.Run("EnforceMFA=true, TOTP disabled server-wide — refuses passkey login entirely", func(t *testing.T) { + cfg := getTestConfig() + cfg.EnforceMFA = true + cfg.EnableTOTPLogin = false + ts := initTestSetup(t, cfg) + user, rp, authenticator, credential := registerPasskeyForNewUser(t, ts) + user.IsMultiFactorAuthEnabled = refs.NewBoolRef(true) + _, err := ts.StorageProvider.UpdateUser(t.Context(), user) + require.NoError(t, err) + + authRes, err := assertPasskeyLogin(t, ts, rp, authenticator, credential) + require.Error(t, err, "must refuse rather than silently issue a token with no compatible second factor available") + assert.Nil(t, authRes) + }) +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `cd /Users/lakhansamani/projects/authorizer/authorizer && go test ./internal/integration_tests/ -run TestWebauthnLoginVerifyEnforceMFA -v` +Expected: the `EnforceMFA=false` and `not individually enabled` subtests PASS already (current unconditional-issue behavior happens to satisfy them); the three `EnforceMFA=true` subtests FAIL (today's code always issues a token / never refuses). + +- [ ] **Step 3: Implement the gate** + +Edit `internal/service/webauthn.go`. Add `"time"` to the import block (currently `context`, `strings`, then the internal packages, then `gin`): + +```go +import ( + "context" + "strings" + "time" + + "github.com/authorizerdev/authorizer/internal/audit" + "github.com/authorizerdev/authorizer/internal/constants" + "github.com/authorizerdev/authorizer/internal/cookie" + "github.com/authorizerdev/authorizer/internal/graph/model" + "github.com/authorizerdev/authorizer/internal/refs" + "github.com/gin-gonic/gin" +) +``` + +In `WebauthnLoginVerify`, insert this block immediately after the email-verified check (after the `if user.EmailVerifiedAt == nil { ... }` block, i.e. right before the `p.AuditProvider.LogEvent(...)` call that currently precedes `issueAuthResponse`): + +```go + // EnforceMFA is absolute and applies to passkey primary login exactly + // like it applies to password login: a passkey may not silently satisfy + // an org's two-factor requirement. This does not claim a passkey is + // itself insufficient as a factor - it only prevents passkey login from + // becoming an unintended bypass of a policy the org explicitly turned on. + if p.Config.EnforceMFA && refs.BoolValue(user.IsMultiFactorAuthEnabled) { + if !p.Config.EnableTOTPLogin { + log.Debug().Msg("EnforceMFA is on but no compatible second factor is configured for passkey login") + return nil, nil, FailedPrecondition("multi-factor authentication is required but no compatible verification method is available for passkey sign-in; please sign in with your password instead") + } + authenticator, authErr := p.StorageProvider.GetAuthenticatorDetailsByUserId(ctx, user.ID, constants.EnvKeyTOTPAuthenticator) + totpVerified := authErr == nil && authenticator != nil && authenticator.VerifiedAt != nil + expiresAt := time.Now().Add(3 * time.Minute).Unix() + if err := p.setMFASession(meta, side, user.ID, expiresAt); err != nil { + log.Debug().Msg("Failed to set mfa session") + return nil, nil, err + } + if totpVerified { + return &model.AuthResponse{ + Message: `Proceed to mfa verification`, + ShouldShowTotpScreen: refs.NewBoolRef(true), + }, side, nil + } + enrollment, err := p.generateTOTPEnrollment(ctx, user.ID) + if err != nil { + log.Debug().Msg("Failed to generate totp") + return nil, nil, err + } + return &model.AuthResponse{ + Message: `Proceed to totp verification screen`, + ShouldShowTotpScreen: refs.NewBoolRef(true), + AuthenticatorScannerImage: refs.NewStringRef(enrollment.ScannerImage), + AuthenticatorSecret: refs.NewStringRef(enrollment.Secret), + AuthenticatorRecoveryCodes: enrollment.RecoveryCodes, + }, side, nil + } +``` + +Note `side` already exists in this function (`side := &ResponseSideEffects{}` at the top) — reuse it, don't redeclare. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `cd /Users/lakhansamani/projects/authorizer/authorizer && go test ./internal/integration_tests/ -run TestWebauthnLoginVerifyEnforceMFA -v` +Expected: PASS, all 5 subtests. + +- [ ] **Step 5: Run the full webauthn and login test files to confirm no regressions** + +Run: `cd /Users/lakhansamani/projects/authorizer/authorizer && go build ./... && go vet ./... && go test ./internal/integration_tests/ -run 'TestWebauthn|TestLogin' -v` +Expected: builds clean, vet clean, all subtests pass — including the original `TestWebauthnPasskeyRegistrationAndLogin`'s usernameless/scoped login subtests (both run with `EnforceMFA` unset/false via `getTestConfig()`, so they must be completely unaffected). + +- [ ] **Step 6: Commit** + +```bash +git add internal/service/webauthn.go internal/integration_tests/webauthn_enforce_mfa_test.go +git commit -m "feat(mfa): refuse passkey primary login to silently bypass EnforceMFA" +``` + +--- + +## SDK (`authorizer-js` repo) + +### Task 4: Expose `is_mfa_enforced` + +**Files:** +- Modify: `/Users/lakhansamani/projects/authorizer/authorizer-js/src/types.ts` (`Meta` interface) +- Modify: `/Users/lakhansamani/projects/authorizer/authorizer-js/package.json` (version bump) + +**Interfaces:** +- Consumes: `is_mfa_enforced` from Task 2's `Meta` query. +- Produces: `Types.Meta.is_mfa_enforced: boolean` — consumed by `authorizer-react` Task 6. This flows into React's `config` object automatically via the existing `getMetaData()` → context-merge path (no SDK method changes needed — `Meta` is already fetched and merged wholesale). + +- [ ] **Step 1: Add the field to `types.ts`** + +Edit `/Users/lakhansamani/projects/authorizer/authorizer-js/src/types.ts`, in `interface Meta`, add after `is_webauthn_enabled: boolean;`: + +```typescript + is_webauthn_enabled: boolean; + is_mfa_enforced: boolean; +``` + +- [ ] **Step 2: Build and bump version** + +Run: `cd /Users/lakhansamani/projects/authorizer/authorizer-js && npm run build` +Expected: builds clean, `lib/index.d.ts` regenerated with the new field. + +Edit `package.json`: bump `"version"` from `3.5.0-rc.0` to `3.6.0-rc.0`. + +- [ ] **Step 3: Commit** + +```bash +git add src/types.ts package.json lib/ +git commit -m "feat: expose is_mfa_enforced on Meta" +``` + +--- + +## Frontend (`authorizer-react` repo) + +### Task 5: Point `authorizer-react` at the new local SDK build + +**Files:** +- Modify: `/Users/lakhansamani/projects/authorizer/authorizer-react/package.json` + +- [ ] **Step 1: Link the local SDK build** + +Run: `cd /Users/lakhansamani/projects/authorizer/authorizer-react && npm install /Users/lakhansamani/projects/authorizer/authorizer-js` + +- [ ] **Step 2: Typecheck** + +Run: `cd /Users/lakhansamani/projects/authorizer/authorizer-react && npx tsc --noEmit -p tsconfig.json` +Expected: clean. + +- [ ] **Step 3: Commit** + +```bash +git add package.json package-lock.json +git commit -m "chore(deps): bump @authorizerdev/authorizer-js to 3.6.0-rc.0 (enforce-mfa passkey gate)" +``` + +--- + +### Task 6: Hide the primary passkey button when MFA is enforced, guard the response handler + +**Files:** +- Modify: `src/components/AuthorizerPasskeyLogin.tsx` + +**Interfaces:** +- Consumes: `config.is_mfa_enforced` (from Task 5's linked SDK, already flows into `config` via the existing `useAuthorizer()` context — same pattern as `config.is_google_login_enabled` etc. already used in this same file). + +- [ ] **Step 1: Hide the button when MFA is enforced** + +In `src/components/AuthorizerPasskeyLogin.tsx`, find the early return: + +```typescript + if (!isWebauthnSupported()) { + return null; + } +``` + +Replace with: + +```typescript + // When the org enforces MFA, passkey must never be offered as a + // standalone primary-login path - it would let a user skip the org's + // two-factor requirement entirely. The server refuses this independently + // (webauthn_login_verify checks EnforceMFA itself), but the button + // shouldn't invite the attempt in the first place: authenticator methods + // belong after a first factor has identified the user, not before. + if (!isWebauthnSupported() || config.is_mfa_enforced) { + return null; + } +``` + +- [ ] **Step 2: Guard the response handler against a tokenless response** + +Find the `onClick` handler's success path: + +```typescript + if (res) { + setAuthData({ + user: res.user || null, + token: res, + config, + loading: false, + }); + } + if (onLogin) { + onLogin(res); + } +``` + +Replace with: + +```typescript + if (res && !res.access_token) { + // Reachable only if this button somehow rendered despite the + // is_mfa_enforced guard above (e.g. a brief pre-config-load + // window) - the server is the real boundary and just refused to + // issue a token. Don't treat this as a successful login. + setError( + `Additional verification is required. Please sign in with your email and password instead.` + ); + return; + } + if (res) { + setAuthData({ + user: res.user || null, + token: res, + config, + loading: false, + }); + } + if (onLogin) { + onLogin(res); + } +``` + +- [ ] **Step 3: Typecheck and build** + +Run: `cd /Users/lakhansamani/projects/authorizer/authorizer-react && npx tsc --noEmit -p tsconfig.json && npm run build` +Expected: clean. + +- [ ] **Step 4: Manual local verification** + +With the recipe server running with `--enforce-mfa`, a user who has a registered passkey but no TOTP: + +1. Load the login screen. Expected: "Sign in with a passkey" button is absent. +2. Log in with password. Expected: forced into TOTP enrollment (existing `mfaGateBlockEnroll` behavior, unchanged by this plan). +3. Complete TOTP enrollment, log out, log back in with password. Expected: TOTP verify screen (existing `mfaGateBlockVerify` behavior). +4. Confirm no code path in the UI ever offers a bare "sign in with passkey, skip everything" option while `--enforce-mfa` is set. + +- [ ] **Step 5: Commit** + +```bash +git add src/components/AuthorizerPasskeyLogin.tsx +git commit -m "feat(mfa): hide the primary passkey button when MFA is enforced" +``` + +--- + +## Self-Review Notes + +- **Spec coverage:** backend gate (Task 3) ✓, backend refactor prerequisite (Task 1) ✓, `is_mfa_enforced` field (Task 2) ✓, SDK exposure (Task 4) ✓, frontend hide + defense-in-depth (Task 6) ✓. Both explicitly-deferred items from the spec (self-opted-but-not-enforced TOTP skippability via passkey; the NIST AAL2 question) are correctly absent from this plan. +- **Type consistency:** `EnforceMFA` (Go, unchanged existing field) → `IsMfaEnforced`/`is_mfa_enforced` (Meta, gqlgen-generated → GraphQL/JSON) → `is_mfa_enforced` (TS `Meta`) → `config.is_mfa_enforced` (React, via the existing meta-merge path, no new plumbing) — traced end to end, no mismatches, no new naming introduced beyond this one field. +- **No placeholders:** every step has complete, compilable code. From 8810ebee100b8e4c09579d53147fa91a97339536 Mon Sep 17 00:00:00 2001 From: Lakhan Samani Date: Tue, 14 Jul 2026 15:00:48 +0530 Subject: [PATCH 19/20] chore(deps): bump @authorizerdev/authorizer-js to 3.6.0-rc.0 (enforce-mfa passkey gate) --- package-lock.json | 38 ++++++++++++++++++++++++++++++++++++-- package.json | 2 +- 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 736e3f0..41b4ca7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "2.2.0-rc.0", "license": "MIT", "dependencies": { - "@authorizerdev/authorizer-js": "file:../../../../authorizer-js/.worktrees/feat/passkey-mfa", + "@authorizerdev/authorizer-js": "file:../../../../authorizer-js/.worktrees/feat/enforce-mfa-passkey-gate", "@storybook/preset-scss": "^1.0.3", "validator": "^13.11.0" }, @@ -85,6 +85,39 @@ "url": "https://github.com/sponsors/authorizerdev" } }, + "../../../../authorizer-js/.worktrees/feat/enforce-mfa-passkey-gate": { + "name": "@authorizerdev/authorizer-js", + "version": "3.6.0-rc.0", + "license": "MIT", + "dependencies": { + "cross-fetch": "^4.1.0" + }, + "devDependencies": { + "@antfu/eslint-config": "^2.27.3", + "@swc/core": "^1.15.24", + "@types/jest": "^29.5.14", + "@types/node": "^20.19.39", + "@typescript-eslint/eslint-plugin": "^8.58.0", + "@typescript-eslint/parser": "^8.58.0", + "bumpp": "^9.11.1", + "eslint": "^8.57.1", + "husky": "^8.0.3", + "jest": "^29.7.0", + "jest-environment-jsdom": "^29.7.0", + "lint-staged": "^15.5.2", + "testcontainers": "^10.28.0", + "ts-jest": "^29.4.9", + "tslib": "^2.8.1", + "tsup": "^8.5.1", + "typescript": "^5.9.3" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/authorizerdev" + } + }, "../../../../authorizer-js/.worktrees/feat/optional-mfa-setup": { "name": "@authorizerdev/authorizer-js", "version": "3.4.0-rc.0", @@ -122,6 +155,7 @@ "../../../../authorizer-js/.worktrees/feat/passkey-mfa": { "name": "@authorizerdev/authorizer-js", "version": "3.5.0-rc.0", + "extraneous": true, "license": "MIT", "dependencies": { "cross-fetch": "^4.1.0" @@ -173,7 +207,7 @@ } }, "node_modules/@authorizerdev/authorizer-js": { - "resolved": "../../../../authorizer-js/.worktrees/feat/passkey-mfa", + "resolved": "../../../../authorizer-js/.worktrees/feat/enforce-mfa-passkey-gate", "link": true }, "node_modules/@babel/code-frame": { diff --git a/package.json b/package.json index a6da3ad..43f8798 100644 --- a/package.json +++ b/package.json @@ -91,7 +91,7 @@ "typescript": "^5.7.2" }, "dependencies": { - "@authorizerdev/authorizer-js": "file:../../../../authorizer-js/.worktrees/feat/passkey-mfa", + "@authorizerdev/authorizer-js": "file:../../../../authorizer-js/.worktrees/feat/enforce-mfa-passkey-gate", "@storybook/preset-scss": "^1.0.3", "validator": "^13.11.0" } From 9f77110705480b6eea38158a444c0fe30c33a2fc Mon Sep 17 00:00:00 2001 From: Lakhan Samani Date: Tue, 14 Jul 2026 15:04:14 +0530 Subject: [PATCH 20/20] feat(mfa): hide the primary passkey button when MFA is enforced Also wires config.is_mfa_enforced through AuthorizerConfig / AuthorizerContextPropsType and both default config objects in AuthorizerContext - the field existed in the linked SDK's Meta type but was never declared on the local config types, so referencing it failed to typecheck. --- src/components/AuthorizerPasskeyLogin.tsx | 18 +++++++++++++++++- src/contexts/AuthorizerContext.tsx | 2 ++ src/types/index.ts | 2 ++ 3 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/components/AuthorizerPasskeyLogin.tsx b/src/components/AuthorizerPasskeyLogin.tsx index bb29d1e..ee94ef5 100644 --- a/src/components/AuthorizerPasskeyLogin.tsx +++ b/src/components/AuthorizerPasskeyLogin.tsx @@ -47,7 +47,13 @@ export const AuthorizerPasskeyLogin: FC<{ const [loading, setLoading] = useState(false); const { setAuthData, config, authorizerRef } = useAuthorizer(); - if (!isWebauthnSupported()) { + // When the org enforces MFA, passkey must never be offered as a + // standalone primary-login path - it would let a user skip the org's + // two-factor requirement entirely. The server refuses this independently + // (webauthn_login_verify checks EnforceMFA itself), but the button + // shouldn't invite the attempt in the first place: authenticator methods + // belong after a first factor has identified the user, not before. + if (!isWebauthnSupported() || config.is_mfa_enforced) { return null; } @@ -94,6 +100,16 @@ export const AuthorizerPasskeyLogin: FC<{ } return; } + if (res && !res.access_token) { + // Reachable only if this button somehow rendered despite the + // is_mfa_enforced guard above (e.g. a brief pre-config-load + // window) - the server is the real boundary and just refused to + // issue a token. Don't treat this as a successful login. + setError( + `Additional verification is required. Please sign in with your email and password instead.` + ); + return; + } if (res) { setAuthData({ user: res.user || null, diff --git a/src/contexts/AuthorizerContext.tsx b/src/contexts/AuthorizerContext.tsx index 30f6bba..459979e 100644 --- a/src/contexts/AuthorizerContext.tsx +++ b/src/contexts/AuthorizerContext.tsx @@ -39,6 +39,7 @@ const AuthorizerContext = createContext({ is_multi_factor_auth_enabled: false, is_mobile_basic_authentication_enabled: false, is_phone_verification_enabled: false, + is_mfa_enforced: false, }, configLoadError: null, user: null, @@ -114,6 +115,7 @@ let initialState: AuthorizerState = { is_multi_factor_auth_enabled: false, is_mobile_basic_authentication_enabled: false, is_phone_verification_enabled: false, + is_mfa_enforced: false, }, }; diff --git a/src/types/index.ts b/src/types/index.ts index 35b4fb8..5a05bf0 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -22,6 +22,7 @@ export type AuthorizerConfig = { is_multi_factor_auth_enabled: boolean; is_mobile_basic_authentication_enabled: boolean; is_phone_verification_enabled: boolean; + is_mfa_enforced: boolean; }; export type AuthorizerState = { @@ -59,6 +60,7 @@ export type AuthorizerContextPropsType = { is_multi_factor_auth_enabled: boolean; is_mobile_basic_authentication_enabled: boolean; is_phone_verification_enabled: boolean; + is_mfa_enforced: boolean; }; user: null | User; token: null | AuthToken;