feat(api): implement passkey login and register api

This commit is contained in:
Julien Oculi 2024-06-19 16:09:45 +02:00
parent 8d316ae52e
commit 80a2eed2ee
4 changed files with 331 additions and 21 deletions

View file

@ -11,32 +11,22 @@
"serve": "deno task preview", "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" "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": { "fmt": { "singleQuote": true, "semiColons": false, "useTabs": true },
"singleQuote": true, "lint": { "rules": { "tags": ["fresh", "recommended"] } },
"semiColons": false, "exclude": ["**/_fresh/*", "packages/"],
"useTabs": true
},
"lint": {
"rules": {
"tags": [
"fresh",
"recommended"
]
}
},
"exclude": [
"**/_fresh/*",
"packages/"
],
"imports": { "imports": {
"$fresh/": "https://deno.land/x/fresh@1.6.8/", "$fresh/": "https://deno.land/x/fresh@1.6.8/",
"$std/": "https://deno.land/std@0.208.0/", "$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", "@jotsr/smart-css-bundler": "jsr:@jotsr/smart-css-bundler@^0.3.0",
"@preact/signals": "https://esm.sh/*@preact/signals@1.2.2", "@preact/signals": "https://esm.sh/*@preact/signals@1.2.2",
"@preact/signals-core": "https://esm.sh/*@preact/signals-core@1.5.1", "@preact/signals-core": "https://esm.sh/*@preact/signals-core@1.5.1",
"@simplewebauthn/browser": "npm:@simplewebauthn/browser@^10.0.0", "@simplewebauthn/browser": "npm:@simplewebauthn/browser@^10.0.0",
"@simplewebauthn/server": "npm:@simplewebauthn/server@^10.0.0", "@simplewebauthn/server": "npm:@simplewebauthn/server@^10.0.0",
"@simplewebauthn/types": "npm:@simplewebauthn/types@^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", "@std/http": "jsr:@std/http@^0.224.4",
"@univoq/": "https://deno.land/x/univoq@0.2.0/", "@univoq/": "https://deno.land/x/univoq@0.2.0/",
"gfm": "https://deno.land/x/gfm@0.6.0/mod.ts", "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", "univoq": "https://deno.land/x/univoq@0.2.0/mod.ts",
"web-push": "npm:web-push@^3.6.7" "web-push": "npm:web-push@^3.6.7"
}, },
"compilerOptions": { "compilerOptions": { "jsx": "react-jsx", "jsxImportSource": "preact" },
"jsx": "react-jsx", "workspaces": [
"jsxImportSource": "preact" "packages/@cohabit__cohamail@0.2.1",
} "packages/@cohabit__ressources_manager@0.1.0"
],
"unstable": ["kv"]
} }

View file

@ -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>('user-request')
const passkey = ctx.state.session
.get<Passkey[]>('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)
},
}

View file

@ -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>('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)
},
}

23
src/webauthn/mod.ts Normal file
View file

@ -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,
}
}