Created
June 6, 2024 14:29
-
-
Save Radiergummi/c60e477d067873d0e157e0eea4cbbf88 to your computer and use it in GitHub Desktop.
Supabase WebAuthn implementation
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import type { Client, Database } from '$lib/server/database'; | |
import type { Insertable, Selectable } from 'kysely'; | |
const table = 'authentication.challenge' as const; | |
export async function resolveCurrentChallenge( | |
client: Client, | |
identifier: string | |
) { | |
const { challenge, expires_at } = await client | |
.selectFrom(table) | |
.select(['expires_at', 'challenge']) | |
.where('session_identifier', '=', identifier) | |
.orderBy('created_at', 'desc') | |
.limit(1) | |
.executeTakeFirstOrThrow(); | |
if (new Date(expires_at) <= new Date()) { | |
throw new Error('Challenge has expired'); | |
} | |
return challenge; | |
} | |
export async function findChallengeByIdentifier( | |
client: Client, | |
identifier: string | |
) { | |
return await client | |
.selectFrom(table) | |
.selectAll() | |
.where('session_identifier', '=', identifier) | |
.orderBy('created_at', 'desc') | |
.limit(1) | |
.executeTakeFirstOrThrow(); | |
} | |
export async function createChallenge( | |
client: Client, | |
data: Insertable<Table>, | |
) { | |
return await client | |
.insertInto(table) | |
.values(data) | |
.executeTakeFirstOrThrow(); | |
} | |
export function deleteChallenges(client: Client, identifier: string) { | |
return client | |
.deleteFrom(table) | |
.where('session_identifier', '=', identifier) | |
.executeTakeFirstOrThrow(); | |
} | |
type Table = Database[typeof table]; | |
export type Challenge = Selectable<Table>; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { Buffer } from 'node:buffer'; | |
import { generateAuthenticationOptions, type GenerateAuthenticationOptionsOpts } from '@simplewebauthn/server'; | |
import { error, json, type RequestHandler } from '@sveltejs/kit'; | |
import { getAuthSessionIdFromCookie, resolveUserId } from '$lib/server/auth/utilities'; | |
import { createChallenge } from '$lib/server/data/authentication/challenge'; | |
import { listAuthenticatorsForUser } from '$lib/server/data/authentication/authenticator'; | |
import { findUserByIdentifier } from '$lib/server/data/authentication/user'; | |
export const GET: RequestHandler = async function handler({ | |
url, | |
cookies, | |
locals: { database } | |
}) { | |
const userId = resolveUserId(cookies); | |
const sessionId = getAuthSessionIdFromCookie(cookies); | |
if (!sessionId) { | |
throw error(403, { | |
title: 'Not authorized', | |
message: 'Session ID cookie is missing or invalid' | |
}); | |
} | |
const options: GenerateAuthenticationOptionsOpts = { | |
userVerification: 'required', | |
rpID: url.hostname, | |
timeout: 60_000 | |
}; | |
if (userId) { | |
const user = await findUserByIdentifier(database, userId); | |
const authenticators = await listAuthenticatorsForUser(database, user); | |
options.allowCredentials = authenticators.map(({ identifier, transports }) => ({ | |
type: 'public-key', | |
id: Buffer.from(identifier, 'base64url'), | |
transports | |
})); | |
} | |
const responseData = await generateAuthenticationOptions(options); | |
const timeout = responseData.timeout || options.timeout || 60_000; | |
await createChallenge(database, { | |
challenge: responseData.challenge, | |
expires_at: new Date(+new Date() + timeout).toISOString(), | |
session_identifier: sessionId | |
}); | |
return json(responseData); | |
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { Buffer } from 'node:buffer'; | |
import { error, json, type RequestHandler } from '@sveltejs/kit'; | |
import type { VerifiedAuthenticationResponse, VerifyAuthenticationResponseOpts } from '@simplewebauthn/server'; | |
import { verifyAuthenticationResponse } from '@simplewebauthn/server'; | |
import type { AuthenticationResponseJSON } from '@simplewebauthn/types'; | |
import jwt from 'jsonwebtoken'; | |
import { NoResultError } from 'kysely'; | |
import { env } from '$env/dynamic/private'; | |
import { getAuthSessionIdFromCookie, setJwtCookie } from '$lib/server/auth/utilities'; | |
import { | |
type Authenticator, | |
findAuthenticatorByIdentifier, | |
updateAuthenticator | |
} from '$lib/server/data/authentication/authenticator'; | |
import { deleteChallenges, resolveCurrentChallenge } from '$lib/server/data/authentication/challenge'; | |
export const POST: RequestHandler = async function handler({ | |
url, | |
request, | |
cookies, | |
locals: { database } | |
}) { | |
const sessionId = getAuthSessionIdFromCookie(cookies); | |
if (!sessionId) { | |
throw error(401, 'Not authenticated'); | |
} | |
let challenge: string; | |
try { | |
challenge = await resolveCurrentChallenge(database, sessionId); | |
} catch (err) { | |
if (!(err instanceof Error)) { | |
throw err; | |
} | |
throw error(400, `Failed to resolve challenge: ${err.message}`); | |
} | |
let response: AuthenticationResponseJSON; | |
try { | |
response = await request.json() as AuthenticationResponseJSON; | |
} catch (err) { | |
if (!(err instanceof Error)) { | |
throw err; | |
} | |
await deleteChallenges(database, sessionId); | |
return error(400, `Invalid request body: ${err.message}`); | |
} | |
const userId = response.response.userHandle; | |
if (!userId) { | |
await deleteChallenges(database, sessionId); | |
return error(400, `Invalid payload: Missing user handle`); | |
} | |
let authenticator: Authenticator | null; | |
try { | |
authenticator = await findAuthenticatorByIdentifier( | |
database, | |
response.rawId | |
); | |
} catch (err) { | |
if (!(err instanceof NoResultError)) { | |
await deleteChallenges(database, sessionId); | |
throw err; | |
} | |
authenticator = null; | |
} | |
if (!authenticator || authenticator.user_id !== userId) { | |
await deleteChallenges(database, sessionId); | |
return error(400, 'Authenticator is not registered with this site'); | |
} | |
let verification: VerifiedAuthenticationResponse; | |
try { | |
verification = await verifyAuthenticationResponse({ | |
response, | |
expectedChallenge: `${challenge}`, | |
expectedOrigin: url.origin, // <-- TODO: Use origin from RP ID instead | |
expectedRPID: url.hostname, // <-- TODO: Use hostname from env instead | |
authenticator: { | |
credentialPublicKey: Buffer.from(authenticator.public_key, 'base64url'), | |
credentialID: Buffer.from(authenticator.identifier, 'base64url'), | |
counter: Number(authenticator.counter), | |
transports: authenticator.transports | |
}, | |
requireUserVerification: true | |
} satisfies VerifyAuthenticationResponseOpts); | |
} catch (err) { | |
if (!(err instanceof Error)) { | |
throw err; | |
} | |
await deleteChallenges(database, sessionId); | |
return error(400, err.message); | |
} | |
const { verified, authenticationInfo } = verification; | |
const { newCounter: counter } = authenticationInfo; | |
if (verified) { | |
// Update the authenticator's counter in the DB to the newest count in the authentication | |
await updateAuthenticator(database, response.rawId, { | |
counter: counter.toString(), | |
last_used_at: new Date() | |
}); | |
} | |
await deleteChallenges(database, sessionId); | |
// Sign the user token: We have authenticated the user successfully using the passcode, so they | |
// may use this JWT to create their pass *key*. | |
const token = jwt.sign({ | |
authenticator: authenticator.id | |
}, env.JWT_SECRET, { | |
subject: authenticator.user_id | |
}); | |
// Set the cookie on the response: It will be included in any requests to the server, including | |
// for tRPC. This makes for a nice, transparent, and "just works" authentication scheme. | |
setJwtCookie(cookies, token); | |
return json({ verified }); | |
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<script lang="ts"> | |
import { onMount } from 'svelte'; | |
import { browserSupportsWebAuthn, startRegistration } from '@simplewebauthn/browser'; | |
import type { RegistrationResponseJSON } from '@simplewebauthn/types'; | |
import { goto } from '$app/navigation'; | |
import type { VerificationResponseJSON } from './verify/+server'; | |
import type { PageData } from './$types'; | |
export let data: PageData; | |
let webAuthnSupported: boolean = true; | |
let registered: boolean = false; | |
let passkeyError: { title?: string; message: string } | null; | |
$: passkeyError = data.error ?? null; | |
onMount(() => webAuthnSupported = browserSupportsWebAuthn()); | |
async function init() { | |
const options = data.options; | |
if (!options) { | |
throw new Error('Unexpected state: No attestation options in page data'); | |
} | |
let attestationData: RegistrationResponseJSON; | |
try { | |
attestationData = await startRegistration(data.options); | |
} catch (error) { | |
if (!(error instanceof Error)) { | |
throw error; | |
} | |
console.error('Failed to create passkey', { error }); | |
if (error.name === 'InvalidStateError') { | |
passkeyError = { | |
message: 'You already have a passkey registered. Please try to register another ' + | |
'passkey after signing in with your existing one.' | |
}; | |
} else if (error.name === 'NotAllowedError') { | |
passkeyError = { | |
title: 'Permission denied', | |
message: 'You denied the request to create a passkey. ' + | |
'If this was by mistake, please try again.' | |
}; | |
} else { | |
passkeyError = { | |
message: `An unexpected error occurred while creating your passkey: ${error.message}. ` + | |
'Please try again.' | |
}; | |
} | |
return; | |
} | |
// TODO: Do we actually want this? | |
options.authenticatorSelection!.residentKey = 'required'; | |
options.authenticatorSelection!.requireResidentKey = true; | |
options.extensions = { credProps: true }; | |
let verificationResponseData: VerificationResponseJSON; | |
try { | |
const verificationResponse = await fetch('/auth/attestation/verify', { | |
method: 'POST', | |
headers: { 'Content-Type': 'application/json' }, | |
body: JSON.stringify(attestationData) | |
}); | |
verificationResponseData = await verificationResponse.json() as VerificationResponseJSON; | |
} catch (error) { | |
if (!(error instanceof Error)) { | |
throw error; | |
} | |
console.error('Failed to verify attestation data', { error }); | |
passkeyError = { | |
title: 'Verification failed', | |
message: `An error occurred while verifying your passkey: ${error.message}. ` + | |
'Please refresh the page and try again.' | |
}; | |
return; | |
} | |
if (!(verificationResponseData && verificationResponseData.verified)) { | |
console.error('Failed to verify attestation data: Server did not report success', { | |
response: verificationResponseData | |
}); | |
passkeyError = { | |
title: 'Verification failed', | |
message: 'An unexpected error occurred while verifying your passkey. ' + | |
'Please try again.' | |
}; | |
return; | |
} | |
registered = true; | |
return goto('/'); | |
} | |
function skip() { | |
return goto('/'); | |
} | |
</script> | |
<h1>Add a passkey</h1> | |
{#if !webAuthnSupported} | |
<span>Your internet browser does not support Passkeys :(</span> | |
{/if} | |
{#if !registered} | |
<p class="mb-4 max-w-lg text-gray-500"> | |
To sign you in automatically and securely next time, create a passkey by | |
clicking the button below. | |
</p> | |
<div class="flex items-center"> | |
<button on:click={init}>Create Passkey</button> | |
<button class="ml-4" on:click={skip}>Skip for now</button> | |
</div> | |
{#if passkeyError && passkeyError.message} | |
<div class="mt-4 text-red-500"> | |
<div class="flex flex-col"> | |
{#if passkeyError.title} | |
<strong>{passkeyError.title}</strong> | |
{/if} | |
<span>{passkeyError.message}</span> | |
</div> | |
</div> | |
{/if} | |
{:else} | |
<span>You're all set.</span> | |
{/if} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { browser } from '$app/environment'; | |
import type { PublicKeyCredentialCreationOptionsJSON } from '@simplewebauthn/types'; | |
import type { PageLoad } from './$types'; | |
export const load: PageLoad = async function load({ fetch, parent }) { | |
const data = await parent(); | |
// This should be expected during SSR | |
if (!browser) { | |
return { ...data, error: null }; | |
} | |
let options: PublicKeyCredentialCreationOptionsJSON; | |
try { | |
const attestationResponse = await fetch('/auth/attestation/generate'); | |
options = await attestationResponse.json() as PublicKeyCredentialCreationOptionsJSON; | |
} catch (error) { | |
console.error('Failed to generate attestation options', { error }); | |
return { | |
...data, | |
error: { | |
message: 'An error occurred while initializing your passkey. ' + | |
'Please refresh the page and try again.' | |
} | |
}; | |
} | |
return { ...data, options }; | |
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { Buffer } from 'node:buffer'; | |
import { env } from '$env/dynamic/private'; | |
import { error, json } from '@sveltejs/kit'; | |
import type { GenerateRegistrationOptionsOpts } from '@simplewebauthn/server'; | |
import { generateRegistrationOptions } from '@simplewebauthn/server'; | |
import { errorResponse } from '$lib/server/utilities'; | |
import { getAuthSessionIdFromCookie, resolveUserId } from '$lib/server/auth/utilities'; | |
import { listAuthenticatorsForUser } from '$lib/server/data/authentication/authenticator'; | |
import { createChallenge } from '$lib/server/data/authentication/challenge'; | |
import { findUserByIdentifier } from '$lib/server/data/authentication/user'; | |
import type { RequestHandler } from './$types'; | |
export const GET: RequestHandler = async function handler({ url, cookies, locals: { database } }) { | |
const sessionId = getAuthSessionIdFromCookie(cookies); | |
if (!sessionId) { | |
throw error(403, 'Not authorized'); | |
} | |
const userId = resolveUserId(cookies); | |
if (!userId) { | |
return errorResponse(401, 'Not authenticated'); | |
} | |
const user = await findUserByIdentifier(database, userId); | |
const authenticators = await listAuthenticatorsForUser(database, user); | |
const options = await generateRegistrationOptions({ | |
rpName: env.FIDO_NAME || 'Kiosk', | |
rpID: url.hostname, | |
userID: user.id, | |
userName: user.email, | |
userDisplayName: user.name || user.email, | |
timeout: 60_000, | |
attestationType: 'none', | |
/** | |
* Passing in a user's list of already-registered authenticator IDs here prevents users from | |
* registering the same device multiple times. The authenticator will simply throw an error in | |
* the browser if it's asked to perform registration when one of these ID's already resides | |
* on it. | |
*/ | |
excludeCredentials: authenticators.map(({ identifier, transports }) => ({ | |
id: Buffer.from(identifier, 'base64url'), | |
type: 'public-key', | |
transports | |
})), | |
/** | |
* The optional authenticatorSelection property allows for specifying more the types of | |
* authenticators that users to can use for registration | |
*/ | |
authenticatorSelection: { | |
residentKey: 'required', | |
userVerification: 'preferred' | |
}, | |
/** | |
* Support the two most common algorithms: ES256, and RS256 | |
*/ | |
supportedAlgorithmIDs: [-7, -257] | |
} satisfies GenerateRegistrationOptionsOpts); | |
const timeout = options.timeout || 60_000; | |
/** | |
* The server needs to temporarily remember this value for verification, so don't lose it until | |
* after you verify an authenticator response. | |
*/ | |
await createChallenge(database, { | |
challenge: options.challenge, | |
expires_at: new Date(+new Date() + timeout), | |
session_identifier: sessionId, | |
}); | |
return json(options); | |
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { Buffer } from 'node:buffer'; | |
import type { VerifiedRegistrationResponse, VerifyRegistrationResponseOpts } from '@simplewebauthn/server'; | |
import { verifyRegistrationResponse } from '@simplewebauthn/server'; | |
import type { RegistrationResponseJSON } from '@simplewebauthn/types'; | |
import { error, json type RequestHandler } from '@sveltejs/kit'; | |
import parseUserAgent from 'ua-parser-js'; | |
import { getAuthSessionIdFromCookie, resolveUserId } from '$lib/server/auth/utilities'; | |
import { deleteChallenges, resolveCurrentChallenge } from '$lib/server/data/authentication/challenge'; | |
import { createAuthenticator, listAuthenticatorsForUser } from '$lib/server/data/authentication/authenticator'; | |
import { findUserByIdentifier } from '$lib/server/data/authentication/user'; | |
export const POST: RequestHandler = async function handler({ | |
url, | |
request, | |
cookies, | |
locals: { database } | |
}) { | |
const sessionId = getAuthSessionIdFromCookie(cookies); | |
if (!sessionId) { | |
throw error(403, { | |
title: 'Not authorized', | |
message: 'Session ID cookie is missing or invalid' | |
}); | |
} | |
const userId = resolveUserId(cookies); | |
if (!userId) { | |
throw error(401, 'Not authenticated'); | |
} | |
const user = await findUserByIdentifier(database, userId); | |
if (!user) { | |
throw error(401, 'Not authenticated'); | |
} | |
let expectedChallenge: string; | |
try { | |
expectedChallenge = await resolveCurrentChallenge(database, sessionId); | |
} catch (err) { | |
if (!(err instanceof Error)) { | |
throw err; | |
} | |
await deleteChallenges(database, sessionId); | |
throw error(400, `Failed to resolve challenge: ${err.message}`); | |
} | |
let response: RegistrationResponseJSON; | |
try { | |
response = await request.json() as RegistrationResponseJSON; | |
} catch (err) { | |
if (!(err instanceof Error)) { | |
throw err; | |
} | |
throw error(400, `Invalid request body: ${err.message}`); | |
} | |
let verification: VerifiedRegistrationResponse; | |
try { | |
verification = await verifyRegistrationResponse({ | |
response, | |
expectedChallenge, | |
// TODO: Replace with env vars | |
expectedOrigin: url.origin, // <-- TODO: Use origin from RP ID instead | |
expectedRPID: url.hostname // <-- TODO: Use hostname from env instead | |
} satisfies VerifyRegistrationResponseOpts); | |
} catch (err) { | |
if (!(err instanceof Error)) { | |
throw err; | |
} | |
await deleteChallenges(database, sessionId); | |
throw error(400, `Failed to verify registration response: ${err.message}`); | |
} | |
const { verified, registrationInfo } = verification; | |
if (verified && registrationInfo) { | |
const { | |
credentialPublicKey, | |
credentialBackedUp, | |
credentialDeviceType, | |
credentialID, | |
counter, | |
credentialType | |
} = registrationInfo; | |
const authenticators = await listAuthenticatorsForUser(database, user); | |
const existingDevice = authenticators.find(({ identifier }) => | |
Buffer.from(identifier, 'base64url').equals(credentialID) | |
); | |
if (!existingDevice) { | |
await createAuthenticator(database, { | |
agent: inferAgent(request), | |
backed_up: credentialBackedUp, | |
counter, | |
device_type: credentialDeviceType, | |
handle: inferHandle(request), | |
identifier: Buffer.from(credentialID).toString('base64url'), | |
public_key: Buffer.from(credentialPublicKey).toString('base64url'), | |
transports: response.response.transports ?? [], | |
type: credentialType, | |
user_id: user.id | |
}); | |
} | |
} | |
await deleteChallenges(database, sessionId); | |
return json({ verified } as VerificationResponseJSON); | |
}; | |
function inferHandle(request: Request) { | |
const userAgent = request.headers.get('user-agent') || ''; | |
const { os, browser } = parseUserAgent(userAgent); | |
return `${browser.name} on ${os.name} ${os.version}`; | |
} | |
function inferAgent(request: Request) { | |
const userAgent = request.headers.get('user-agent') || ''; | |
const { browser } = parseUserAgent(userAgent); | |
return browser.name; | |
} | |
export type VerificationResponseJSON = { | |
verified: boolean; | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment