Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save Radiergummi/c60e477d067873d0e157e0eea4cbbf88 to your computer and use it in GitHub Desktop.
Save Radiergummi/c60e477d067873d0e157e0eea4cbbf88 to your computer and use it in GitHub Desktop.
Supabase WebAuthn implementation
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>;
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);
};
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 });
};
<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}
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 };
};
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);
};
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