From 774942ce2cff2eba8d315609d8ff03cf45dcfd5f Mon Sep 17 00:00:00 2001 From: Kaare Larsen Date: Wed, 23 Sep 2026 16:20:11 +0200 Subject: [PATCH] Drive auth settings from the API and enforce passkey server-side AUTH_TYPE, AUTH_CHANNEL and AUTH_PASSKEY move from the web env to the API env and are exposed via /1/meta. The web loads meta before rendering (also when logged out) and derives the login/signup forms and passkey UI from it. Passkey was previously only a client-side toggle: the API routes were always live. They now return 403 when AUTH_PASSKEY is off. The Settings passkey card is hidden when passkey is disabled, so users can't register a passkey they can't sign in with. Removes the unused OptionalPassword component. --- services/api/.env | 7 ++ .../src/routes/auth/passkey-disabled.test.js | 17 ++++ services/api/src/routes/auth/passkey.js | 7 ++ services/api/src/routes/meta.js | 6 ++ services/api/src/routes/meta.test.js | 15 ++++ services/web/.env | 7 -- services/web/src/components/Auth/Federated.js | 5 +- .../src/components/Auth/OptionalPassword.js | 10 --- services/web/src/screens/Auth/Login.js | 56 ++++++------ services/web/src/screens/Auth/Signup.js | 45 +++++----- .../src/screens/Settings/Security/index.js | 90 ++++++++++--------- services/web/src/stores/session.js | 78 ++++++++-------- services/web/src/utils/auth/passkey.js | 6 +- services/web/src/utils/env.js | 3 - 14 files changed, 200 insertions(+), 152 deletions(-) create mode 100644 services/api/src/routes/auth/passkey-disabled.test.js delete mode 100644 services/web/src/components/Auth/OptionalPassword.js diff --git a/services/api/.env b/services/api/.env index 851bbf628..8e0f7f4a1 100644 --- a/services/api/.env +++ b/services/api/.env @@ -27,6 +27,13 @@ APP_LOGO_URL= API_URL=http://localhost:2300 +# How to authenticate (password|link|code) +AUTH_TYPE=password +# Link/code send by (email|sms) +AUTH_CHANNEL=email +# Allow passkey sign-in +AUTH_PASSKEY=true + DEFAULT_TIME_ZONE=America/New_York # Uploads (local|gcs) diff --git a/services/api/src/routes/auth/passkey-disabled.test.js b/services/api/src/routes/auth/passkey-disabled.test.js new file mode 100644 index 000000000..539f526e5 --- /dev/null +++ b/services/api/src/routes/auth/passkey-disabled.test.js @@ -0,0 +1,17 @@ +vi.hoisted(() => { + process.env.AUTH_PASSKEY = 'false'; +}); + +import { request } from '../../utils/testing/index.js'; + +describe('/1/auth/passkey (disabled)', () => { + it('should reject passkey routes', async () => { + const response = await request('POST', '/1/auth/passkey/generate-login', {}); + expect(response).toHaveStatus(403); + }); + + it('should report passkey as disabled in meta', async () => { + const response = await request('GET', '/1/meta', {}, {}); + expect(response.body.data.auth.passkey).toBe(false); + }); +}); diff --git a/services/api/src/routes/auth/passkey.js b/services/api/src/routes/auth/passkey.js index d1bb6f31b..e69dbd1f7 100644 --- a/services/api/src/routes/auth/passkey.js +++ b/services/api/src/routes/auth/passkey.js @@ -1,5 +1,6 @@ import Router from '@koa/router'; import yd from '@bedrockio/yada'; +import config from '@bedrockio/config'; import { validateBody } from '../../utils/middleware/validate.js'; import { authenticate } from '../../utils/middleware/authenticate.js'; @@ -18,6 +19,12 @@ import { const router = new Router(); router + .use(async (ctx, next) => { + if (!config.get('AUTH_PASSKEY', 'boolean')) { + ctx.throw(403, 'Passkey authentication is disabled.'); + } + await next(); + }) .post('/generate-login', async (ctx) => { try { ctx.body = { diff --git a/services/api/src/routes/meta.js b/services/api/src/routes/meta.js index 9210a6008..b56c9d7f2 100644 --- a/services/api/src/routes/meta.js +++ b/services/api/src/routes/meta.js @@ -1,4 +1,5 @@ import Router from '@koa/router'; +import config from '@bedrockio/config'; import types from '../lib/notifications/types.js'; import roles from '../roles.json' with { type: 'json' }; @@ -10,6 +11,11 @@ router.get('/', async (ctx) => { data: { roles, notifications: types, + auth: { + type: config.get('AUTH_TYPE'), + channel: config.get('AUTH_CHANNEL'), + passkey: config.get('AUTH_PASSKEY', 'boolean'), + }, }, }; }); diff --git a/services/api/src/routes/meta.test.js b/services/api/src/routes/meta.test.js index 8bef71367..0e3c199f6 100644 --- a/services/api/src/routes/meta.test.js +++ b/services/api/src/routes/meta.test.js @@ -17,6 +17,21 @@ describe('/1/meta', () => { type: 'product-updated', }, ], + auth: { + type: 'password', + channel: 'email', + passkey: true, + }, + }); + }); + + it('should get app meta without authentication', async () => { + const response = await request('GET', '/1/meta', {}, {}); + expect(response).toHaveStatus(200); + expect(response.body.data.auth).toEqual({ + type: 'password', + channel: 'email', + passkey: true, }); }); }); diff --git a/services/web/.env b/services/web/.env index d9847dc94..cee6393cb 100644 --- a/services/web/.env +++ b/services/web/.env @@ -26,13 +26,6 @@ SENTRY_DSN= # Google Tag Manager Analytics GTM_CONTAINER_ID= -# How to authenticate (password|link|code) -AUTH_TYPE=password -# Link/code send by (email|sms) -AUTH_CHANNEL=email -# Allow passkey? -AUTH_PASSKEY= - # Google Maps and Address Lookup # https://console.cloud.google.com/apis/library/maps-backend.googleapis.com # https://console.cloud.google.com/apis/library/places-backend.googleapis.com diff --git a/services/web/src/components/Auth/Federated.js b/services/web/src/components/Auth/Federated.js index f32c74d5a..1a68ca6a7 100644 --- a/services/web/src/components/Auth/Federated.js +++ b/services/web/src/components/Auth/Federated.js @@ -1,3 +1,5 @@ +import { useSession } from 'stores/session'; + import { canShowAppleSignin } from 'utils/auth/apple'; import { canShowGoogleSignin } from 'utils/auth/google'; import { canShowPasskey } from 'utils/auth/passkey'; @@ -8,12 +10,13 @@ import PasskeyButton from './PasskeyButton'; export default function Federated(props) { const { type } = props; + const { meta } = useSession(); const isSignup = type === 'signup'; const showApple = canShowAppleSignin(); const showGoogle = canShowGoogleSignin(); - const showPasskey = !isSignup && canShowPasskey(); + const showPasskey = !isSignup && canShowPasskey(meta); if (!showApple && !showGoogle && !showPasskey) { return null; diff --git a/services/web/src/components/Auth/OptionalPassword.js b/services/web/src/components/Auth/OptionalPassword.js deleted file mode 100644 index a54a5ff6a..000000000 --- a/services/web/src/components/Auth/OptionalPassword.js +++ /dev/null @@ -1,10 +0,0 @@ -import { PasswordInput } from '@/components/ui/password-input'; - -import { AUTH_TYPE } from 'utils/env'; - -export default function OptionalPassword(props) { - if (AUTH_TYPE !== 'password') { - return null; - } - return ; -} diff --git a/services/web/src/screens/Auth/Login.js b/services/web/src/screens/Auth/Login.js index 7916c8917..9608479c3 100644 --- a/services/web/src/screens/Auth/Login.js +++ b/services/web/src/screens/Auth/Login.js @@ -25,18 +25,19 @@ import { PasswordInput } from '@/components/ui/password-input'; import { Separator } from '@/components/ui/separator'; import { request } from 'utils/api'; -import { AUTH_CHANNEL, AUTH_TYPE } from 'utils/env'; import { formatPhone, normalizePhone } from 'utils/phone'; // The SMS channel delivers to a phone, so the login screen identifies by phone // rather than email. Password login never uses a channel. -const USE_PHONE = AUTH_TYPE !== 'password' && AUTH_CHANNEL === 'sms'; +function usesPhone(auth) { + return auth.type !== 'password' && auth.channel === 'sms'; +} -function login(values) { - if (AUTH_TYPE === 'password') { +function login(values, auth) { + if (auth.type === 'password') { return loginPassword(values); } else { - return loginOtp(values); + return loginOtp(values, auth); } } @@ -51,37 +52,42 @@ async function loginPassword(body) { }); } -async function loginOtp(body) { +async function loginOtp(body, auth) { return await request({ method: 'POST', path: `/1/auth/otp/send`, body: { - ...(USE_PHONE ? { phone: body.phone } : { email: body.email }), - type: AUTH_TYPE, - channel: AUTH_CHANNEL, + ...(usesPhone(auth) ? { phone: body.phone } : { email: body.email }), + type: auth.type, + channel: auth.channel, }, }); } -const schema = z.object({ - email: USE_PHONE - ? z.string().optional() - : z.string().min(1, 'Email is required').email('Enter a valid email'), - phone: USE_PHONE - ? z.string().min(1, 'Phone is required') - : z.string().optional(), - password: - AUTH_TYPE === 'password' - ? z.string().min(1, 'Password is required') +function getSchema(auth) { + const usePhone = usesPhone(auth); + return z.object({ + email: usePhone + ? z.string().optional() + : z.string().min(1, 'Email is required').email('Enter a valid email'), + phone: usePhone + ? z.string().min(1, 'Phone is required') : z.string().optional(), -}); + password: + auth.type === 'password' + ? z.string().min(1, 'Password is required') + : z.string().optional(), + }); +} export default function PasswordLogin() { const navigate = useNavigate(); - const { authenticate } = useSession(); + const { authenticate, meta } = useSession(); + const { auth } = meta; + const usePhone = usesPhone(auth); const form = useForm({ - resolver: zodResolver(schema), + resolver: zodResolver(getSchema(auth)), defaultValues: { email: '', phone: '', @@ -104,7 +110,7 @@ export default function PasswordLogin() { try { setError(null); - const { data } = await login(values); + const { data } = await login(values, auth); const { token, challenge } = data; if (token) { @@ -132,7 +138,7 @@ export default function PasswordLogin() {
- {USE_PHONE ? ( + {usePhone ? ( )} - {AUTH_TYPE === 'password' && ( + {auth.type === 'password' && ( )} /> - {AUTH_TYPE === 'password' && ( + {auth.type === 'password' && ( )}
- - - Passkey - - Sign in without a password using a passkey on your device. - - - - {user.authenticators - .filter((authenticator) => authenticator.type === 'passkey') - .map((passkey) => { - const { id, name, createdAt, lastUsedAt } = passkey; - return ( -
-
- {name} - - Added {formatDate(createdAt)} · Last used{' '} - {fromNow(lastUsedAt)} - + {canShowPasskey(meta) && ( + + + Passkey + + Sign in without a password using a passkey on your device. + + + + {user.authenticators + .filter((authenticator) => authenticator.type === 'passkey') + .map((passkey) => { + const { id, name, createdAt, lastUsedAt } = passkey; + return ( +
+
+ {name} + + Added {formatDate(createdAt)} · Last used{' '} + {fromNow(lastUsedAt)} + +
+
- -
- ); - })} -
- -
- - + ); + })} +
+ +
+ + + )} diff --git a/services/web/src/stores/session.js b/services/web/src/stores/session.js index 3d03fcc16..c8edbb657 100644 --- a/services/web/src/stores/session.js +++ b/services/web/src/stores/session.js @@ -99,54 +99,56 @@ function SessionProvider({ children, location }) { }, []); const bootstrap = useCallback(async () => { - if (hasToken()) { - updateState({ - loading: true, - error: null, + updateState({ + loading: true, + error: null, + }); + try { + const { data: meta } = await request({ + method: 'GET', + path: '/1/meta', }); - try { - const { data: user } = await request({ - method: 'GET', - path: '/1/users/me', - }); + updateState({ meta }); - const { data: meta } = await request({ - method: 'GET', - path: '/1/meta', + if (!hasToken()) { + updateState({ + user: null, + ready: true, + loading: false, }); - const organization = await loadOrganization(); + return; + } + + const { data: user } = await request({ + method: 'GET', + path: '/1/users/me', + }); - // Uncomment this line if you want to set up - // User-Id tracking. https://bit.ly/2DKQYEN. - // setUserId(user.id); + const organization = await loadOrganization(); + // Uncomment this line if you want to set up + // User-Id tracking. https://bit.ly/2DKQYEN. + // setUserId(user.id); + + updateState({ + user, + organization, + loading: false, + ready: true, + }); + } catch (error) { + // eslint-disable-next-line no-console + console.log(error); + captureError(error); + if (error.type === 'token') { + await logout(); + } else { updateState({ - user, - meta, - organization, + error, loading: false, ready: true, }); - } catch (error) { - // eslint-disable-next-line no-console - console.log(error); - captureError(error); - if (error.type === 'token') { - await logout(); - } else { - updateState({ - error, - loading: false, - ready: true, - }); - } } - } else { - updateState({ - user: null, - ready: true, - loading: false, - }); } }, [loadOrganization]); diff --git a/services/web/src/utils/auth/passkey.js b/services/web/src/utils/auth/passkey.js index 2fcb4f867..1b080582b 100644 --- a/services/web/src/utils/auth/passkey.js +++ b/services/web/src/utils/auth/passkey.js @@ -3,14 +3,12 @@ import { startRegistration, } from '@simplewebauthn/browser'; -import { AUTH_PASSKEY } from 'utils/env'; - import { request } from '../api'; const DIALOG_ERRORS = ['ERROR_PASSTHROUGH_SEE_CAUSE_PROPERTY']; -export function canShowPasskey() { - return !!AUTH_PASSKEY; +export function canShowPasskey(meta) { + return !!meta?.auth?.passkey; } export async function login() { diff --git a/services/web/src/utils/env.js b/services/web/src/utils/env.js index 7d6efc85a..67d3e4994 100644 --- a/services/web/src/utils/env.js +++ b/services/web/src/utils/env.js @@ -9,7 +9,4 @@ export const { GOOGLE_API_KEY, GOOGLE_CLIENT_ID, APP_SUPPORT_EMAIL, - AUTH_TYPE, - AUTH_PASSKEY, - AUTH_CHANNEL, } = window.__ENV__;