Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions .changeset/multi-method-auth.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
---
'@factiii/auth': minor
---

Multi-method accounts: one account can hold a password, several passkeys, and
both Google and Apple at once, and manage them from settings.

Passkey and multi-provider storage now live in dedicated **adapters**
(`config.passkey`, `config.oauthAccounts`) mirroring `deviceAuth`, instead of
loose entries in `hooks`.

**Breaking — OAuth is now table-based.** The `User.oauthProvider` / `oauthId`
scalar is gone; the `OAuthAccount` table (via `config.oauthAccounts`) is the sole
source of truth. Concretely:
- `AuthUser` and `CreateUserData` no longer include `oauthProvider` / `oauthId`,
and `findByEmailOrOAuthId` is removed from `DatabaseAdapter`. Drop the
`oauthProvider` / `oauthId` columns from your User table. The prebuilt Prisma
and Drizzle adapters already reflect this.
- `oAuthLogin` requires a `config.oauthAccounts` adapter (throws if OAuth is used
without one). It resolves by the linked provider identity, attaches a provider
to an existing passwordless account with the same email, else creates one.
- 2FA no longer refuses "social login accounts" — it keys off whether the
account has a password (a social account may now also have one).

**Breaking — passkey storage moved.** The passkey storage that shipped in 0.18 as
`hooks.storePasskeyChallenge` / `consumePasskeyChallenge` / `createPasskeyUser` /
`resolvePasskeyCredential` / `onPasskeyAuthenticated` / `userHasPasskey` moves to
a `PasskeyAdapter` on `config.passkey`, renamed `storeChallenge` /
`consumeChallenge` / `createUser` / `resolveCredential` / `onAuthenticated` /
`has` (plus new `list` / `add` / `remove`).

- `OAuthAccountAdapter` (`config.oauthAccounts`): `resolve` / `link` / `unlink` /
`list`. New authed `oAuthLink` / `oAuthUnlink`.
- Add-passkey to an existing account: `auth.passkey.addOptions` / `addVerify` /
`list` / `remove` (via the `passkey` adapter's `list` / `add` / `remove`).
- Passkey registration now fires `onUserCreated` (it didn't before), so
provisioning is shared across password/OAuth/passkey signup instead of being
re-implemented inside the passkey adapter. `onUserCreated`'s input type widened
to include the passkey register input.
- `setPassword` for passwordless accounts (uses the User adapter).
- `countLoginMethods` / `assertKeepsLoginMethod` / `resolveLoginMethods`
exported; every unlink/remove keeps at least one method.
- Prebuilt Prisma adapters `createPrismaOAuthAccountAdapter(prisma)` (fully
generic) and `createPrismaPasskeyAdapter(prisma, { createUser, challenge })`
(generic CRUD; you inject user-creation + the challenge store), mirroring
`createPrismaDeviceAdapter`.
- Reference `Passkey` + `OAuthAccount` models in both schemas; README documented.
45 changes: 44 additions & 1 deletion packages/auth/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ Do **not** reach for `domain` to solve this — it applies to both cookies and w

## Procedures

Auth procedures: `register`, `login`, `logout`, `refresh`, `changePassword`, `resetPassword`, `oAuthLogin`, `enableTwofa`, `disableTwofa`, `sendVerificationEmail`, `verifyEmail`, and more.
Auth procedures: `register`, `login`, `logout`, `refresh`, `changePassword`, `setPassword`, `resetPassword`, `oAuthLogin`, `oAuthLink`, `oAuthUnlink`, `enableTwofa`, `disableTwofa`, `sendVerificationEmail`, `verifyEmail`, `passkey.*`, and more. See [Multi-method accounts](#multi-method-accounts-passkeys--linked-providers).

## Lifecycle Hooks

Expand Down Expand Up @@ -184,6 +184,49 @@ interface AuthHooks {
}
```

## Multi-method accounts (passkeys + linked providers)

One account can hold a password, several passkeys, and both Google and Apple. All additive and opt-in — implement the storage hooks and the matching procedures light up; omit them and behavior is unchanged (single provider, single scalar).

**Passkeys** (`features.passkey`): `auth.passkey.registerOptions` / `registerVerify` create a new account; `auth.passkey.addOptions` / `addVerify` / `list` / `remove` manage a signed-in account's credentials. The package runs the WebAuthn ceremony; you own storage via the **`passkey` adapter** (`config.passkey`, like `deviceAuth`):

```typescript
// PasskeyAdapter
storeChallenge, consumeChallenge, createUser, resolveCredential,
onAuthenticated, has, list, add, remove
```

**Linked OAuth providers** — the **`oauthAccounts` adapter** (`config.oauthAccounts`) lets `auth.oAuthLink` / `auth.oAuthUnlink` attach/detach providers, and `oAuthLogin` resolve any linked provider:

```typescript
// OAuthAccountAdapter
resolve, link, unlink, list
```

When `oauthAccounts` is provided, `resolve` is the source of truth for OAuth sign-in, so `oAuthLogin` no longer rejects a token whose provider differs from the legacy `User.oauthProvider` scalar. Keep the scalar as the primary/creation provider (mirror it into the link table) for backwards compatibility.

**Add a password** to a passwordless (passkey/OAuth) account: `auth.setPassword` — no adapter, it uses the User adapter.

**Prisma? Skip the boilerplate.** Like `createPrismaDeviceAdapter`, the package ships prebuilt Prisma adapters — the credential/link CRUD is generic, so you only wire the app-specific bits:

```ts
import {
createPrismaOAuthAccountAdapter,
createPrismaPasskeyAdapter,
} from '@factiii/auth';

createAuthRouter({
// ...
oauthAccounts: createPrismaOAuthAccountAdapter(prisma), // fully generic
passkey: createPrismaPasskeyAdapter(prisma, {
createUser: async (input) => { /* your user creation + provisioning */ },
challenge: { storeChallenge, consumeChallenge }, // e.g. Redis with a TTL
}),
});
```

The `Passkey` and `OAuthAccount` Prisma models ship in the reference schemas (`prisma/schema.*.prisma`). Every unlink/remove is guarded so an account never loses its last sign-in method; `countLoginMethods`, `assertKeepsLoginMethod` and `resolveLoginMethods` are exported (the last drives a username-first login screen).

## CLI

```bash
Expand Down
63 changes: 55 additions & 8 deletions packages/auth/prisma/schema.device.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -64,20 +64,21 @@ model User {
password String?
username String @unique
twoFaEnabled Boolean @default(false)
oauthProvider OAuthProvider?
oauthId String?
tag UserTag @default(HUMAN)
isActive Boolean @default(false)
verifiedHumanAt DateTime?
otpForEmailVerification String?

// Relations
sessions Session[]
passwordReset PasswordReset[]
otps OTP[]
devices Device[] @relation("devices")
admin Admin?
magicLinks MagicLink[]
sessions Session[]
passwordReset PasswordReset[]
otps OTP[]
devices Device[] @relation("devices")
admin Admin?
magicLinks MagicLink[]
// Optional — passkey + multi-provider features (see models at end of file).
passkeys Passkey[]
oAuthAccounts OAuthAccount[]
}

// ==============================================================================
Expand Down Expand Up @@ -167,3 +168,49 @@ model MagicLink {

@@index([userId])
}

// ==============================================================================
// Passkey Model (optional — enable with features.passkey)
// ==============================================================================
// The package runs the WebAuthn ceremony; you own storage via the `passkey`
// adapter (config.passkey — storeChallenge/consumeChallenge/createUser/
// resolveCredential/onAuthenticated/has/list/add/remove). A user may have several.

model Passkey {
id String @id @default(uuid())
credentialId String @unique // base64url credential ID
publicKey Bytes // COSE public key
counter BigInt @default(0)
transports String[] @default([]) // e.g. ["internal","hybrid"]
deviceType String? // "singleDevice" | "multiDevice"
backedUp Boolean @default(false)
name String? // friendly label, editable in settings
createdAt DateTime @default(now())
lastUsedAt DateTime?
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)

@@index([userId])
}

// ==============================================================================
// OAuthAccount Model (optional — multi-provider accounts)
// ==============================================================================
// Lets one account link both Google and Apple. Implement the `oauthAccounts`
// adapter (config.oauthAccounts — resolve/link/unlink/list) against this table;
// when it is provided, `resolve` becomes the source of truth for OAuth sign-in
// and the legacy User.oauthProvider scalar stops gating. Keep the scalar as the
// primary/creation provider (and mirror it here) for backwards compat.

model OAuthAccount {
id String @id @default(uuid())
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
provider OAuthProvider
providerSubject String // provider 'sub' / oauthId
email String?
createdAt DateTime @default(now())

@@unique([provider, providerSubject])
@@index([userId])
}
61 changes: 54 additions & 7 deletions packages/auth/prisma/schema.standard.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -66,19 +66,20 @@ model User {
// 2FA is "on" iff `twoFaSecret` is non-null — no separate enabled flag.
twoFaSecret String?
twoFaBackupCodes String[] @default([])
oauthProvider OAuthProvider?
oauthId String?
tag UserTag @default(HUMAN)
isActive Boolean @default(false)
verifiedHumanAt DateTime?
otpForEmailVerification String?

// Relations
sessions Session[]
passwordReset PasswordReset[]
otps OTP[]
admin Admin?
magicLinks MagicLink[]
sessions Session[]
passwordReset PasswordReset[]
otps OTP[]
admin Admin?
magicLinks MagicLink[]
// Optional — passkey + multi-provider features (see models at end of file).
passkeys Passkey[]
oAuthAccounts OAuthAccount[]
}

// ==============================================================================
Expand Down Expand Up @@ -151,3 +152,49 @@ model MagicLink {

@@index([userId])
}

// ==============================================================================
// Passkey Model (optional — enable with features.passkey)
// ==============================================================================
// The package runs the WebAuthn ceremony; you own storage via the `passkey`
// adapter (config.passkey — storeChallenge/consumeChallenge/createUser/
// resolveCredential/onAuthenticated/has/list/add/remove). A user may have several.

model Passkey {
id String @id @default(uuid())
credentialId String @unique // base64url credential ID
publicKey Bytes // COSE public key
counter BigInt @default(0)
transports String[] @default([]) // e.g. ["internal","hybrid"]
deviceType String? // "singleDevice" | "multiDevice"
backedUp Boolean @default(false)
name String? // friendly label, editable in settings
createdAt DateTime @default(now())
lastUsedAt DateTime?
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)

@@index([userId])
}

// ==============================================================================
// OAuthAccount Model (optional — multi-provider accounts)
// ==============================================================================
// Lets one account link both Google and Apple. Implement the `oauthAccounts`
// adapter (config.oauthAccounts — resolve/link/unlink/list) against this table;
// when it is provided, `resolve` becomes the source of truth for OAuth sign-in
// and the legacy User.oauthProvider scalar stops gating. Keep the scalar as the
// primary/creation provider (and mirror it here) for backwards compat.

model OAuthAccount {
id String @id @default(uuid())
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
provider OAuthProvider
providerSubject String // provider 'sub' / oauthId
email String?
createdAt DateTime @default(now())

@@unique([provider, providerSubject])
@@index([userId])
}
5 changes: 0 additions & 5 deletions packages/auth/src/adapters/database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,6 @@ export interface AuthUser {
twoFaSecret?: string | null;
/** Standard-mode only. Single-use recovery codes. */
twoFaBackupCodes?: string[];
oauthProvider: string | null;
oauthId: string | null;
tag: string;
verifiedHumanAt: Date | null;
emailVerificationStatus: string;
Expand Down Expand Up @@ -76,8 +74,6 @@ export interface CreateUserData {
tag: string;
emailVerificationStatus: string;
verifiedHumanAt: Date | null;
oauthProvider?: string;
oauthId?: string;
}

export interface CreateSessionData {
Expand All @@ -100,7 +96,6 @@ export interface DatabaseAdapter {
findByEmailInsensitive(email: string): Promise<AuthUser | null>;
findByUsernameInsensitive(username: string): Promise<AuthUser | null>;
findByEmailOrUsernameInsensitive(identifier: string): Promise<AuthUser | null>;
findByEmailOrOAuthId(email: string, oauthId: string): Promise<AuthUser | null>;
findById(id: number): Promise<AuthUser | null>;
findActiveById(id: number): Promise<AuthUser | null>;
create(data: CreateUserData): Promise<AuthUser>;
Expand Down
14 changes: 0 additions & 14 deletions packages/auth/src/adapters/drizzleAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,20 +122,6 @@ export function createDrizzleAdapter(
return (rows[0] as unknown as AuthUser | undefined) ?? null;
},

async findByEmailOrOAuthId(email: string, oauthId: string): Promise<AuthUser | null> {
const rows = await db
.select()
.from(users)
.where(
or(
sql`lower(${users.email}) = lower(${email})`,
eq(users.oauthId, oauthId)
)
)
.limit(1);
return (rows[0] as unknown as AuthUser | undefined) ?? null;
},

async findById(id: number): Promise<AuthUser | null> {
const rows = await db
.select()
Expand Down
26 changes: 26 additions & 0 deletions packages/auth/src/adapters/oauthAccount.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/**
* Linked-OAuth-provider storage adapter for @factiii/auth.
*
* Required when OAuth is enabled. `resolve` is the source of truth for OAuth
* sign-in, and one account can link several providers (e.g. both Google and
* Apple). See `prisma/schema.*.prisma` for the reference `OAuthAccount` model.
*/
export interface OAuthAccountAdapter {
/** Resolve a linked provider identity to its account. Null if not linked. */
resolve(
provider: 'GOOGLE' | 'APPLE',
subject: string
): Promise<{ userId: number } | null>;

/** Attach a provider identity to a user (idempotent for the same user). */
link(
userId: number,
data: { provider: 'GOOGLE' | 'APPLE'; subject: string; email: string | null }
): Promise<void>;

/** Detach a provider from a user. */
unlink(userId: number, provider: 'GOOGLE' | 'APPLE'): Promise<void>;

/** List a user's linked providers — powers the keep-one-method guard. */
list(userId: number): Promise<Array<'GOOGLE' | 'APPLE'>>;
}
58 changes: 58 additions & 0 deletions packages/auth/src/adapters/passkey.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/**
* Passkey (WebAuthn) storage adapter for @factiii/auth.
*
* Required (and only used) when `features.passkey` is enabled. The package runs
* the WebAuthn ceremony and mints the session; this adapter owns all storage —
* the short-lived challenge, the credential, and user creation. See
* `prisma/schema.*.prisma` for the reference `Passkey` model.
*/
import type { PasskeyChallengeType, PasskeyCredential, StoredPasskeyCredential } from '../types/passkey';
import type { PasskeyRegisterInput, SchemaExtensions } from '../types/hooks';

export interface PasskeyAdapter<TExtensions extends SchemaExtensions = {}> {
/** Persist a short-lived challenge; return a `flowId` the client echoes back on verify. */
storeChallenge(data: {
challenge: string;
type: PasskeyChallengeType;
username: string | null;
expiresAt: Date;
}): Promise<{ flowId: string }>;

/** Look up + delete a challenge by flowId. Null if missing/expired. */
consumeChallenge(
flowId: string
): Promise<{ challenge: string; type: PasskeyChallengeType; username: string | null } | null>;

/** Create the user and persist the verified credential; return the new userId. */
createUser(input: PasskeyRegisterInput<TExtensions>): Promise<{ userId: number }>;

/** Resolve a stored credential for an authentication ceremony. Null if unknown. */
resolveCredential(credentialId: string): Promise<StoredPasskeyCredential | null>;

/** Persist the updated signature counter after a successful authentication. */
onAuthenticated(credentialId: string, newCounter: number): Promise<void>;

/** Whether a user has any passkey — used to label the login method accurately. */
has(userId: number): Promise<boolean>;

/** List a user's credentials (settings list, `excludeCredentials`, keep-one guard). */
list(userId: number): Promise<
Array<{
id: string;
credentialId: string;
transports: string[];
name: string | null;
createdAt: Date;
lastUsedAt: Date | null;
}>
>;

/** Persist a verified credential against an existing user. */
add(
userId: number,
credential: PasskeyCredential & { name?: string | null }
): Promise<{ id: string }>;

/** Delete one of the user's passkeys by its storage id. */
remove(userId: number, id: string): Promise<void>;
}
Loading
Loading