mirror of https://github.com/requarks/wiki
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
474 lines
16 KiB
474 lines
16 KiB
import {
|
|
generateAuthenticationOptions,
|
|
generateRegistrationOptions,
|
|
verifyAuthenticationResponse,
|
|
verifyRegistrationResponse
|
|
} from '@simplewebauthn/server'
|
|
import { isoBase64URL } from '@simplewebauthn/server/helpers'
|
|
import { eq, sql } from 'drizzle-orm'
|
|
import { validate as uuidValidate } from 'uuid'
|
|
import { users as usersTable } from '../db/schema.ts'
|
|
import type {
|
|
AuthenticationResponseJSON,
|
|
AuthenticatorTransportFuture,
|
|
PublicKeyCredentialCreationOptionsJSON,
|
|
PublicKeyCredentialRequestOptionsJSON,
|
|
RegistrationResponseJSON
|
|
} from '@simplewebauthn/server'
|
|
import type { AfterLoginResult } from './users.ts'
|
|
|
|
/**
|
|
* One registered authenticator, as stored in the user's `passkeys` blob. Every binary value is held
|
|
* base64url-encoded, since this lives in a JSONB column.
|
|
*/
|
|
interface StoredPasskey {
|
|
/** The credential ID, which is also what the browser sends back to identify it. */
|
|
id: string
|
|
name: string
|
|
/** COSE public key, base64url-encoded. */
|
|
publicKey: string
|
|
/** Signature counter last reported by the authenticator, for replay detection. */
|
|
counter: number
|
|
transports?: AuthenticatorTransportFuture[]
|
|
createdAt: string
|
|
siteId: string
|
|
/** The hostname the credential is bound to. A passkey only works on the site it was created on. */
|
|
rpId: string
|
|
}
|
|
|
|
/**
|
|
* A ceremony waiting to be answered. Held on the session between the two requests a ceremony takes —
|
|
* see the note on `Session.passkeyLogin` in `types/fastify.d.ts` for why it cannot live anywhere else.
|
|
*/
|
|
export interface PasskeyChallenge {
|
|
challenge: string
|
|
rpId: string
|
|
origin: string
|
|
siteId: string
|
|
}
|
|
|
|
/** What a user's `passkeys` column holds: the credentials themselves, and nothing transient. */
|
|
interface PasskeyStore {
|
|
authenticators?: StoredPasskey[]
|
|
}
|
|
|
|
/** A passkey as the profile page lists it — never the key material. */
|
|
export interface PasskeyInfo {
|
|
id: string
|
|
name: string
|
|
siteHostname: string
|
|
createdAt: string
|
|
}
|
|
|
|
/**
|
|
* Hostnames a browser treats as a secure context without TLS, so that `http://localhost:3001` — the
|
|
* dev server — is a usable origin. Anything else has to be https, which is a WebAuthn requirement
|
|
* rather than a choice made here.
|
|
*/
|
|
const insecureOriginExceptions = new Set(['localhost', '127.0.0.1', '[::1]', '::1'])
|
|
|
|
/**
|
|
* The origin a passkey ceremony must be performed on.
|
|
*
|
|
* Taken from the request's own `Origin` header rather than assembled from the hostname, because the
|
|
* port is part of an origin and this instance does not know which one the browser reached it on. That
|
|
* is safe because the header is only trusted as far as it agrees with the host the request was
|
|
* addressed to: a page on another origin posting here would disagree, and is rejected. What the
|
|
* header cannot establish is that the connection was secure, so that is checked separately.
|
|
*
|
|
* @param origin The `Origin` header, if the client sent one
|
|
* @param hostname The host the request was addressed to, i.e. the RP ID
|
|
* @throws `ERR_PK_INSECURE_ORIGIN` for an origin that does not match, or that is not a secure context
|
|
*/
|
|
function resolveOrigin(origin: string | undefined, hostname: string): string {
|
|
// -> A client that sends no Origin at all is not a browser doing a WebAuthn ceremony, but it may
|
|
// still be a legitimate API client driving one, so the canonical https origin is assumed
|
|
if (!origin) {
|
|
return `https://${hostname}`
|
|
}
|
|
|
|
let parsed: URL
|
|
try {
|
|
parsed = new URL(origin)
|
|
} catch {
|
|
throw new Error('ERR_PK_INSECURE_ORIGIN')
|
|
}
|
|
if (parsed.hostname !== hostname) {
|
|
throw new Error('ERR_PK_INSECURE_ORIGIN')
|
|
}
|
|
if (parsed.protocol !== 'https:' && !insecureOriginExceptions.has(parsed.hostname)) {
|
|
throw new Error('ERR_PK_INSECURE_ORIGIN')
|
|
}
|
|
return parsed.origin
|
|
}
|
|
|
|
/**
|
|
* Passkeys (WebAuthn) model
|
|
*
|
|
* Credentials are stored in the user's `passkeys` JSONB column rather than a table of their own: they
|
|
* are only ever read for one user at a time, and they die with the account.
|
|
*/
|
|
class Passkeys {
|
|
/**
|
|
* The passkeys registered by a user, as the profile page lists them.
|
|
*/
|
|
async list(userId: string): Promise<PasskeyInfo[]> {
|
|
const store = await this.getStore(userId)
|
|
return (store.authenticators ?? []).map((pk) => ({
|
|
id: pk.id,
|
|
name: pk.name,
|
|
// -> The hostname it was registered against, not the site's current one: that is what the
|
|
// credential is actually bound to, and renaming a site does not move it
|
|
siteHostname: pk.rpId,
|
|
createdAt: pk.createdAt
|
|
}))
|
|
}
|
|
|
|
/**
|
|
* Options for registering a new passkey.
|
|
*
|
|
* @param userId The user registering it, who must be logged in
|
|
* @param hostname The host being browsed, which becomes the RP ID the credential is bound to
|
|
* @param origin The request's `Origin` header
|
|
* @returns The options to hand the browser, and the challenge to remember for
|
|
* `finalizeRegistration()`
|
|
* @throws `ERR_INVALID_USER`, `ERR_PK_HOSTNAME_MISSING` or `ERR_PK_INSECURE_ORIGIN`
|
|
*/
|
|
async startRegistration({
|
|
userId,
|
|
hostname,
|
|
origin
|
|
}: {
|
|
userId: string
|
|
hostname: string
|
|
origin?: string
|
|
}): Promise<{
|
|
registrationOptions: PublicKeyCredentialCreationOptionsJSON
|
|
pending: PasskeyChallenge
|
|
}> {
|
|
const user = await WIKI.models.users.getById(userId)
|
|
if (!user) {
|
|
throw new Error('ERR_INVALID_USER')
|
|
}
|
|
if (!hostname || hostname === '*') {
|
|
throw new Error('ERR_PK_HOSTNAME_MISSING')
|
|
}
|
|
const expectedOrigin = resolveOrigin(origin, hostname)
|
|
|
|
const site = await WIKI.models.sites.getSiteByHostname({ hostname })
|
|
const store = (user.passkeys ?? {}) as PasskeyStore
|
|
|
|
const options = await generateRegistrationOptions({
|
|
rpName: site?.config?.title || 'Wiki',
|
|
rpID: hostname,
|
|
// -> The user handle comes back on login as the only clue to who is signing in, so it is the
|
|
// user ID itself rather than a random value that would need a second lookup table
|
|
userID: new TextEncoder().encode(user.id),
|
|
userName: user.email,
|
|
userDisplayName: user.name,
|
|
attestationType: 'none',
|
|
authenticatorSelection: {
|
|
residentKey: 'required',
|
|
userVerification: 'preferred'
|
|
},
|
|
// -> Every credential the user already has, so the authenticator can refuse to enroll twice and
|
|
// the browser can say so before anything is stored
|
|
excludeCredentials: (store.authenticators ?? []).map((pk) => ({
|
|
id: pk.id,
|
|
transports: pk.transports
|
|
}))
|
|
})
|
|
|
|
return {
|
|
registrationOptions: options,
|
|
pending: {
|
|
challenge: options.challenge,
|
|
rpId: hostname,
|
|
origin: expectedOrigin,
|
|
siteId: site?.id ?? ''
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Verify what the authenticator produced and store the credential under the given name.
|
|
*
|
|
* @param pending The challenge `startRegistration()` handed out, off the session
|
|
* @throws `ERR_INVALID_USER`, `ERR_PASSKEY_NOT_SETUP`, `ERR_PK_NAME_MISSING_OR_INVALID`,
|
|
* `ERR_PK_ALREADY_REGISTERED` or `ERR_PK_VERIFICATION_FAILED`
|
|
*/
|
|
async finalizeRegistration({
|
|
userId,
|
|
name,
|
|
registrationResponse,
|
|
pending
|
|
}: {
|
|
userId: string
|
|
name: string
|
|
registrationResponse: RegistrationResponseJSON
|
|
pending?: PasskeyChallenge
|
|
}): Promise<PasskeyInfo> {
|
|
const user = await WIKI.models.users.getById(userId)
|
|
if (!user) {
|
|
throw new Error('ERR_INVALID_USER')
|
|
}
|
|
if (!pending) {
|
|
throw new Error('ERR_PASSKEY_NOT_SETUP')
|
|
}
|
|
const store = (user.passkeys ?? {}) as PasskeyStore
|
|
const trimmedName = (name ?? '').trim()
|
|
if (trimmedName.length < 1 || trimmedName.length > 255) {
|
|
throw new Error('ERR_PK_NAME_MISSING_OR_INVALID')
|
|
}
|
|
|
|
let verification
|
|
try {
|
|
verification = await verifyRegistrationResponse({
|
|
response: registrationResponse,
|
|
expectedChallenge: pending.challenge,
|
|
expectedOrigin: pending.origin,
|
|
expectedRPID: pending.rpId,
|
|
// -> Matches the `preferred` asked for above: an authenticator that has no way to verify the
|
|
// user is still worth registering, and requiring it here would reject exactly those
|
|
requireUserVerification: false
|
|
})
|
|
} catch (err: any) {
|
|
WIKI.models.flags.authDebug(
|
|
`Passkey registration for user ${user.id} failed verification: ${err.message}`
|
|
)
|
|
throw new Error('ERR_PK_VERIFICATION_FAILED')
|
|
}
|
|
if (!verification.verified) {
|
|
throw new Error('ERR_PK_VERIFICATION_FAILED')
|
|
}
|
|
|
|
const { credential } = verification.registrationInfo
|
|
const authenticators = store.authenticators ?? []
|
|
if (authenticators.some((pk) => pk.id === credential.id)) {
|
|
throw new Error('ERR_PK_ALREADY_REGISTERED')
|
|
}
|
|
|
|
const passkey: StoredPasskey = {
|
|
id: credential.id,
|
|
name: trimmedName,
|
|
publicKey: isoBase64URL.fromBuffer(credential.publicKey),
|
|
counter: credential.counter,
|
|
transports: registrationResponse.response.transports,
|
|
createdAt: Temporal.Now.instant().toString({ smallestUnit: 'millisecond' }),
|
|
siteId: pending.siteId,
|
|
rpId: pending.rpId
|
|
}
|
|
|
|
await this.saveStore(user.id, { authenticators: [...authenticators, passkey] })
|
|
|
|
WIKI.models.flags.authDebug(
|
|
`User ${user.id} <${user.email}> registered passkey "${trimmedName}" on ${pending.rpId}`
|
|
)
|
|
|
|
return {
|
|
id: passkey.id,
|
|
name: passkey.name,
|
|
siteHostname: passkey.rpId,
|
|
createdAt: passkey.createdAt
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Forget a passkey. The credential itself lives on the user's device and has to be removed there
|
|
* too, which is what the client says when this succeeds.
|
|
*
|
|
* @returns False if the user has no such passkey
|
|
*/
|
|
async remove(userId: string, passkeyId: string): Promise<boolean> {
|
|
const store = await this.getStore(userId)
|
|
const authenticators = store.authenticators ?? []
|
|
const remaining = authenticators.filter((pk) => pk.id !== passkeyId)
|
|
if (remaining.length === authenticators.length) {
|
|
return false
|
|
}
|
|
await this.saveStore(userId, { ...store, authenticators: remaining })
|
|
WIKI.models.flags.authDebug(`User ${userId} removed a passkey`)
|
|
return true
|
|
}
|
|
|
|
/**
|
|
* Options for logging in with a passkey.
|
|
*
|
|
* Nobody is named here, and no `allowCredentials` list is sent: every passkey is registered as a
|
|
* discoverable credential, so the authenticator offers whichever ones it holds for this host and the
|
|
* assertion says who signed. That is what makes a passkey login one gesture — there is nothing to ask
|
|
* the user first, and no lookup that could reveal whether an address has an account.
|
|
*
|
|
* @returns The options to hand the browser, and the challenge to remember for `verifyLogin()`
|
|
* @throws `ERR_PK_HOSTNAME_MISSING` or `ERR_PK_INSECURE_ORIGIN`
|
|
*/
|
|
async startLogin({ hostname, origin }: { hostname: string; origin?: string }): Promise<{
|
|
authOptions: PublicKeyCredentialRequestOptionsJSON
|
|
pending: PasskeyChallenge
|
|
}> {
|
|
if (!hostname || hostname === '*') {
|
|
throw new Error('ERR_PK_HOSTNAME_MISSING')
|
|
}
|
|
const expectedOrigin = resolveOrigin(origin, hostname)
|
|
|
|
const options = await generateAuthenticationOptions({
|
|
rpID: hostname,
|
|
userVerification: 'preferred'
|
|
})
|
|
|
|
return {
|
|
authOptions: options,
|
|
pending: {
|
|
challenge: options.challenge,
|
|
rpId: hostname,
|
|
origin: expectedOrigin,
|
|
siteId: (await WIKI.models.sites.getSiteByHostname({ hostname }))?.id ?? ''
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Verify a passkey login and, if it holds up, log the user in.
|
|
*
|
|
* A passkey establishes both who the user is and that they were present, so this does not go on to
|
|
* ask for a password or a 2FA code. The account checks the password strategy performs still apply —
|
|
* a deactivated account cannot be signed into with a key either.
|
|
*
|
|
* Who signed comes out of the assertion's user handle, which is the only way this can work: the
|
|
* challenge was handed out before anyone was named.
|
|
*
|
|
* @param pending The challenge `startLogin()` handed out, off the session
|
|
* @returns The same shape a password login returns, so the client handles both the same way
|
|
* @throws `ERR_LOGIN_FAILED`, `ERR_INACTIVE_USER` or `ERR_USER_NOT_VERIFIED`
|
|
*/
|
|
async verifyLogin(
|
|
{
|
|
authResponse,
|
|
pending,
|
|
ip
|
|
}: {
|
|
authResponse: AuthenticationResponseJSON
|
|
pending?: PasskeyChallenge
|
|
ip?: string
|
|
},
|
|
req: any
|
|
): Promise<AfterLoginResult> {
|
|
if (!pending) {
|
|
WIKI.models.flags.authDebug(
|
|
'Passkey login rejected: no challenge outstanding on this session'
|
|
)
|
|
throw new Error('ERR_LOGIN_FAILED')
|
|
}
|
|
|
|
const userHandle = authResponse.response?.userHandle
|
|
if (!userHandle) {
|
|
WIKI.models.flags.authDebug('Passkey login rejected: the response carried no user handle')
|
|
throw new Error('ERR_LOGIN_FAILED')
|
|
}
|
|
|
|
// -> The handle is the user ID this server encoded at registration, so anything else is not a
|
|
// credential of ours. Checked for shape before it is looked up: postgres rejects a malformed
|
|
// uuid with an error of its own, which would turn a rejected login into a logged fault.
|
|
let userId: string
|
|
try {
|
|
userId = isoBase64URL.toUTF8String(userHandle)
|
|
} catch {
|
|
throw new Error('ERR_LOGIN_FAILED')
|
|
}
|
|
if (!uuidValidate(userId)) {
|
|
WIKI.models.flags.authDebug('Passkey login rejected: the user handle is not one of ours')
|
|
throw new Error('ERR_LOGIN_FAILED')
|
|
}
|
|
|
|
const user = await WIKI.models.users.getById(userId)
|
|
if (!user) {
|
|
WIKI.models.flags.authDebug(`Passkey login rejected: no user ${userId}`)
|
|
throw new Error('ERR_LOGIN_FAILED')
|
|
}
|
|
const store = (user.passkeys ?? {}) as PasskeyStore
|
|
const passkey = (store.authenticators ?? []).find((pk) => pk.id === authResponse.id)
|
|
if (!passkey) {
|
|
WIKI.models.flags.authDebug(
|
|
`Passkey login rejected: credential ${authResponse.id} is not registered for user ${userId}`
|
|
)
|
|
throw new Error('ERR_LOGIN_FAILED')
|
|
}
|
|
|
|
let verification
|
|
try {
|
|
verification = await verifyAuthenticationResponse({
|
|
response: authResponse,
|
|
expectedChallenge: pending.challenge,
|
|
expectedOrigin: pending.origin,
|
|
expectedRPID: pending.rpId,
|
|
// -> As at registration: the ceremony asked for `preferred`, so requiring it here would turn
|
|
// an authenticator that cannot verify into a login that never succeeds
|
|
requireUserVerification: false,
|
|
credential: {
|
|
id: passkey.id,
|
|
publicKey: isoBase64URL.toBuffer(passkey.publicKey),
|
|
counter: passkey.counter,
|
|
transports: passkey.transports
|
|
}
|
|
})
|
|
} catch (err: any) {
|
|
WIKI.models.flags.authDebug(
|
|
`Passkey login for user ${userId} failed to verify: ${err.message}`
|
|
)
|
|
throw new Error('ERR_LOGIN_FAILED')
|
|
}
|
|
if (!verification.verified) {
|
|
throw new Error('ERR_LOGIN_FAILED')
|
|
}
|
|
|
|
// -> The counter has to be stored for the replay check to mean anything next time
|
|
await this.saveStore(user.id, {
|
|
authenticators: (store.authenticators ?? []).map((pk) =>
|
|
pk.id === passkey.id ? { ...pk, counter: verification.authenticationInfo.newCounter } : pk
|
|
)
|
|
})
|
|
|
|
// -> Checks the password strategy would have made, which a passkey login would otherwise skip
|
|
if (!user.isActive) {
|
|
throw new Error('ERR_INACTIVE_USER')
|
|
}
|
|
if (!user.isVerified) {
|
|
throw new Error('ERR_USER_NOT_VERIFIED')
|
|
}
|
|
|
|
WIKI.models.flags.authDebug(
|
|
`User ${user.id} <${user.email}> authenticated with passkey "${passkey.name}"`
|
|
)
|
|
|
|
// -> Attributed to the local strategy, which is where an account's own credentials belong. Neither
|
|
// a password change nor a 2FA code is asked for on top of a passkey.
|
|
return WIKI.models.users.afterLoginChecks(
|
|
user,
|
|
WIKI.data.systemIds.localAuthId,
|
|
{ ip, siteId: pending.siteId },
|
|
{ skipTFA: true, skipChangePwd: true },
|
|
req
|
|
)
|
|
}
|
|
|
|
/**
|
|
* The stored blob for a user, or an empty one for a user who has never registered a passkey.
|
|
*/
|
|
async getStore(userId: string): Promise<PasskeyStore> {
|
|
const user = await WIKI.models.users.getById(userId)
|
|
return (user?.passkeys ?? {}) as PasskeyStore
|
|
}
|
|
|
|
/**
|
|
* Replace a user's stored passkey blob.
|
|
*/
|
|
async saveStore(userId: string, store: PasskeyStore): Promise<void> {
|
|
await WIKI.db
|
|
.update(usersTable)
|
|
.set({ passkeys: { authenticators: store.authenticators ?? [] }, updatedAt: sql`now()` })
|
|
.where(eq(usersTable.id, userId))
|
|
}
|
|
}
|
|
|
|
export const passkeys = new Passkeys()
|