Skip to content

Commit

Permalink
feat: Passkey / WebAuthn provider (experimental) (#8808)
Browse files Browse the repository at this point in the history
* Fixed typos in supabase documentation (#9698)

chore: Fix typos in supabase adapter documentation

* initial passkeys

* working v1

* cleanup

* remove GetUserInfo restraints

* fix webpack renaming issues

* Use simplewebauthn server 9.0.1

* disconnect webauthn userID and internal database userID

* Add webauthn method testing to adapter utils

* move simplewebauthn/server to peerdeps

* update pnpm lock

* comment improvements

* use User instead of AdapterUser for webauthn methods

* remove unnecessary casting

* rename baseURL to authURL in webauthn contexts

* use inferWebAuthnOptions instead of decideWebAuthnOptions

* fix inferWebAuthnOptions docstring

* remove unecessary default value in inferWebAuthnOptions

* infer relaying party from request url

* simplify getLoggedInUser

* validate provider.simpleWebAuthnBrowserVersion in assertConfig

* add tests to webauthn-utils

* allow multiple relaying parties

* remove changes to dev app

* fix email and db session assertion

* move adapter codebase to new PR

* fix adapter builds

* fix: move @simplewebauthn/browser to peerDep

* fix: add note about installing peerDep to passkeys provider docs page

* fix: dynamic import webauthn browser methods

* fix: move webauthn signin to own export

* feat(prisma): support webauthn (#9876)

passkey adapter stuff

Co-authored-by: Nico Domino <yo@ndo.dev>

---------

Co-authored-by: Vishal Kashi <dev.vishalkashi@gmail.com>
Co-authored-by: Nico Domino <yo@ndo.dev>
Co-authored-by: Balázs Orbán <info@balazsorban.com>
  • Loading branch information
4 people committed Feb 5, 2024
1 parent d4e1c51 commit 3722f91
Show file tree
Hide file tree
Showing 29 changed files with 3,078 additions and 60 deletions.
7 changes: 6 additions & 1 deletion packages/core/package.json
Expand Up @@ -70,9 +70,13 @@
"preact-render-to-string": "5.2.3"
},
"peerDependencies": {
"@simplewebauthn/server": "^9.0.1",
"nodemailer": "^6.8.0"
},
"peerDependenciesMeta": {
"@simplewebauthn/server": {
"optional": true
},
"nodemailer": {
"optional": true
}
Expand All @@ -87,11 +91,12 @@
"providers": "node scripts/generate-providers"
},
"devDependencies": {
"@simplewebauthn/browser": "v9.0.0",
"@types/node": "18.11.10",
"@types/nodemailer": "6.4.6",
"@types/react": "18.0.37",
"autoprefixer": "10.4.13",
"postcss": "8.4.19",
"postcss-nested": "6.0.0"
}
}
}
56 changes: 54 additions & 2 deletions packages/core/src/adapters.ts
Expand Up @@ -163,7 +163,7 @@
*/

import { ProviderType } from "./providers/index.js"
import type { Account, Awaitable, User } from "./types.js"
import type { Account, Authenticator, Awaitable, User } from "./types.js"
// TODO: Discuss if we should expose methods to serialize and deserialize
// the data? Many adapters share this logic, so it could be useful to
// have a common implementation.
Expand Down Expand Up @@ -197,7 +197,7 @@ export interface AdapterUser extends User {
*/
export interface AdapterAccount extends Account {
userId: string
type: Extract<ProviderType, "oauth" | "oidc" | "email">
type: Extract<ProviderType, "oauth" | "oidc" | "email" | "webauthn">
}

/**
Expand Down Expand Up @@ -245,6 +245,16 @@ export interface VerificationToken {
token: string
}

/**
* An authenticator represents a credential authenticator assigned to a user.
*/
export interface AdapterAuthenticator extends Authenticator {
/**
* User ID of the authenticator.
*/
userId: string
}

/**
* An adapter is an object with function properties (methods) that read and write data from a data source.
* Think of these methods as a way to normalize the data layer to common interfaces that Auth.js can understand.
Expand Down Expand Up @@ -375,6 +385,48 @@ export interface Adapter {
identifier: string
token: string
}): Awaitable<VerificationToken | null>
/**
* Get account by provider account id and provider.
*
* If an account is not found, the adapter must return `null`.
*/
getAccount?(
providerAccountId: AdapterAccount["providerAccountId"], provider: AdapterAccount["provider"]
): Awaitable<AdapterAccount | null>
/**
* Returns an authenticator from its credentialID.
*
* If an authenticator is not found, the adapter must return `null`.
*/
getAuthenticator?(
credentialID: AdapterAuthenticator['credentialID']
): Awaitable<AdapterAuthenticator | null>
/**
* Create a new authenticator.
*
* If the creation fails, the adapter must throw an error.
*/
createAuthenticator?(
authenticator: AdapterAuthenticator
): Awaitable<AdapterAuthenticator>
/**
* Returns all authenticators from a user.
*
* If a user is not found, the adapter should still return an empty array.
* If the retrieval fails for some other reason, the adapter must throw an error.
*/
listAuthenticatorsByUserId?(
userId: AdapterAuthenticator['userId']
): Awaitable<AdapterAuthenticator[]>
/**
* Updates an authenticator's counter.
*
* If the update fails, the adapter must throw an error.
*/
updateAuthenticatorCounter?(
credentialID: AdapterAuthenticator['credentialID'],
newCounter: AdapterAuthenticator['counter']
): Awaitable<AdapterAuthenticator>
}

// For compatibility with older versions of NextAuth.js
Expand Down
48 changes: 47 additions & 1 deletion packages/core/src/errors.ts
Expand Up @@ -28,6 +28,11 @@ type ErrorType =
| "UntrustedHost"
| "Verification"
| "MissingCSRF"
| "AccountNotLinked"
| "DuplicateConditionalUI"
| "MissingWebAuthnAutocomplete"
| "WebAuthnVerificationError"
| "ExperimentalFeatureNotEnabled"

/**
* Base error class for all Auth.js errors.
Expand Down Expand Up @@ -385,7 +390,7 @@ export class UnsupportedStrategy extends AuthError {
static type = "UnsupportedStrategy"
}

/** Thrown when the callback endpoint was incorrectly called without a provider. */
/** Thrown when an endpoint was incorrectly called without a provider, or with an unsupported provider. */
export class InvalidProvider extends AuthError {
static type = "InvalidProvider"
}
Expand Down Expand Up @@ -427,3 +432,44 @@ export class Verification extends AuthError {
export class MissingCSRF extends SignInError {
static type = "MissingCSRF"
}

/**
* Thrown when multiple providers have `enableConditionalUI` set to `true`.
* Only one provider can have this option enabled at a time.
*/
export class DuplicateConditionalUI extends AuthError {
static type = "DuplicateConditionalUI"
}

/**
* Thrown when a WebAuthn provider has `enableConditionalUI` set to `true` but no formField has `webauthn` in its autocomplete param.
*
* The `webauthn` autocomplete param is required for conditional UI to work.
*/
export class MissingWebAuthnAutocomplete extends AuthError {
static type = "MissingWebAuthnAutocomplete"
}

/**
* Thrown when a WebAuthn provider fails to verify a client response.
*/
export class WebAuthnVerificationError extends AuthError {
static type = "WebAuthnVerificationError"
}

/**
* Thrown when an Email address is already associated with an account
* but the user is trying an account that is not linked to it.
*
* For security reasons, Auth.js does not automatically link accounts to existing accounts if the user is not signed in.
*/
export class AccountNotLinked extends SignInError {
static type = "AccountNotLinked"
}

/**
* Thrown when an experimental feature is used but not enabled.
*/
export class ExperimentalFeatureNotEnabled extends AuthError {
static type = "ExperimentalFeatureNotEnabled"
}
9 changes: 8 additions & 1 deletion packages/core/src/index.ts
Expand Up @@ -422,7 +422,14 @@ export interface AuthConfig {
* @note Experimental features are not guaranteed to be stable and may change or be removed without notice. Please use with caution.
* @default {}
*/
experimental?: Record<string, boolean>
experimental?: {
/**
* Enable WebAuthn support.
*
* @default false
*/
enableWebAuthn?: boolean
}
/**
* The base path of the Auth.js API endpoints.
*
Expand Down
89 changes: 87 additions & 2 deletions packages/core/src/lib/actions/callback/handle-login.ts
@@ -1,4 +1,4 @@
import { OAuthAccountNotLinked } from "../../../errors.js"
import { AccountNotLinked, OAuthAccountNotLinked } from "../../../errors.js"
import { fromDate } from "../../utils/date.js"

import type {
Expand Down Expand Up @@ -32,7 +32,7 @@ export async function handleLoginOrRegister(
// Input validation
if (!_account?.providerAccountId || !_account.type)
throw new Error("Missing or invalid provider account")
if (!["email", "oauth", "oidc"].includes(_account.type))
if (!["email", "oauth", "oidc", "webauthn"].includes(_account.type))
throw new Error("Provider not supported")

const {
Expand Down Expand Up @@ -125,6 +125,91 @@ export async function handleLoginOrRegister(
})

return { session, user, isNewUser }
} else if (account.type === "webauthn") {
// Check if the account exists
const userByAccount = await getUserByAccount({
providerAccountId: account.providerAccountId,
provider: account.provider,
})
if (userByAccount) {
if (user) {
// If the user is already signed in with this account, we don't need to do anything
if (userByAccount.id === user.id) {
const currentAccount: AdapterAccount = { ...account, userId: user.id }
return { session, user, isNewUser, account: currentAccount }
}
// If the user is currently signed in, but the new account they are signing in
// with is already associated with another user, then we cannot link them
// and need to return an error.
throw new AccountNotLinked(
"The account is already associated with another user",
{ provider: account.provider }
)
}
// If there is no active session, but the account being signed in with is already
// associated with a valid user then create session to sign the user in.
session = useJwtSession
? {}
: await createSession({
sessionToken: generateSessionToken(),
userId: userByAccount.id,
expires: fromDate(options.session.maxAge),
})

const currentAccount: AdapterAccount = { ...account, userId: userByAccount.id }
return { session, user: userByAccount, isNewUser, account: currentAccount }
} else {
// If the account doesn't exist, we'll create it
if (user) {
// If the user is already signed in and the account isn't already associated
// with another user account then we can go ahead and link the accounts safely.
await linkAccount({ ...account, userId: user.id })
await events.linkAccount?.({ user, account, profile })

// As they are already signed in, we don't need to do anything after linking them
const currentAccount: AdapterAccount = { ...account, userId: user.id }
return { session, user, isNewUser, account: currentAccount }
}

// If the user is not signed in and it looks like a new account then we
// check there also isn't an user account already associated with the same
// email address as the one in the request.
const userByEmail = profile.email
? await getUserByEmail(profile.email)
: null
if (userByEmail) {
// We don't trust user-provided email addresses, so we don't want to link accounts
// if the email address associated with the new account is already associated with
// an existing account.
throw new AccountNotLinked(
"Another account already exists with the same e-mail address",
{ provider: account.provider }
)
} else {
// If the current user is not logged in and the profile isn't linked to any user
// accounts (by email or provider account id)...
//
// If no account matching the same [provider].id or .email exists, we can
// create a new account for the user, link it to the OAuth account and
// create a new session for them so they are signed in with it.
user = await createUser({ ...profile })
}
await events.createUser?.({ user })

await linkAccount({ ...account, userId: user.id })
await events.linkAccount?.({ user, account, profile })

session = useJwtSession
? {}
: await createSession({
sessionToken: generateSessionToken(),
userId: user.id,
expires: fromDate(options.session.maxAge),
})

const currentAccount: AdapterAccount = { ...account, userId: user.id }
return { session, user, isNewUser: true, account: currentAccount }
}
}

// If signing in with OAuth account, check to see if the account exists already
Expand Down

0 comments on commit 3722f91

Please sign in to comment.