Skip to content

Commit 3722f91

Browse files
Maronatodestructo570ndom91balazsorban44
authored
feat: Passkey / WebAuthn provider (experimental) (#8808)
* 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 <[email protected]> --------- Co-authored-by: Vishal Kashi <[email protected]> Co-authored-by: Nico Domino <[email protected]> Co-authored-by: Balázs Orbán <[email protected]>
1 parent d4e1c51 commit 3722f91

29 files changed

+3078
-60
lines changed

packages/core/package.json

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,9 +70,13 @@
7070
"preact-render-to-string": "5.2.3"
7171
},
7272
"peerDependencies": {
73+
"@simplewebauthn/server": "^9.0.1",
7374
"nodemailer": "^6.8.0"
7475
},
7576
"peerDependenciesMeta": {
77+
"@simplewebauthn/server": {
78+
"optional": true
79+
},
7680
"nodemailer": {
7781
"optional": true
7882
}
@@ -87,11 +91,12 @@
8791
"providers": "node scripts/generate-providers"
8892
},
8993
"devDependencies": {
94+
"@simplewebauthn/browser": "v9.0.0",
9095
"@types/node": "18.11.10",
9196
"@types/nodemailer": "6.4.6",
9297
"@types/react": "18.0.37",
9398
"autoprefixer": "10.4.13",
9499
"postcss": "8.4.19",
95100
"postcss-nested": "6.0.0"
96101
}
97-
}
102+
}

packages/core/src/adapters.ts

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,7 @@
163163
*/
164164

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

203203
/**
@@ -245,6 +245,16 @@ export interface VerificationToken {
245245
token: string
246246
}
247247

248+
/**
249+
* An authenticator represents a credential authenticator assigned to a user.
250+
*/
251+
export interface AdapterAuthenticator extends Authenticator {
252+
/**
253+
* User ID of the authenticator.
254+
*/
255+
userId: string
256+
}
257+
248258
/**
249259
* An adapter is an object with function properties (methods) that read and write data from a data source.
250260
* Think of these methods as a way to normalize the data layer to common interfaces that Auth.js can understand.
@@ -375,6 +385,48 @@ export interface Adapter {
375385
identifier: string
376386
token: string
377387
}): Awaitable<VerificationToken | null>
388+
/**
389+
* Get account by provider account id and provider.
390+
*
391+
* If an account is not found, the adapter must return `null`.
392+
*/
393+
getAccount?(
394+
providerAccountId: AdapterAccount["providerAccountId"], provider: AdapterAccount["provider"]
395+
): Awaitable<AdapterAccount | null>
396+
/**
397+
* Returns an authenticator from its credentialID.
398+
*
399+
* If an authenticator is not found, the adapter must return `null`.
400+
*/
401+
getAuthenticator?(
402+
credentialID: AdapterAuthenticator['credentialID']
403+
): Awaitable<AdapterAuthenticator | null>
404+
/**
405+
* Create a new authenticator.
406+
*
407+
* If the creation fails, the adapter must throw an error.
408+
*/
409+
createAuthenticator?(
410+
authenticator: AdapterAuthenticator
411+
): Awaitable<AdapterAuthenticator>
412+
/**
413+
* Returns all authenticators from a user.
414+
*
415+
* If a user is not found, the adapter should still return an empty array.
416+
* If the retrieval fails for some other reason, the adapter must throw an error.
417+
*/
418+
listAuthenticatorsByUserId?(
419+
userId: AdapterAuthenticator['userId']
420+
): Awaitable<AdapterAuthenticator[]>
421+
/**
422+
* Updates an authenticator's counter.
423+
*
424+
* If the update fails, the adapter must throw an error.
425+
*/
426+
updateAuthenticatorCounter?(
427+
credentialID: AdapterAuthenticator['credentialID'],
428+
newCounter: AdapterAuthenticator['counter']
429+
): Awaitable<AdapterAuthenticator>
378430
}
379431

380432
// For compatibility with older versions of NextAuth.js

packages/core/src/errors.ts

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,11 @@ type ErrorType =
2828
| "UntrustedHost"
2929
| "Verification"
3030
| "MissingCSRF"
31+
| "AccountNotLinked"
32+
| "DuplicateConditionalUI"
33+
| "MissingWebAuthnAutocomplete"
34+
| "WebAuthnVerificationError"
35+
| "ExperimentalFeatureNotEnabled"
3136

3237
/**
3338
* Base error class for all Auth.js errors.
@@ -385,7 +390,7 @@ export class UnsupportedStrategy extends AuthError {
385390
static type = "UnsupportedStrategy"
386391
}
387392

388-
/** Thrown when the callback endpoint was incorrectly called without a provider. */
393+
/** Thrown when an endpoint was incorrectly called without a provider, or with an unsupported provider. */
389394
export class InvalidProvider extends AuthError {
390395
static type = "InvalidProvider"
391396
}
@@ -427,3 +432,44 @@ export class Verification extends AuthError {
427432
export class MissingCSRF extends SignInError {
428433
static type = "MissingCSRF"
429434
}
435+
436+
/**
437+
* Thrown when multiple providers have `enableConditionalUI` set to `true`.
438+
* Only one provider can have this option enabled at a time.
439+
*/
440+
export class DuplicateConditionalUI extends AuthError {
441+
static type = "DuplicateConditionalUI"
442+
}
443+
444+
/**
445+
* Thrown when a WebAuthn provider has `enableConditionalUI` set to `true` but no formField has `webauthn` in its autocomplete param.
446+
*
447+
* The `webauthn` autocomplete param is required for conditional UI to work.
448+
*/
449+
export class MissingWebAuthnAutocomplete extends AuthError {
450+
static type = "MissingWebAuthnAutocomplete"
451+
}
452+
453+
/**
454+
* Thrown when a WebAuthn provider fails to verify a client response.
455+
*/
456+
export class WebAuthnVerificationError extends AuthError {
457+
static type = "WebAuthnVerificationError"
458+
}
459+
460+
/**
461+
* Thrown when an Email address is already associated with an account
462+
* but the user is trying an account that is not linked to it.
463+
*
464+
* For security reasons, Auth.js does not automatically link accounts to existing accounts if the user is not signed in.
465+
*/
466+
export class AccountNotLinked extends SignInError {
467+
static type = "AccountNotLinked"
468+
}
469+
470+
/**
471+
* Thrown when an experimental feature is used but not enabled.
472+
*/
473+
export class ExperimentalFeatureNotEnabled extends AuthError {
474+
static type = "ExperimentalFeatureNotEnabled"
475+
}

packages/core/src/index.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -422,7 +422,14 @@ export interface AuthConfig {
422422
* @note Experimental features are not guaranteed to be stable and may change or be removed without notice. Please use with caution.
423423
* @default {}
424424
*/
425-
experimental?: Record<string, boolean>
425+
experimental?: {
426+
/**
427+
* Enable WebAuthn support.
428+
*
429+
* @default false
430+
*/
431+
enableWebAuthn?: boolean
432+
}
426433
/**
427434
* The base path of the Auth.js API endpoints.
428435
*

packages/core/src/lib/actions/callback/handle-login.ts

Lines changed: 87 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { OAuthAccountNotLinked } from "../../../errors.js"
1+
import { AccountNotLinked, OAuthAccountNotLinked } from "../../../errors.js"
22
import { fromDate } from "../../utils/date.js"
33

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

3838
const {
@@ -125,6 +125,91 @@ export async function handleLoginOrRegister(
125125
})
126126

127127
return { session, user, isNewUser }
128+
} else if (account.type === "webauthn") {
129+
// Check if the account exists
130+
const userByAccount = await getUserByAccount({
131+
providerAccountId: account.providerAccountId,
132+
provider: account.provider,
133+
})
134+
if (userByAccount) {
135+
if (user) {
136+
// If the user is already signed in with this account, we don't need to do anything
137+
if (userByAccount.id === user.id) {
138+
const currentAccount: AdapterAccount = { ...account, userId: user.id }
139+
return { session, user, isNewUser, account: currentAccount }
140+
}
141+
// If the user is currently signed in, but the new account they are signing in
142+
// with is already associated with another user, then we cannot link them
143+
// and need to return an error.
144+
throw new AccountNotLinked(
145+
"The account is already associated with another user",
146+
{ provider: account.provider }
147+
)
148+
}
149+
// If there is no active session, but the account being signed in with is already
150+
// associated with a valid user then create session to sign the user in.
151+
session = useJwtSession
152+
? {}
153+
: await createSession({
154+
sessionToken: generateSessionToken(),
155+
userId: userByAccount.id,
156+
expires: fromDate(options.session.maxAge),
157+
})
158+
159+
const currentAccount: AdapterAccount = { ...account, userId: userByAccount.id }
160+
return { session, user: userByAccount, isNewUser, account: currentAccount }
161+
} else {
162+
// If the account doesn't exist, we'll create it
163+
if (user) {
164+
// If the user is already signed in and the account isn't already associated
165+
// with another user account then we can go ahead and link the accounts safely.
166+
await linkAccount({ ...account, userId: user.id })
167+
await events.linkAccount?.({ user, account, profile })
168+
169+
// As they are already signed in, we don't need to do anything after linking them
170+
const currentAccount: AdapterAccount = { ...account, userId: user.id }
171+
return { session, user, isNewUser, account: currentAccount }
172+
}
173+
174+
// If the user is not signed in and it looks like a new account then we
175+
// check there also isn't an user account already associated with the same
176+
// email address as the one in the request.
177+
const userByEmail = profile.email
178+
? await getUserByEmail(profile.email)
179+
: null
180+
if (userByEmail) {
181+
// We don't trust user-provided email addresses, so we don't want to link accounts
182+
// if the email address associated with the new account is already associated with
183+
// an existing account.
184+
throw new AccountNotLinked(
185+
"Another account already exists with the same e-mail address",
186+
{ provider: account.provider }
187+
)
188+
} else {
189+
// If the current user is not logged in and the profile isn't linked to any user
190+
// accounts (by email or provider account id)...
191+
//
192+
// If no account matching the same [provider].id or .email exists, we can
193+
// create a new account for the user, link it to the OAuth account and
194+
// create a new session for them so they are signed in with it.
195+
user = await createUser({ ...profile })
196+
}
197+
await events.createUser?.({ user })
198+
199+
await linkAccount({ ...account, userId: user.id })
200+
await events.linkAccount?.({ user, account, profile })
201+
202+
session = useJwtSession
203+
? {}
204+
: await createSession({
205+
sessionToken: generateSessionToken(),
206+
userId: user.id,
207+
expires: fromDate(options.session.maxAge),
208+
})
209+
210+
const currentAccount: AdapterAccount = { ...account, userId: user.id }
211+
return { session, user, isNewUser: true, account: currentAccount }
212+
}
128213
}
129214

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

0 commit comments

Comments
 (0)