Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 11 additions & 8 deletions docs/contributing/architecture/authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -206,12 +206,14 @@ Both are opt-in and adapted from the Epic Stack.
holds a pending setup that only activates once the user confirms a generated
code at `/account/two-factor` (managed by
`packages/worker/src/app/handlers/account-two-factor.ts`).
- When a 2FA account logs in (password or passkey), the handler does **not**
issue `kody_session`. It sets the short-lived signed `kody_verify` cookie
(`packages/worker/src/app/verify-session.ts`, 10 minutes) and the client
redirects to `/verify`. `POST /verify/2fa.json`
- When a 2FA account logs in with a password (or social login), the handler does
**not** issue `kody_session`. It sets the short-lived signed `kody_verify`
cookie (`packages/worker/src/app/verify-session.ts`, 10 minutes) and the
client redirects to `/verify`. `POST /verify/2fa.json`
(`packages/worker/src/app/handlers/verify.ts`) checks the TOTP code and only
then issues the real session cookie.
then issues the real session cookie. Passkey sign-in skips this step: a
verified WebAuthn assertion already requires possession of the authenticator
plus user verification (biometric/PIN), so it is treated as MFA-complete.
- Disabling 2FA requires a fresh code. The inline OAuth password form
(`packages/worker/src/oauth-handlers.ts`) rejects 2FA accounts and directs
them to establish a browser session first, since that flow has no TOTP step.
Expand All @@ -227,8 +229,9 @@ Both are opt-in and adapted from the Epic Stack.
registrable domain, so Playwright passkey tests navigate via `localhost`
rather than `127.0.0.1`.
- Passkeys are stored per user in the `passkeys` table and managed at
`/account/passkeys`. Passkey sign-in is an alternative first factor: accounts
with TOTP enabled still get the `/verify` challenge.
`/account/passkeys`. Passkey sign-in is MFA-complete on its own
(`userVerification: 'required'`): accounts with TOTP enabled go straight to a
session and do not visit `/verify`.
- `POST /verify/2fa.json`, `POST /account/two-factor.json`, and
`POST /webauthn/authentication` share the per-IP auth rate-limit bucket with
the other credential-accepting endpoints (`packages/worker/src/index.ts`).
Expand Down Expand Up @@ -339,7 +342,7 @@ definitions in `packages/worker/src/app/oauth-providers.ts`.
authorize URL itself, because the CSP locks `form-action` and `connect-src` to
`'self'`
- Existing connections sign in directly; the two-factor gate applies exactly as
for password and passkey logins
for password logins (passkey sign-in skips TOTP)
- A signed-in user hitting the callback links the provider identity to their
account, managed from the `/account` "Connected accounts" card backed by
`/account/connections.json` (disconnect is refused when the connection is the
Expand Down
4 changes: 2 additions & 2 deletions docs/contributing/architecture/primitives.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -95,8 +95,8 @@ primitives:
(GitHub/Google/X OAuth connections in oauth_connections, mockable via
MOCK_ client ids on non-production runtimes), and opt-in second factors —
TOTP two-factor (verifications table, pending kody_verify cookie gates
login until /verify) and passkeys (WebAuthn sign-in as an alternative
first factor that still honors 2FA).
password/social login until /verify) and passkeys (WebAuthn sign-in with
required user verification; treated as MFA-complete, so TOTP is skipped).
code:
- packages/worker/src/app/auth-session.ts
- packages/worker/src/app/handlers/auth.ts
Expand Down
2 changes: 1 addition & 1 deletion docs/contributing/social-login.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ Callback resolution order:
switch; callback errors for signed-in users redirect to
`/account?oauthError=<code>`.
2. A known connection signs in its user (the two-factor gate applies exactly as
it does for password and passkey logins).
it does for password logins; passkey sign-in skips TOTP).
3. A **provider-verified** email matching an existing account links the identity
and signs that account in (this also marks the account email verified, since
the provider asserted ownership of the same address).
Expand Down
10 changes: 2 additions & 8 deletions e2e/passkeys.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ test('passkey lifecycle: register, sign in, delete', async ({
).toBeVisible()
})

test('passkey sign-in still challenges accounts with two-factor enabled', async ({
test('passkey sign-in skips TOTP when two-factor is enabled', async ({
page,
seedE2eUser,
login,
Expand Down Expand Up @@ -102,17 +102,11 @@ test('passkey sign-in still challenges accounts with two-factor enabled', async
await page.getByRole('button', { name: 'Register a passkey' }).click()
await expect(page.getByText('Passkey registered.')).toBeVisible()

// Passkey sign-in must still require the TOTP second factor.
// A verified passkey already satisfies MFA, so skip the TOTP challenge.
await page.context().clearCookies()
clearAuthRateLimitsInE2eDatabase()
await page.goto(localhostUrl(baseURL, '/login'))
await page.getByRole('button', { name: 'Sign in with a passkey' }).click()
await expect(page).toHaveURL(/\/verify$/)

await page
.getByLabel('Verification code')
.fill((await generateTOTP({ secret })).otp)
await page.getByRole('button', { name: 'Verify' }).click()
await expect(page).toHaveURL(/\/account$/)
await expect(page.getByText(`Email: ${user.email}`)).toBeVisible()
})
4 changes: 2 additions & 2 deletions packages/worker/client/routes/account-passkeys.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -292,8 +292,8 @@ export function AccountPasskeysRoute(handle: Handle) {
<h2 mix={css(cardTitleCss)}>Register a passkey</h2>
<p mix={css(descriptionCss)}>
Your browser will prompt you to create a passkey with your
device. Accounts with two-factor authentication enabled still
require a code when signing in with a passkey.
device. Passkey sign-in skips the two-factor code because the
passkey itself already satisfies multi-factor authentication.
</p>
<div>
<button
Expand Down
2 changes: 0 additions & 2 deletions packages/worker/src/app/handlers/passkeys.node.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ import {
createWebauthnAuthenticationHandler,
createWebauthnRegistrationHandler,
} from '#app/handlers/webauthn.ts'
import { setVerifySessionSecret } from '#app/verify-session.ts'
import { createStableUserIdFromEmail } from '#worker/user-id.ts'
import { createPasswordHash } from '@kody-internal/shared/password-hash.ts'

Expand Down Expand Up @@ -190,7 +189,6 @@ async function runHandler(

function initTestSecrets() {
setAuthSessionSecret(testCookieSecret)
setVerifySessionSecret(testCookieSecret)
}

const userOneSession: AuthSession = {
Expand Down
48 changes: 6 additions & 42 deletions packages/worker/src/app/handlers/webauthn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ import { type Action } from 'remix/router'
import { getRequestIp, logAuditEvent } from '#app/audit-log.ts'
import {
createAuthCookie,
destroyAuthCookie,
isSecureRequest,
setAuthSessionSecret,
} from '#app/auth-session.ts'
Expand All @@ -24,11 +23,6 @@ import {
updatePasskeyCounter,
} from '#app/passkeys.ts'
import { type routes } from '#app/routes.ts'
import { isTwoFactorEnabled } from '#app/two-factor.ts'
import {
createVerifySessionCookie,
setVerifySessionSecret,
} from '#app/verify-session.ts'
import {
createWebAuthnChallengeCookie,
destroyWebAuthnChallengeCookie,
Expand Down Expand Up @@ -66,9 +60,9 @@ export function createWebauthnRegistrationHandler(env: Env) {
excludeCredentials: existingPasskeys.map((passkey) => ({
id: passkey.id,
})),
// Passkeys act as a full first factor here, so user verification
// is required end-to-end (matching requireUserVerification below;
// `preferred` could register credentials the server then rejects).
// Passkeys are MFA-complete here, so user verification is required
// end-to-end (matching requireUserVerification below; `preferred`
// could register credentials the server then rejects).
authenticatorSelection: {
residentKey: 'preferred',
userVerification: 'required',
Expand Down Expand Up @@ -205,7 +199,6 @@ export function createWebauthnAuthenticationHandler(env: Env) {
async handler({ request, url }) {
setWebAuthnChallengeSecret(env.COOKIE_SECRET)
setAuthSessionSecret(env.COOKIE_SECRET)
setVerifySessionSecret(env.COOKIE_SECRET)

const config = getWebAuthnConfig(request)
const secure = isSecureRequest(request)
Expand Down Expand Up @@ -329,38 +322,9 @@ export function createWebauthnAuthenticationHandler(env: Env) {
})
headers.append('Set-Cookie', clearChallengeCookie)

// Passkeys are an alternative first factor: accounts with TOTP
// enabled still get the second-factor challenge (Epic Stack parity).
if (await isTwoFactorEnabled(env.APP_DB, userRecord.id)) {
headers.append(
'Set-Cookie',
await createVerifySessionCookie(
{
id: String(userRecord.id),
email: userRecord.email,
rememberMe,
},
secure,
),
)
// A pre-existing session must not stay usable while the second
// factor is still pending for this new login.
headers.append('Set-Cookie', await destroyAuthCookie(secure))
void logAuditEvent({
category: 'auth',
action: 'login_2fa_challenge',
result: 'success',
email: userRecord.email,
ip: requestIp,
path: url.pathname,
reason: 'passkey',
})
return new Response(
JSON.stringify({ ok: true, requiresTwoFactor: true }),
{ status: 200, headers },
)
}

// A verified passkey assertion already satisfies MFA (possession +
// user verification), so skip the TOTP challenge even when 2FA is
// enabled. Password and social logins still require it.
headers.append(
'Set-Cookie',
await createAuthCookie(
Expand Down
Loading