feat(api): ✨ implement passkey login and register api
This commit is contained in:
parent
8d316ae52e
commit
80a2eed2ee
34
deno.json
34
deno.json
|
@ -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"]
|
||||||
}
|
}
|
||||||
|
|
151
routes/api/webauthn/login/[step].ts
Normal file
151
routes/api/webauthn/login/[step].ts
Normal 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)
|
||||||
|
},
|
||||||
|
}
|
144
routes/api/webauthn/register/[step].ts
Normal file
144
routes/api/webauthn/register/[step].ts
Normal 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
23
src/webauthn/mod.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in a new issue