Skip to content

Commit 484c61e

Browse files
committed
prod: update passkey, OAuth2, and core behavior
- Updated passkey plugin to use VerifiedPasskey types - Removed legacy WebAuthn JSON handling - Removed email_otp from second factor methods and updated types - Added deterministicTokenHash for stable token generation - Implemented rate limiting in email‑otp and magic‑link flows - Refactored OAuth2 plugin to use an authorization‑code exchange - Added idempotency support and updated docs, examples, and tests
1 parent ac8a26e commit 484c61e

25 files changed

Lines changed: 1237 additions & 226 deletions

README.md

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@ Type-safe, plugin-first authentication core for TypeScript applications.
88
- Strongly typed register/authenticate payloads inferred from enabled plugins.
99
- Path-based issue model (`{ message, path }`) for field-level error mapping.
1010
- Built-in methods: password, email OTP, magic link, OAuth2, passkey.
11-
- Built-in domain plugins: two-factor auth (TOTP/recovery), organizations/RBAC.
12-
- Framework-agnostic core (works with Next.js, SvelteKit, Workers, Node servers).
11+
- Built-in domain plugins: two-factor auth (TOTP/recovery code), organizations/RBAC.
12+
- Framework-agnostic core for TypeScript apps running in Node-compatible server environments.
1313

1414
## Install
1515

@@ -164,19 +164,19 @@ createIssue("Generic failure");
164164
### OAuth2 (Arctic)
165165

166166
- Method: `"oauth2"`
167-
- Uses provider clients with `validateAuthorizationCode(...)` (Arctic-compatible).
167+
- Uses provider exchange callbacks. Arctic clients can be wrapped with `arcticAuthorizationCodeExchange(...)`.
168168
- Supports profile completion when required fields are missing.
169169

170170
```ts
171171
import { Google } from "arctic";
172-
import { oauth2Plugin } from "@oglofus/auth";
172+
import { arcticAuthorizationCodeExchange, oauth2Plugin } from "@oglofus/auth";
173173

174174
const google = new Google(process.env.GOOGLE_CLIENT_ID!, process.env.GOOGLE_CLIENT_SECRET!, process.env.GOOGLE_REDIRECT_URI!);
175175

176176
oauth2Plugin<AppUser, "google", "given_name" | "family_name">({
177177
providers: {
178178
google: {
179-
client: google,
179+
exchangeAuthorizationCode: arcticAuthorizationCodeExchange(google),
180180
resolveProfile: async ({ tokens }) => {
181181
const res = await fetch("https://openidconnect.googleapis.com/v1/userinfo", {
182182
headers: { Authorization: `Bearer ${tokens.accessToken()}` },
@@ -211,19 +211,23 @@ const result = await auth.authenticate({
211211
authorizationCode: "code-from-callback",
212212
redirectUri: process.env.GOOGLE_REDIRECT_URI!,
213213
codeVerifier: "pkce-code-verifier",
214+
idempotencyKey: "oauth-state",
214215
});
215216
```
216217

217218
### Passkey
218219

219220
- Method: `"passkey"`
220221
- Register + authenticate supported.
222+
- The package consumes already-verified passkey results; it does not perform raw WebAuthn attestation/assertion verification.
223+
- Verify WebAuthn with `@simplewebauthn/server` or equivalent first, then pass the verified result into `auth.register(...)` / `auth.authenticate(...)`.
221224
- Config: `requiredProfileFields`, `passkeys` adapter.
222225

223226
### Two-Factor (Domain Plugin)
224227

225228
- Method: `"two_factor"`
226229
- Adds post-primary verification (`TWO_FACTOR_REQUIRED`).
230+
- This release supports `totp` and `recovery_code`.
227231
- Uses `@oslojs/otp` internally for TOTP verification and enrollment URI generation.
228232
- Plugin API:
229233
- `beginTotpEnrollment(userId)`

docs/CONCEPT.md

Lines changed: 19 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -92,8 +92,6 @@ export type AuthMethodName = PrimaryAuthMethod | (string & {});
9292

9393
export type SecondFactorMethod =
9494
| "totp"
95-
| "email_otp"
96-
| "passkey"
9795
| "recovery_code";
9896

9997
export type AccountDiscoveryMode = "private" | "explicit";
@@ -180,37 +178,45 @@ export type OAuth2AuthenticateInput<P extends string> = {
180178
provider: P;
181179
authorizationCode: string;
182180
redirectUri: string;
181+
idempotencyKey?: string;
183182
};
184183

185184
export type OAuth2RegisterInput<P extends string> = OAuth2AuthenticateInput<P>;
186185

187-
// Keep this intentionally generic to avoid coupling core to specific WebAuthn helper libs.
188-
export type WebAuthnJson = Record<string, unknown>;
186+
export type VerifiedPasskeyRegistration = {
187+
credentialId: string;
188+
publicKey: string;
189+
counter: number;
190+
transports?: string[];
191+
};
192+
193+
export type VerifiedPasskeyAuthentication = {
194+
credentialId: string;
195+
nextCounter: number;
196+
};
189197

190198
export type PasskeyAuthenticateInput = {
191199
method: "passkey";
192-
email?: string;
193-
assertion: WebAuthnJson;
200+
authentication: VerifiedPasskeyAuthentication;
194201
};
195202

196203
export type PasskeyRegisterInput<U extends UserBase, K extends keyof U> = {
197204
method: "passkey";
198205
email: string;
199-
attestation: WebAuthnJson;
206+
registration: VerifiedPasskeyRegistration;
200207
} & LocalProfileFields<U, K>;
201208

202209
export type TwoFactorVerifyInput =
203210
| { method: "totp"; pendingAuthId: string; code: string }
204-
| { method: "email_otp"; pendingAuthId: string; code: string }
205-
| { method: "passkey"; pendingAuthId: string; assertion: WebAuthnJson }
206211
| { method: "recovery_code"; pendingAuthId: string; code: string };
207212

208213
export type ProfileCompletionState<U extends UserBase> = {
209214
pendingProfileId: string;
210-
sourceMethod: "oauth2" | "passkey";
215+
sourceMethod: AuthMethodName;
211216
email?: string;
212217
missingFields: readonly Extract<keyof U, string>[];
213218
prefill: Partial<U>;
219+
continuation?: Record<string, unknown>;
214220
};
215221

216222
export type CompleteProfileInput<U extends UserBase> = {
@@ -1443,7 +1449,7 @@ Keep wrappers thin so developer can swap providers later with minimal breakage.
14431449
- OTP/magic-link send failures return explicit `DELIVERY_FAILED` (never silent event-only failures).
14441450
- OTP/magic-link/2FA challenge consumption must be atomic to prevent replay races.
14451451
- OTP verification must be bound to `challengeId` (never resolve active challenge only by email).
1446-
- Passkey verification must validate challenge, origin, RP ID, and signature counter.
1452+
- Passkey verification must validate challenge, origin, RP ID, and signature counter before calling this package; the package consumes already-verified passkey results.
14471453
- OAuth2 callbacks must use `state`/PKCE and be idempotent against duplicate provider callbacks.
14481454
- Session expiration + rotation support.
14491455
- `accountDiscovery.mode = "private"` should avoid leaking user existence through explicit routing messages.
@@ -2347,9 +2353,9 @@ Fix in spec:
23472353
- For email OTP verify/register, require and validate `challengeId` (avoid "latest challenge by email" lookup).
23482354
- For out-of-band methods, use explicit delivery handlers for sending; use events only for observability/policy.
23492355
- Use atomic compare-and-set for one-time credentials/challenges/recovery-code consumption.
2350-
- For passkeys, strictly verify RP ID/origin/challenge and store updated counters after successful assertions.
2356+
- For passkeys, require verified registration/authentication results from a WebAuthn library and store updated counters after successful assertions.
23512357
- For 2FA, bind second-factor verification to `pendingAuthId` and consume challenge on success.
2352-
- For OAuth/passkey partial signups, return `PROFILE_COMPLETION_REQUIRED` with missing fields and pending profile ID.
2358+
- For OAuth partial signups, return `PROFILE_COMPLETION_REQUIRED` with missing fields and pending profile ID.
23532359
- For organizations, enforce tenant scoping and membership status checks before permission/feature/limit evaluation.
23542360
- For organizations, ensure bootstrap and seat-limited membership creation are atomic.
23552361
- For organizations, validate role catalog on startup (default role, owner role, no cyclic inheritance).

examples/oauth2-google-arctic/README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@
22

33
Files:
44

5-
- `auth.ts`: `oauth2Plugin` configured with Arctic `Google` client.
5+
- `auth.ts`: `oauth2Plugin` configured with Arctic `Google` client via `arcticAuthorizationCodeExchange(...)` and an idempotency adapter.
66
- `start-route.ts`: starts OAuth flow and stores state + PKCE verifier in cookies.
7-
- `callback-route.ts`: validates callback and calls `auth.authenticate`.
7+
- `callback-route.ts`: validates callback and calls `auth.authenticate` with the callback `state` as `idempotencyKey`.
88

99
Required environment variables:
1010

examples/oauth2-google-arctic/auth.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import {
22
OglofusAuth,
3+
arcticAuthorizationCodeExchange,
34
oauth2Plugin,
45
type OAuth2AccountAdapter,
6+
type IdempotencyAdapter,
57
type PendingProfileAdapter,
68
type PendingProfileRecord,
79
type Session,
@@ -21,6 +23,7 @@ const usersByEmail = new Map<string, AppUser>();
2123
const sessionsById = new Map<string, Session>();
2224
const linkedAccounts = new Map<string, string>();
2325
const pendingProfiles = new Map<string, PendingProfileRecord<AppUser>>();
26+
const seenCallbacks = new Set<string>();
2427

2528
const users: UserAdapter<AppUser> = {
2629
findById: async (id) => usersById.get(id) ?? null,
@@ -92,6 +95,17 @@ const pending: PendingProfileAdapter<AppUser> = {
9295
},
9396
};
9497

98+
const idempotency: IdempotencyAdapter = {
99+
checkAndSet: async (key) => {
100+
if (seenCallbacks.has(key)) {
101+
return false;
102+
}
103+
104+
seenCallbacks.add(key);
105+
return true;
106+
},
107+
};
108+
95109
const google = new Google(
96110
process.env.GOOGLE_CLIENT_ID!,
97111
process.env.GOOGLE_CLIENT_SECRET!,
@@ -103,12 +117,13 @@ export const auth = new OglofusAuth({
103117
users,
104118
sessions,
105119
pendingProfiles: pending,
120+
idempotency,
106121
},
107122
plugins: [
108123
oauth2Plugin<AppUser, "google", "given_name" | "family_name">({
109124
providers: {
110125
google: {
111-
client: google,
126+
exchangeAuthorizationCode: arcticAuthorizationCodeExchange(google),
112127
resolveProfile: async ({ tokens }) => {
113128
const response = await fetch("https://openidconnect.googleapis.com/v1/userinfo", {
114129
headers: {

examples/oauth2-google-arctic/callback-route.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
1818
authorizationCode: code,
1919
redirectUri: process.env.GOOGLE_REDIRECT_URI!,
2020
codeVerifier,
21+
idempotencyKey: state,
2122
});
2223

2324
if (!result.ok) {

package-lock.json

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
"LICENSE"
2727
],
2828
"devDependencies": {
29-
"@types/node": "^25.3.0",
29+
"@types/node": "^25.3.5",
3030
"tsx": "^4.21.0",
3131
"typescript": "^5.9.3"
3232
},

0 commit comments

Comments
 (0)