From 80a2eed2ee73bc6d1ff57f8ea20c232f34d79663 Mon Sep 17 00:00:00 2001 From: Julien Oculi Date: Wed, 19 Jun 2024 16:09:45 +0200 Subject: [PATCH] feat(api): :sparkles: implement passkey login and register api --- deno.json | 34 +++--- routes/api/webauthn/login/[step].ts | 151 +++++++++++++++++++++++++ routes/api/webauthn/register/[step].ts | 144 +++++++++++++++++++++++ src/webauthn/mod.ts | 23 ++++ 4 files changed, 331 insertions(+), 21 deletions(-) create mode 100644 routes/api/webauthn/login/[step].ts create mode 100644 routes/api/webauthn/register/[step].ts create mode 100644 src/webauthn/mod.ts diff --git a/deno.json b/deno.json index d211c29..97a7685 100644 --- a/deno.json +++ b/deno.json @@ -11,32 +11,22 @@ "serve": "deno task preview", "dev:add_package": "deno run --allow-net=git.cohabit.fr --allow-read=. --allow-write=./deno.json,./packages --allow-run=git,deno ./scripts/add_package.ts" }, - "fmt": { - "singleQuote": true, - "semiColons": false, - "useTabs": true - }, - "lint": { - "rules": { - "tags": [ - "fresh", - "recommended" - ] - } - }, - "exclude": [ - "**/_fresh/*", - "packages/" - ], + "fmt": { "singleQuote": true, "semiColons": false, "useTabs": true }, + "lint": { "rules": { "tags": ["fresh", "recommended"] } }, + "exclude": ["**/_fresh/*", "packages/"], "imports": { "$fresh/": "https://deno.land/x/fresh@1.6.8/", "$std/": "https://deno.land/std@0.208.0/", + "@cohabit/cohamail/": "./packages/@cohabit__cohamail@0.2.1/", + "@cohabit/ressources_manager/": "./packages/@cohabit__ressources_manager@0.1.0/", + "@jotsr/delayed": "jsr:@jotsr/delayed@^2.1.1", "@jotsr/smart-css-bundler": "jsr:@jotsr/smart-css-bundler@^0.3.0", "@preact/signals": "https://esm.sh/*@preact/signals@1.2.2", "@preact/signals-core": "https://esm.sh/*@preact/signals-core@1.5.1", "@simplewebauthn/browser": "npm:@simplewebauthn/browser@^10.0.0", "@simplewebauthn/server": "npm:@simplewebauthn/server@^10.0.0", "@simplewebauthn/types": "npm:@simplewebauthn/types@^10.0.0", + "@std/encoding": "jsr:@std/encoding@^0.224.3", "@std/http": "jsr:@std/http@^0.224.4", "@univoq/": "https://deno.land/x/univoq@0.2.0/", "gfm": "https://deno.land/x/gfm@0.6.0/mod.ts", @@ -45,8 +35,10 @@ "univoq": "https://deno.land/x/univoq@0.2.0/mod.ts", "web-push": "npm:web-push@^3.6.7" }, - "compilerOptions": { - "jsx": "react-jsx", - "jsxImportSource": "preact" - } + "compilerOptions": { "jsx": "react-jsx", "jsxImportSource": "preact" }, + "workspaces": [ + "packages/@cohabit__cohamail@0.2.1", + "packages/@cohabit__ressources_manager@0.1.0" + ], + "unstable": ["kv"] } diff --git a/routes/api/webauthn/login/[step].ts b/routes/api/webauthn/login/[step].ts new file mode 100644 index 0000000..d5b70bf --- /dev/null +++ b/routes/api/webauthn/login/[step].ts @@ -0,0 +1,151 @@ +import { + generateAuthenticationOptions, + verifyAuthenticationResponse, +} from '@simplewebauthn/server' +import { getRelyingParty } from '../../../../src/webauthn/mod.ts' +import { + AuthenticationResponseJSON, + PublicKeyCredentialRequestOptionsJSON, +} from '@simplewebauthn/types' +import { respondApi } from '../../../../src/utils.ts' +import type { SessionHandlers } from '../../../../src/session/mod.ts' +import { db } from '../../../../src/db/mod.ts' +import { Credential, Ref, User } from '@cohabit/ressources_manager/mod.ts' +import { Passkey } from '@cohabit/ressources_manager/src/models/src/credential.ts' +import { decodeBase64 } from '@std/encoding' + +type Params = { step: 'start' | 'finish' } + +export type WebAuthnLoginStartPayload = { + email: string +} + +export type WebAuthnLoginFinishPayload = AuthenticationResponseJSON + +export const handler: SessionHandlers = { + async POST(req, ctx) { + const relyingParty = getRelyingParty(ctx.url) + + const { step } = ctx.params as Params + + if (step === 'start') { + const { email } = await req.json() as WebAuthnLoginStartPayload + + // Get user credentials + const [user] = await db.ressource.user.list((user) => user.mail === email) + .take(1).toArray() + // Resolve refs to credentials + const resolver = Ref.dbResolver(db) + const credentials = await Promise.all(user.credentials.map(resolver)) + // Get user passkeys + const passkeys = credentials + .filter((credential): credential is Credential<'passkey'> => + credential.category === 'passkey' + ) + .map((credential) => credential.store) + + // Flash current user and passkeys + ctx.state.session.flash('user-request', user) + ctx.state.session.flash('passkeys-request', passkeys) + + if (passkeys.length === 0) { + return respondApi( + 'error', + new Error('no passkey found for requested user'), + 301, + ) + } + + const options = await generateAuthenticationOptions({ + rpID: relyingParty.id, + allowCredentials: passkeys, + }) + + ctx.state.session.flash('webauthn-login', options) + + return respondApi('success', options) + } + + if (step === 'finish') { + const authentication = await req + .json() as WebAuthnLoginFinishPayload + + const options = ctx.state.session.get< + PublicKeyCredentialRequestOptionsJSON + >('webauthn-login') + + if (options === undefined) { + return respondApi( + 'error', + new Error('no authentication options registered'), + 301, + ) + } + + const user = ctx.state.session.get('user-request') + const passkey = ctx.state.session + .get('passkeys-request') + ?.filter((passkey) => passkey.id === authentication.id) + .at(0) + + if (passkey === undefined) { + return respondApi( + 'error', + new Error('no passkey found for requested user'), + 301, + ) + } + + try { + const verification = await verifyAuthenticationResponse({ + response: authentication, + expectedChallenge: options.challenge, + expectedOrigin: relyingParty.origin, + expectedRPID: relyingParty.id, + requireUserVerification: true, + authenticator: { + credentialID: passkey.id, + credentialPublicKey: decodeBase64(passkey.publicKey), + counter: passkey.counter, + transports: passkey.transports, + }, + }) + + const { authenticationInfo, verified } = verification + + if (authenticationInfo === undefined) { + throw new Error('no authentication info found from verification') + } + + const { newCounter } = authenticationInfo + + passkey.counter = newCounter + + // Update credential store + const [credential] = await db.ressource.credential.list( + (credential) => { + if (credential.category !== 'passkey') { + return false + } + return (credential as Credential<'passkey'>).store.id === passkey.id + }, + ).toArray() + + // Save credential to db + await db.ressource.credential.set([ + credential.update({ store: passkey }), + ]) + + // log user + ctx.state.session.set('user', user) + + return respondApi('success', { verified }) + } catch (error) { + console.error(error) + return respondApi('error', error, 400) + } + } + + return respondApi('error', new Error('unknown step'), 400) + }, +} diff --git a/routes/api/webauthn/register/[step].ts b/routes/api/webauthn/register/[step].ts new file mode 100644 index 0000000..b2a8ecd --- /dev/null +++ b/routes/api/webauthn/register/[step].ts @@ -0,0 +1,144 @@ +import { + generateRegistrationOptions, + verifyRegistrationResponse, +} from '@simplewebauthn/server' +import type { + PublicKeyCredentialCreationOptionsJSON, + RegistrationResponseJSON, +} from '@simplewebauthn/types' +import { respondApi } from '../../../../src/utils.ts' +import { SessionHandlers } from '../../../../src/session/mod.ts' + +//TODO improve workspace imports +import { Passkey } from '@cohabit/ressources_manager/src/models/src/credential.ts' +import { Credential, Ref, User } from '@cohabit/ressources_manager/mod.ts' +import { getRelyingParty } from '../../../../src/webauthn/mod.ts' +import { encodeBase64 } from '@std/encoding' +import { db } from '../../../../src/db/mod.ts' + +type Params = { step: 'start' | 'finish' } + +export type WebAuthnRegisterStartPayload = { name: string } + +export type WebAuthnRegisterFinishPayload = RegistrationResponseJSON + +export const handler: SessionHandlers = { + async POST(req, ctx) { + const relyingParty = getRelyingParty(ctx.url) + + const { step } = ctx.params as Params + const user = ctx.state.session.get('user') + + if (user === undefined) { + return respondApi( + 'error', + new Error('no logged user in current session'), + 401, + ) + } + + if (step === 'start') { + const { name } = await req.json() as WebAuthnRegisterStartPayload + + // Get user credentials + // Ensure latest user datas + const dbUser = await db.ressource.user.get(user) + // Resolve refs to credentials + const resolver = Ref.dbResolver(db) + const credentials = await Promise.all(dbUser.credentials.map(resolver)) + const excludeCredentials = credentials + .filter((credential): credential is Credential<'passkey'> => + credential.category === 'passkey' + ) + .map((credential) => credential.store) + + const options = await generateRegistrationOptions({ + rpName: relyingParty.name, + rpID: relyingParty.id, + userName: user.login, + attestationType: 'none', + excludeCredentials, + authenticatorSelection: { + residentKey: 'preferred', + userVerification: 'preferred', + }, + }) + + ctx.state.session.flash('webauthn-registration', { name, options }) + + return respondApi('success', options) + } + + if (step === 'finish') { + const registration = await req.json() as WebAuthnRegisterFinishPayload + + const { name, options } = ctx.state.session.get< + { name: string; options: PublicKeyCredentialCreationOptionsJSON } + >('webauthn-registration')! + + try { + if (options === undefined) { + throw new Error(`no registration found for ${user.mail}`) + } + + const verification = await verifyRegistrationResponse({ + response: registration, + expectedChallenge: options.challenge, + expectedOrigin: relyingParty.origin, + expectedRPID: relyingParty.id, + requireUserVerification: true, + }) + + const { registrationInfo } = verification + + if (registrationInfo === undefined) { + throw new Error('no registration info found from verification') + } + + const { + credentialID, + credentialPublicKey, + counter, + credentialDeviceType, + credentialBackedUp, + } = registrationInfo + + // Create new Passkey + const store: Passkey = { + user: user.uuid, + webAuthnUserID: options.user.id, + id: credentialID, + publicKey: encodeBase64(credentialPublicKey), + counter, + deviceType: credentialDeviceType, + backedUp: credentialBackedUp, + transports: registration.response.transports, + } + + // create and save new Credentials + const credential = Credential.load({ name, category: 'passkey', store }) + await db.ressource.credential.set([credential]) + + // Update user credentials + // Ensure latest user datas + const dbUser = await db.ressource.user.get(user) + // Append new credentials + const credentials = [...dbUser.credentials, credential.toRef()] + const updatedUser = user.update({ credentials }) + // Save user to db + await db.ressource.user.set([updatedUser]) + // Update session + ctx.state.session.set('user', updatedUser) + + const { verified } = verification + + return respondApi('success', { verified }) + } catch (error) { + console.error(error) + return respondApi('error', error, 400) + } + } + + return respondApi('error', new Error('unknown step'), 400) + }, +} diff --git a/src/webauthn/mod.ts b/src/webauthn/mod.ts new file mode 100644 index 0000000..4d55e20 --- /dev/null +++ b/src/webauthn/mod.ts @@ -0,0 +1,23 @@ +export function getRelyingParty(url: string | URL) { + url = new URL(url) + + return { + /** + * Human-readable title for your website + */ + name: 'Coh@bit', + /** + * A unique identifier for your website. 'localhost' is okay for + * local dev + */ + // const rpID = 'cohabit.fr' + id: url.hostname, + /** + * The URL at which registrations and authentications should occur. + * 'http://localhost' and 'http://localhost:PORT' are also valid. + * Do NOT include any trailing / + */ + // const origin = `https://${rpID}` + origin: url.origin, + } +}