Skip to content

Commit a90b173

Browse files
GerbuuunatinuxipanamskiBarbapapazesautofix-ci[bot]
authored
feat: webauthn (passkey) support
* feat: add passkey specific webauthn authentication support * feat: playground passkey implementation * feat: initial docs * fix: composable type and availability functions * fix: types and webauthn config functions * fix: auto import * fix: composable jsdoc * feat: handle attempts internally and change config to respective options name * chore: update README.md * fix: make sure attempt is always removed from storage! * chore: make playground implementation more consistent * refactor: use 'webauthn' and 'credential' terms instead of 'passkey' * refactor: use body instead of query param for `attemptId` * chore: rename passkey terms * chore: improvements * up * lint fix * feat: use session to store challenge by default * feat: base64 encode publicKey by default * chore: types cleanup and typo fixes * feat: improve example and documentation * chore: proofread readme * fix: typo * docs: add frontend example * docs: fix typo Change useServerSession() to useUserSession() * refactor: request token * refactor: request token * chore: fix import * up * up * Merge branch 'main' into refactor/request-token * [autofix.ci] apply automated fixes * chore: fix types issue * chore: lint --------- Co-authored-by: Sébastien Chopin <[email protected]> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> * feat: add tiktok provider * feat: add tiktok provider * docs: add tiktok * feat: add tiktok .env example * chore: remove console logs * [autofix.ci] apply automated fixes * chore: remove unused authorizationParams * chore: use new utils * fix: extends from RequestAccesTokenBody interface * [autofix.ci] apply automated fixes --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> * chore: update deps * chore(release): v0.3.6 * fix: paypal tokens request requires encoded `redirect_uri` * fix: encode paypal `redirect_uri` * chore: add comment * chore: update deps * chore(release): v0.3.7 * docs: add note about cookie size * feat: add Gitlab provider * feat: add yandex oauth * chore: linting * update: change FormData to URLSearchParams & add config.emailRequired * up * [autofix.ci] apply automated fixes * chore(release): v0.2.0 * style: add lint script * style: add lint script * ci: update lint fix command * [autofix.ci] apply automated fixes * feat: add gitlab provider * [autofix.ci] apply automated fixes * update Supported OAuth Providers in readme * Apply suggestions from code review --------- Co-authored-by: Sébastien Chopin <[email protected]> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Estéban <[email protected]> Co-authored-by: Sébastien Chopin <[email protected]> * docs: Add note to readme about session API route * Add note about session API route * Update README.md * Update README.md --------- Co-authored-by: Sébastien Chopin <[email protected]> * feat: add instagram provider * feat(instagram): new provider * chore(instagram): add provider to readme * fix(instagram): oauth query --------- Co-authored-by: Sébastien Chopin <[email protected]> * chore: add emailRequired for testing Gitlab * feat: add vk provider * feat: add yandex oauth * chore: linting * update: change FormData to URLSearchParams & add config.emailRequired * up * [autofix.ci] apply automated fixes * chore(release): v0.2.0 * style: add lint script * style: add lint script * ci: update lint fix command * [autofix.ci] apply automated fixes * feat: add gitlab provider * [autofix.ci] apply automated fixes * update Supported OAuth Providers in readme * feat: add vk provider * [autofix.ci] apply automated fixes * up --------- Co-authored-by: Sébastien Chopin <[email protected]> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Estéban <[email protected]> * fix: ensure plugin declaration files are emitted (#170) * feat: add support for private data & config argument (#171) * chore: up * chore(release): v0.3.8 * fix: UserSession secure type augmentation (#181) * fix: UserSession secure type augmentation * docs: add readme example * chore: update deps * chore(release): v0.3.9 * feat: add Dropbox as supported oauth provider (#183) * feat: add Dropbox as supported oauth provider * chore: remove no needed config * fix(steam): improve open id validation (#184) * fix(steam): open id validation * chore: lint * chore: check steam id * [autofix.ci] apply automated fixes * chore: update error message * chore: adjust steam id checker --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> * feat!: call `fetch` hook if session is not empty instead of user defined (#188) * feat!: rename `oauth<Provider>EventHandler` to`defineOAuth<Provider>EventHandler` (#189) * up * lint fix * up * type error * [autofix.ci] apply automated fixes * fix all types * [autofix.ci] apply automated fixes * chore: use logger * [autofix.ci] apply automated fixes * Update autofix.yml * rename to useWebAuthn * [autofix.ci] apply automated fixes * update readme * [autofix.ci] apply automated fixes * feat: allow for extra data fields to be included in the registration body * [autofix.ci] apply automated fixes * fix: component name * Update autofix.yml * chore: update * up * chore: fix types * add validateUser method * chore: small update * add allowCredentials and improve validateUser * lint * feat: infer registration body and credential data * chore: remove unnecessary generic param * chore: add demo --------- Co-authored-by: Sébastien Chopin <[email protected]> Co-authored-by: Ivailo Panamski <[email protected]> Co-authored-by: Estéban <[email protected]> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Ahmed Rangel <[email protected]> Co-authored-by: Yizack Rangel <[email protected]> Co-authored-by: Alex Blumgart <[email protected]> Co-authored-by: Estéban <[email protected]> Co-authored-by: Sébastien Chopin <[email protected]> Co-authored-by: Rudo Kemper <[email protected]> Co-authored-by: Sandro Circi <[email protected]> Co-authored-by: Daniel Roe <[email protected]> Co-authored-by: Israel Ortuño <[email protected]>
1 parent f75e680 commit a90b173

23 files changed

+1354
-140
lines changed

.github/workflows/autofix.yml

+9-3
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,9 @@ jobs:
1313
runs-on: ubuntu-latest
1414

1515
steps:
16-
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
16+
- uses: actions/checkout@v4
1717
- run: corepack enable
18-
- uses: actions/setup-node@8f152de45cc393bb48ce5d89d36b731f54556e65 # v4.0.0
18+
- uses: actions/setup-node@v4
1919
with:
2020
node-version: 20
2121
cache: "pnpm"
@@ -26,4 +26,10 @@ jobs:
2626
- name: Lint (code)
2727
run: pnpm lint:fix
2828

29-
- uses: autofix-ci/action@d3e591514b99d0fca6779455ff8338516663f7cc
29+
- name: prepare
30+
run: pnpm dev:prepare
31+
32+
- name: Release PR version
33+
run: pnpm dlx pkg-pr-new publish
34+
35+
- uses: autofix-ci/action@ff86a557419858bb967097bfc916833f5647fa8c

README.md

+209-3
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ Add Authentication to Nuxt applications with secured & sealed cookies sessions.
1616

1717
- [Hybrid Rendering](#hybrid-rendering) support (SSR / CSR / SWR / Prerendering)
1818
- [20+ OAuth Providers](#supported-oauth-providers)
19+
- [Password hasing](#password-hashing)
20+
- [WebAuthn (passkey)](#webauthn-passkey)
1921
- [`useUserSession()` Vue composable](#vue-composable)
2022
- [Tree-shakable server utils](#server-utils)
2123
- [`<AuthState>` component](#authstate-component)
@@ -226,7 +228,7 @@ It can also be set using environment variables:
226228

227229
You can add your favorite provider by creating a new file in [src/runtime/server/lib/oauth/](./src/runtime/server/lib/oauth/).
228230

229-
### Example
231+
#### Example
230232

231233
Example: `~/server/routes/auth/github.get.ts`
232234

@@ -255,9 +257,9 @@ Make sure to set the callback URL in your OAuth app settings as `<your-domain>/a
255257

256258
If the redirect URL mismatch in production, this means that the module cannot guess the right redirect URL. You can set the `NUXT_OAUTH_<PROVIDER>_REDIRECT_URL` env variable to overwrite the default one.
257259

258-
### Password Utils
260+
### Password Hashing
259261

260-
Nuxt Auth Utils provides a `hashPassword` and `verifyPassword` function to hash and verify passwords by using [scrypt](https://en.wikipedia.org/wiki/Scrypt) as it is supported in many JS runtime.
262+
Nuxt Auth Utils provides password hashing utilities like `hashPassword` and `verifyPassword` to hash and verify passwords by using [scrypt](https://en.wikipedia.org/wiki/Scrypt) as it is supported in many JS runtime.
261263

262264
```ts
263265
const hashedPassword = await hashPassword('user_password')
@@ -282,6 +284,210 @@ export default defineNuxtConfig({
282284
})
283285
```
284286

287+
### WebAuthn (passkey)
288+
289+
WebAuthn (Web Authentication) is a web standard that enhances security by replacing passwords with passkeys using public key cryptography. Users can authenticate with biometric data (like fingerprints or facial recognition) or physical devices (like USB keys), reducing the risk of phishing and password breaches. This approach offers a more secure and user-friendly authentication method, supported by major browsers and platforms.
290+
291+
To enable WebAuthn you need to:
292+
293+
1. Install the peer dependencies:
294+
295+
```bash
296+
npx nypm i @simplewebauthn/server @simplewebauthn/browser
297+
```
298+
299+
2. Enable it in your `nuxt.config.ts`
300+
301+
```ts
302+
export default defineNuxtConfig({
303+
auth: {
304+
webAuthn: true
305+
}
306+
})
307+
```
308+
309+
#### Example
310+
311+
In this example we will implement the very basic steps to register and authenticate a credential.
312+
313+
The full code can be found in the [playground](https://github.com/atinux/nuxt-auth-utils/blob/main/playground/server/api/webauthn). The example uses a SQLite database with the following minimal tables:
314+
315+
```sql
316+
CREATE TABLE users (
317+
id INTEGER PRIMARY KEY AUTOINCREMENT,
318+
email TEXT NOT NULL
319+
);
320+
321+
CREATE TABLE IF NOT EXISTS credentials (
322+
userId INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE ON UPDATE CASCADE,
323+
id TEXT UNIQUE NOT NULL,
324+
publicKey TEXT NOT NULL,
325+
counter INTEGER NOT NULL,
326+
backedUp INTEGER NOT NULL,
327+
transports TEXT NOT NULL,
328+
PRIMARY KEY ("userId", "id")
329+
);
330+
```
331+
332+
- For the `users` table it is important to have a unique identifier such as a username or email (here we use email). When creating a new credential, this identifier is required and stored with the passkey on the user's device, password manager, or authenticator.
333+
- The `credentials` table stores:
334+
- The `userId` from the `users` table.
335+
- The credential `id` (as unique index)
336+
- The credential `publicKey`
337+
- A `counter`. Each time a credential is used, the counter is incremented. We can use this value to perform extra security checks. More about `counter` can be read [here](https://simplewebauthn.dev/docs/packages/server#3-post-registration-responsibilities). For this example, we won't be using the counter. But you should update the counter in your database with the new value.
338+
- A `backedUp` flag. Normally, credentials are stored on the generating device. When you use a password manager or authenticator, the credential is "backed up" because it can be used on multiple devices. See [this section](https://arc.net/l/quote/ugaemxot) for more details.
339+
- The credential `transports`. It is an array of strings that indicate how the credential communicates with the client. It is used to show the correct UI for the user to utilize the credential. Again, see [this section](https://arc.net/l/quote/ycxtiorp) for more details.
340+
341+
The following code does not include the actual database queries, but shows the general steps to follow. The full example can be found in the playground: [registration](https://github.com/atinux/nuxt-auth-utils/blob/main/playground/server/api/webauthn/register.post.ts), [authentication](https://github.com/atinux/nuxt-auth-utils/blob/main/playground/server/api/webauthn/authenticate.post.ts) and the [database setup](https://github.com/atinux/nuxt-auth-utils/blob/main/playground/server/plugins/database.ts).
342+
343+
```ts
344+
// server/api/webauthn/register.post.ts
345+
import { z } from 'zod'
346+
export default defineWebAuthnRegisterEventHandler({
347+
// optional
348+
validateUser: z.object({
349+
// we want the userName to be a valid email
350+
userName: z.string().email()
351+
}).parse,
352+
async onSuccess(event, { credential, user }) {
353+
// The credential creation has been successful
354+
// We need to create a user if it does not exist
355+
const db = useDatabase()
356+
357+
// Get the user from the database
358+
let dbUser = await db.sql`...`
359+
if (!dbUser) {
360+
// Store new user in database & its credentials
361+
dbUser = await db.sql`...`
362+
}
363+
364+
// we now need to store the credential in our database and link it to the user
365+
await db.sql`...`
366+
367+
// Set the user session
368+
await setUserSession(event, {
369+
user: {
370+
id: dbUser.id
371+
},
372+
loggedInAt: Date.now(),
373+
})
374+
},
375+
})
376+
```
377+
378+
```ts
379+
// server/api/webauthn/authenticate.post.ts
380+
export default defineWebAuthnAuthenticateEventHandler({
381+
// Optionally, we can prefetch the credentials if the user gives their userName during login
382+
async allowCredentials(event, userName) {
383+
const credentials = await useDatabase().sql`...`
384+
// If no credentials are found, the authentication cannot be completed
385+
if (!credentials.length)
386+
throw createError({ statusCode: 400, message: 'User not found' })
387+
388+
// If user is found, only allow credentials that are registered
389+
// The browser will automatically try to use the credential that it knows about
390+
// Skipping the step for the user to select a credential for a better user experience
391+
return credentials
392+
// example: [{ id: '...' }]
393+
},
394+
async getCredential(event, credentialId) {
395+
// Look for the credential in our database
396+
const credential = await useDatabase().sql`...`
397+
398+
// If the credential is not found, there is no account to log in to
399+
if (!credential)
400+
throw createError({ statusCode: 400, message: 'Credential not found' })
401+
402+
return credential
403+
},
404+
async onSuccess(event, { credential, authenticationInfo }) {
405+
// The credential authentication has been successful
406+
// We can look it up in our database and get the corresponding user
407+
const db = useDatabase()
408+
const user = await db.sql`...`
409+
410+
// Update the counter in the database (authenticationInfo.newCounter)
411+
await db.sql`...`
412+
413+
// Set the user session
414+
await setUserSession(event, {
415+
user: {
416+
id: user.id
417+
},
418+
loggedInAt: Date.now(),
419+
})
420+
},
421+
})
422+
```
423+
424+
> [!IMPORTANT]
425+
> By default, the webauthn event handlers will store the challenge in a short lived, encrypted session cookie. This is not recommended for applications that require strong security guarantees. On a secure connection (https) it is highly unlikely for this to cause problems. However, if the connection is not secure, there is a possibility of a man-in-the-middle attack. To prevent this, you should use a database or KV store to store the challenge instead. For this the `storeChallenge` and `getChallenge` functions are provided.
426+
427+
> ```ts
428+
> export default defineWebAuthnAuthenticateEventHandler({
429+
> async storeChallenge(event, challenge, attemptId) {
430+
> // Store the challenge in a KV store or DB
431+
> await useStorage().setItem(`attempt:${attemptId}`, challenge)
432+
> },
433+
> async getChallenge(event, attemptId) {
434+
> const challenge = await useStorage().getItem(`attempt:${attemptId}`)
435+
>
436+
> // Make sure to always remove the attempt because they are single use only!
437+
> await useStorage().removeItem(`attempt:${attemptId}`)
438+
>
439+
> if (!challenge)
440+
> throw createError({ statusCode: 400, message: 'Challenge expired' })
441+
>
442+
> return challenge
443+
> },
444+
> async onSuccess(event, { authenticator }) {
445+
> // ...
446+
> },
447+
> })
448+
> ```
449+
450+
On the frontend it is as simple as:
451+
452+
```vue
453+
<script setup lang="ts">
454+
const { register, authenticate } = useWebAuthn({
455+
registerEndpoint: '/api/webauthn/register', // Default
456+
authenticateEndpoint: '/api/webauthn/authenticate', // Default
457+
})
458+
const { fetch: fetchUserSession } = useUserSession()
459+
460+
const userName = ref('')
461+
async function signUp() {
462+
await register({ userName: userName.value })
463+
.then(fetchUserSession) // refetch the user session
464+
}
465+
466+
async function signIn() {
467+
await authenticate(userName.value)
468+
.then(fetchUserSession) // refetch the user session
469+
}
470+
</script>
471+
472+
<template>
473+
<form @submit.prevent="signUp">
474+
<input v-model="userName" placeholder="Email or username" />
475+
<button type="submit">Sign up</button>
476+
</form>
477+
<form @submit.prevent="signIn">
478+
<input v-model="userName" placeholder="Email or username" />
479+
<button type="submit">Sign in</button>
480+
</form>
481+
</template>
482+
```
483+
484+
Take a look at the [`WebAuthnModal.vue`](https://github.com/atinux/nuxt-auth-utils/blob/main/playground/components/WebAuthnModal.vue) for a full example.
485+
486+
#### Demo
487+
488+
A full demo can be found on https://todo-passkeys.nuxt.dev using [Drizzle ORM](https://orm.drizzle.team/) and [NuxtHub](https://hub.nuxt.com).
489+
490+
The source code of the demo is available on https://github.com/atinux/todo-passkeys.
285491

286492
### Extend Session
287493

package.json

+15-2
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"name": "nuxt-auth-utils",
33
"version": "0.3.9",
44
"description": "Add Authentication to Nuxt applications with secured & sealed cookies sessions.",
5-
"repository": "Atinux/nuxt-auth-utils",
5+
"repository": "atinux/nuxt-auth-utils",
66
"license": "MIT",
77
"type": "module",
88
"packageManager": "[email protected]",
@@ -33,7 +33,7 @@
3333
},
3434
"dependencies": {
3535
"@adonisjs/hash": "^9.0.5",
36-
"@nuxt/kit": "^3.13.0",
36+
"@nuxt/kit": "^3.13.2",
3737
"defu": "^6.1.4",
3838
"hookable": "^5.5.3",
3939
"ofetch": "^1.3.4",
@@ -42,6 +42,18 @@
4242
"scule": "^1.3.0",
4343
"uncrypto": "^0.1.3"
4444
},
45+
"peerDependencies": {
46+
"@simplewebauthn/browser": "^10.0.0",
47+
"@simplewebauthn/server": "^10.0.1"
48+
},
49+
"peerDependenciesMeta": {
50+
"@simplewebauthn/browser": {
51+
"optional": true
52+
},
53+
"@simplewebauthn/server": {
54+
"optional": true
55+
}
56+
},
4557
"devDependencies": {
4658
"@iconify-json/simple-icons": "^1.2.3",
4759
"@nuxt/devtools": "latest",
@@ -51,6 +63,7 @@
5163
"@nuxt/test-utils": "^3.14.2",
5264
"@nuxt/ui": "^2.18.5",
5365
"@nuxt/ui-pro": "^1.4.2",
66+
"@simplewebauthn/types": "^10.0.0",
5467
"changelogen": "^0.5.7",
5568
"eslint": "^9.10.0",
5669
"nuxt": "^3.13.2",

0 commit comments

Comments
 (0)