Skip to content

Commit

Permalink
feat: webauthn (passkey) support
Browse files Browse the repository at this point in the history
* 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]>
  • Loading branch information
14 people authored Sep 30, 2024
1 parent f75e680 commit a90b173
Show file tree
Hide file tree
Showing 23 changed files with 1,354 additions and 140 deletions.
12 changes: 9 additions & 3 deletions .github/workflows/autofix.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,9 @@ jobs:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- uses: actions/checkout@v4
- run: corepack enable
- uses: actions/setup-node@8f152de45cc393bb48ce5d89d36b731f54556e65 # v4.0.0
- uses: actions/setup-node@v4
with:
node-version: 20
cache: "pnpm"
Expand All @@ -26,4 +26,10 @@ jobs:
- name: Lint (code)
run: pnpm lint:fix

- uses: autofix-ci/action@d3e591514b99d0fca6779455ff8338516663f7cc
- name: prepare
run: pnpm dev:prepare

- name: Release PR version
run: pnpm dlx pkg-pr-new publish

- uses: autofix-ci/action@ff86a557419858bb967097bfc916833f5647fa8c
212 changes: 209 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ Add Authentication to Nuxt applications with secured & sealed cookies sessions.

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

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

### Example
#### Example

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

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

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.

### Password Utils
### Password Hashing

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.
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.

```ts
const hashedPassword = await hashPassword('user_password')
Expand All @@ -282,6 +284,210 @@ export default defineNuxtConfig({
})
```

### WebAuthn (passkey)

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.

To enable WebAuthn you need to:

1. Install the peer dependencies:

```bash
npx nypm i @simplewebauthn/server @simplewebauthn/browser
```

2. Enable it in your `nuxt.config.ts`

```ts
export default defineNuxtConfig({
auth: {
webAuthn: true
}
})
```

#### Example

In this example we will implement the very basic steps to register and authenticate a credential.

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:

```sql
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL
);

CREATE TABLE IF NOT EXISTS credentials (
userId INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE ON UPDATE CASCADE,
id TEXT UNIQUE NOT NULL,
publicKey TEXT NOT NULL,
counter INTEGER NOT NULL,
backedUp INTEGER NOT NULL,
transports TEXT NOT NULL,
PRIMARY KEY ("userId", "id")
);
```

- 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.
- The `credentials` table stores:
- The `userId` from the `users` table.
- The credential `id` (as unique index)
- The credential `publicKey`
- 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.
- 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.
- 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.

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).

```ts
// server/api/webauthn/register.post.ts
import { z } from 'zod'
export default defineWebAuthnRegisterEventHandler({
// optional
validateUser: z.object({
// we want the userName to be a valid email
userName: z.string().email()
}).parse,
async onSuccess(event, { credential, user }) {
// The credential creation has been successful
// We need to create a user if it does not exist
const db = useDatabase()

// Get the user from the database
let dbUser = await db.sql`...`
if (!dbUser) {
// Store new user in database & its credentials
dbUser = await db.sql`...`
}

// we now need to store the credential in our database and link it to the user
await db.sql`...`

// Set the user session
await setUserSession(event, {
user: {
id: dbUser.id
},
loggedInAt: Date.now(),
})
},
})
```

```ts
// server/api/webauthn/authenticate.post.ts
export default defineWebAuthnAuthenticateEventHandler({
// Optionally, we can prefetch the credentials if the user gives their userName during login
async allowCredentials(event, userName) {
const credentials = await useDatabase().sql`...`
// If no credentials are found, the authentication cannot be completed
if (!credentials.length)
throw createError({ statusCode: 400, message: 'User not found' })

// If user is found, only allow credentials that are registered
// The browser will automatically try to use the credential that it knows about
// Skipping the step for the user to select a credential for a better user experience
return credentials
// example: [{ id: '...' }]
},
async getCredential(event, credentialId) {
// Look for the credential in our database
const credential = await useDatabase().sql`...`

// If the credential is not found, there is no account to log in to
if (!credential)
throw createError({ statusCode: 400, message: 'Credential not found' })

return credential
},
async onSuccess(event, { credential, authenticationInfo }) {
// The credential authentication has been successful
// We can look it up in our database and get the corresponding user
const db = useDatabase()
const user = await db.sql`...`

// Update the counter in the database (authenticationInfo.newCounter)
await db.sql`...`

// Set the user session
await setUserSession(event, {
user: {
id: user.id
},
loggedInAt: Date.now(),
})
},
})
```

> [!IMPORTANT]
> 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.
> ```ts
> export default defineWebAuthnAuthenticateEventHandler({
> async storeChallenge(event, challenge, attemptId) {
> // Store the challenge in a KV store or DB
> await useStorage().setItem(`attempt:${attemptId}`, challenge)
> },
> async getChallenge(event, attemptId) {
> const challenge = await useStorage().getItem(`attempt:${attemptId}`)
>
> // Make sure to always remove the attempt because they are single use only!
> await useStorage().removeItem(`attempt:${attemptId}`)
>
> if (!challenge)
> throw createError({ statusCode: 400, message: 'Challenge expired' })
>
> return challenge
> },
> async onSuccess(event, { authenticator }) {
> // ...
> },
> })
> ```
On the frontend it is as simple as:
```vue
<script setup lang="ts">
const { register, authenticate } = useWebAuthn({
registerEndpoint: '/api/webauthn/register', // Default
authenticateEndpoint: '/api/webauthn/authenticate', // Default
})
const { fetch: fetchUserSession } = useUserSession()
const userName = ref('')
async function signUp() {
await register({ userName: userName.value })
.then(fetchUserSession) // refetch the user session
}
async function signIn() {
await authenticate(userName.value)
.then(fetchUserSession) // refetch the user session
}
</script>
<template>
<form @submit.prevent="signUp">
<input v-model="userName" placeholder="Email or username" />
<button type="submit">Sign up</button>
</form>
<form @submit.prevent="signIn">
<input v-model="userName" placeholder="Email or username" />
<button type="submit">Sign in</button>
</form>
</template>
```
Take a look at the [`WebAuthnModal.vue`](https://github.com/atinux/nuxt-auth-utils/blob/main/playground/components/WebAuthnModal.vue) for a full example.

#### Demo

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).

The source code of the demo is available on https://github.com/atinux/todo-passkeys.

### Extend Session

Expand Down
17 changes: 15 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"name": "nuxt-auth-utils",
"version": "0.3.9",
"description": "Add Authentication to Nuxt applications with secured & sealed cookies sessions.",
"repository": "Atinux/nuxt-auth-utils",
"repository": "atinux/nuxt-auth-utils",
"license": "MIT",
"type": "module",
"packageManager": "[email protected]",
Expand Down Expand Up @@ -33,7 +33,7 @@
},
"dependencies": {
"@adonisjs/hash": "^9.0.5",
"@nuxt/kit": "^3.13.0",
"@nuxt/kit": "^3.13.2",
"defu": "^6.1.4",
"hookable": "^5.5.3",
"ofetch": "^1.3.4",
Expand All @@ -42,6 +42,18 @@
"scule": "^1.3.0",
"uncrypto": "^0.1.3"
},
"peerDependencies": {
"@simplewebauthn/browser": "^10.0.0",
"@simplewebauthn/server": "^10.0.1"
},
"peerDependenciesMeta": {
"@simplewebauthn/browser": {
"optional": true
},
"@simplewebauthn/server": {
"optional": true
}
},
"devDependencies": {
"@iconify-json/simple-icons": "^1.2.3",
"@nuxt/devtools": "latest",
Expand All @@ -51,6 +63,7 @@
"@nuxt/test-utils": "^3.14.2",
"@nuxt/ui": "^2.18.5",
"@nuxt/ui-pro": "^1.4.2",
"@simplewebauthn/types": "^10.0.0",
"changelogen": "^0.5.7",
"eslint": "^9.10.0",
"nuxt": "^3.13.2",
Expand Down
Loading

0 comments on commit a90b173

Please sign in to comment.