@wtfalch/auth 0.12.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -99,11 +99,11 @@ again. `forget()` sets the same mark, so a sign-out does not undo itself.
99
99
  `completeSignIn()` at the callback returns `signed-in`, `silent-refused` — the
100
100
  answer to a silent attempt, not a failure — or `error`.
101
101
 
102
- ### Hub seams: a non-default opener, storage, refresh and scopes
102
+ ### Multi-session seams: a non-default opener, storage, refresh and scopes
103
103
 
104
104
  Four options on `BrowserAuthOptions` exist for a client that is not a
105
105
  plain same-origin web page — a phone driving a system browser, a desktop app,
106
- or a hub app juggling more than one issuer session. Every default matches the
106
+ or an app juggling more than one issuer session. Every default matches the
107
107
  behaviour above exactly, so a caller that sets none of them sees no change.
108
108
 
109
109
  ```ts
@@ -124,7 +124,7 @@ const auth = createBrowserAuth({
124
124
  `{ get, set, drop }` shape — `get` synchronous, `set`/`drop` allowed to
125
125
  return a promise (see "Storage: `get` stays synchronous" below). Two
126
126
  `createBrowserAuth` instances given different stores never see each other's
127
- values — one hub's session cannot leak into another's held in the same tab.
127
+ values — one app's session cannot leak into another's held in the same tab.
128
128
  - **`refresh()`**, a new method on the returned client, posts the
129
129
  `refresh_token` grant and stores the session it gets back — awaiting the
130
130
  store's write before it resolves, so an app that awaits `refresh()` knows
package/dist/auth.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { type JWTPayload } from 'jose';
2
2
  import { type AuthRequest, type NewUser, type PeoplePage } from './broker.js';
3
- import { type AuthOptions } from './config.js';
3
+ import { type AccessTokenResult, type AuthOptions } from './config.js';
4
4
  import { type SetCookie } from './cookies.js';
5
5
  import type { SessionRecord } from './sessions.js';
6
6
  export interface User {
@@ -163,33 +163,6 @@ export interface QrPollResult {
163
163
  reason?: string;
164
164
  cookies: SetCookie[];
165
165
  }
166
- /**
167
- * Why `accessToken` refused: no session cookie, or one that will not open
168
- * (`no_session`); a session with no refresh token (`no_refresh_token`); or
169
- * the issuer refusing the refresh grant itself, including the same
170
- * organisation/id-token validation `readCookie`'s refresh path applies
171
- * (`refused`). A transient outage is not in this list -- `accessToken`
172
- * throws for that instead, see its own doc comment.
173
- */
174
- export type AccessTokenReason = 'no_session' | 'no_refresh_token' | 'refused';
175
- export type AccessTokenResult = {
176
- ok: true;
177
- /** The issuer's own bearer token. Never the id token, and never what was in the cookie. */
178
- accessToken: string;
179
- /** Epoch milliseconds. */
180
- expiresAt: number;
181
- /**
182
- * The re-sealed session -- always exactly one cookie, even when the
183
- * refresh token itself did not rotate, because the grant always
184
- * returns a fresh id token too. Persist it, e.g. as a `Set-Cookie`,
185
- * before the value passed in is used again: the refresh token in it
186
- * is spent.
187
- */
188
- cookies: SetCookie[];
189
- } | {
190
- ok: false;
191
- reason: AccessTokenReason;
192
- };
193
166
  export interface Auth {
194
167
  readonly sessionCookieName: string;
195
168
  /** GET {basePath}/start?next=&intent= : begins the OIDC flow; the issuer sends the browser to {basePath}/login?authRequest= */
@@ -415,33 +388,33 @@ export interface Auth {
415
388
  */
416
389
  pollQrSignIn(request: Request): Promise<QrPollResult>;
417
390
  /**
418
- * Trades a sealed session cookie's value (`readCookie`'s own `value`
419
- * parameter, not a `Cookie` header) for a fresh issuer access token. The
420
- * cookie never holds one -- `Tokens` keeps only an id token and a refresh
421
- * token -- so this runs the refresh grant itself and reads `access_token`
422
- * straight off that response, applying the same id-token and organisation
423
- * checks `readCookie`'s own refresh path applies, through the same
424
- * `Oidc.refresh`.
391
+ * Trades a sealed session's `value` (`readCookie`'s own `value` parameter,
392
+ * not a `Cookie` header -- in namespace mode, the handle) for a fresh
393
+ * issuer access token. Nothing here ever holds one -- `Tokens` keeps only
394
+ * an id token and a refresh token -- so this runs the refresh grant itself
395
+ * and reads `access_token` straight off that response, applying the same
396
+ * id-token and organisation checks `readCookie`'s own refresh path
397
+ * applies, through the same `Oidc.refresh`.
425
398
  *
426
399
  * The refresh token is SPENT on every call: the issuer rotates it on each
427
- * grant. A caller must serialise calls for one session -- two in flight
428
- * would race the same token -- and persist `cookies`, the re-sealed
429
- * session this always returns on `ok: true` (a fresh id token comes back
430
- * on every grant, even on the rare one that does not rotate the refresh
431
- * token), before the session is read or `accessToken` is called again.
400
+ * grant. Cookie mode: a caller must serialise calls for one session -- two
401
+ * in flight would race the same token -- and persist `cookies`, the
402
+ * re-sealed session this always returns on `ok: true` (a fresh id token
403
+ * comes back on every grant, even on the rare one that does not rotate the
404
+ * refresh token), before the session is read or `accessToken` is called
405
+ * again. Namespace mode: `cookies` is always empty, and the call itself
406
+ * serialises under the record's own lease -- the same one a due `read`
407
+ * refreshes under -- so two calls, or a call racing a due read, spend the
408
+ * token in turn rather than together.
432
409
  *
433
410
  * `ok: false` is a definitive refusal -- no session, no refresh token in
434
411
  * it, or the issuer refusing the grant (`isOAuthRefusal`, or the same
435
412
  * organisation/id-token check `readCookie` applies). A transient issuer
436
413
  * outage (network error, 5xx, 429) throws instead of answering
437
414
  * `ok: false`: unlike `readCookie`, there is no still-valid access token
438
- * to fall back to, because one was never kept.
439
- *
440
- * Not available in namespace mode: those sessions are a handle to a
441
- * server-held record, refreshed under a lease (see `namespace-session.ts`)
442
- * that serialises rotation across replicas. Spending the refresh token
443
- * here would run outside that lease and could race, or duplicate, the
444
- * rotation the lease exists to prevent. Throws when `namespace` is set.
415
+ * to fall back to, because one was never kept, in either mode.
416
+ * Namespace mode also throws while the binding is suspended, and when
417
+ * another holder keeps the record's lease past the wait.
445
418
  */
446
419
  accessToken(cookieValue: string): Promise<AccessTokenResult>;
447
420
  }
package/dist/auth.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { errors } from 'jose';
2
2
  import * as client from 'openid-client';
3
3
  import { Broker, BrokerError } from './broker.js';
4
- import { resolveOptions } from './config.js';
4
+ import { resolveOptions, } from './config.js';
5
5
  import { clearQrTransaction, clearSession, clearTransaction, cookieFrom, openQrTransaction, openRecord, openSession, openTransaction, qrTransactionCookieName, sealQrTransaction, sealSession, sealTransaction, sessionCookieName, transactionCookieName, } from './cookies.js';
6
6
  import { namespaceSessions } from './namespace-session.js';
7
7
  import { AuthError, ORG_CLAIM, Oidc, isOAuthRefusal, scopeFor } from './oidc.js';
@@ -19,6 +19,26 @@ export function createAuth(input) {
19
19
  }
20
20
  return resolved;
21
21
  };
22
+ const emit = (event) => {
23
+ try {
24
+ // The type says void, but nothing stops a caller from handing over an
25
+ // async function -- audit logging is usually a database write. An
26
+ // async throw never reaches this catch (calling an async function
27
+ // cannot throw synchronously, whatever is on its first line), so a
28
+ // thenable return is caught too, or a careless callback would produce
29
+ // an unhandled rejection instead of the logged-and-ignored failure
30
+ // promised above.
31
+ const result = options().onAuthEvent?.(event);
32
+ if (result && typeof result.then === 'function') {
33
+ Promise.resolve(result).catch((error) => {
34
+ console.error('@wtfalch/auth: onAuthEvent threw', error);
35
+ });
36
+ }
37
+ }
38
+ catch (error) {
39
+ console.error('@wtfalch/auth: onAuthEvent threw', error);
40
+ }
41
+ };
22
42
  const boundUser = (claims) => {
23
43
  const namespace = options().namespace;
24
44
  return {
@@ -460,7 +480,11 @@ export function createAuth(input) {
460
480
  const resendVerification = (email, next) => quietly(() => broker().sendVerification(email, safeNextPath(next, '')));
461
481
  const requestPasswordReset = (email, next) => quietly(() => broker().sendReset(email, safeNextPath(next, '')));
462
482
  const sendLink = (email, next) => quietly(() => broker().sendLink(email, safeNextPath(next, '')));
463
- const invite = (input) => broker().invite({ ...input, next: safeNextPath(input.next, '') });
483
+ const invite = async (input) => {
484
+ const result = await broker().invite({ ...input, next: safeNextPath(input.next, '') });
485
+ emit({ type: 'invite', email: input.email, userId: result.userId, invited: result.invited });
486
+ return result;
487
+ };
464
488
  const resetPassword = async ({ userId, code, password, next, }, requestHeaders) => {
465
489
  try {
466
490
  const flow = await serverFlow();
@@ -505,21 +529,29 @@ export function createAuth(input) {
505
529
  };
506
530
  const signIn = async ({ authRequestId, email, password, }) => {
507
531
  try {
508
- return { ok: true, redirectTo: await broker().signIn({ authRequestId, email, password }) };
532
+ const redirectTo = await broker().signIn({ authRequestId, email, password });
533
+ emit({ type: 'signIn', email });
534
+ return { ok: true, redirectTo };
509
535
  }
510
536
  catch (error) {
511
- if (needsHostedLogin(error))
537
+ if (needsHostedLogin(error)) {
538
+ emit({ type: 'signIn', email });
512
539
  return { ok: true, redirectTo: hostedUrl(authRequestId), hosted: true };
540
+ }
513
541
  return { ok: false, error: signInError(error) };
514
542
  }
515
543
  };
516
544
  const signUp = async ({ authRequestId, ...user }) => {
517
545
  try {
518
- return { ok: true, redirectTo: await broker().signUp({ authRequestId, ...user }) };
546
+ const redirectTo = await broker().signUp({ authRequestId, ...user });
547
+ emit({ type: 'signUp', email: user.email });
548
+ return { ok: true, redirectTo };
519
549
  }
520
550
  catch (error) {
521
- if (needsHostedLogin(error))
551
+ if (needsHostedLogin(error)) {
552
+ emit({ type: 'signUp', email: user.email });
522
553
  return { ok: true, redirectTo: hostedUrl(authRequestId), hosted: true };
554
+ }
523
555
  if (error instanceof BrokerError && error.error === 'email_taken') {
524
556
  return { ok: false, error: 'email_taken', message: error.detail ?? error.error };
525
557
  }
@@ -707,9 +739,8 @@ export function createAuth(input) {
707
739
  return { status: 'signed_in', cookies: [cleared, session] };
708
740
  };
709
741
  const accessToken = async (cookieValue) => {
710
- if (options().namespace) {
711
- throw new Error('@wtfalch/auth: accessToken is not supported in namespace mode -- those sessions refresh under a server-side lease this would bypass');
712
- }
742
+ if (options().namespace)
743
+ return sessions.accessToken(cookieValue);
713
744
  const session = await openSession(options(), cookieValue);
714
745
  if (!session)
715
746
  return { ok: false, reason: 'no_session' };
package/dist/browser.d.ts CHANGED
@@ -85,7 +85,7 @@ export interface BrowserAuthOptions {
85
85
  /**
86
86
  * Holds the token, verifier, return path and silent-tried mark. Default
87
87
  * wraps `sessionStorage` and never throws. Two instances given different
88
- * stores never see each other's values — inject one per hub.
88
+ * stores never see each other's values — inject one per app.
89
89
  */
90
90
  store?: BrowserAuthStore;
91
91
  }
package/dist/config.d.ts CHANGED
@@ -1,5 +1,55 @@
1
+ import type { SetCookie } from './cookies.js';
1
2
  import { type NamespaceSelection, resolveNamespaceContext } from './namespaces.js';
2
3
  import type { SessionStore } from './sessions.js';
4
+ /**
5
+ * An auth lifecycle event, for audit logging. Each variant carries only
6
+ * what the call that fires it already has in hand -- `signIn`/`signUp`
7
+ * resolve to a redirect, not a decoded identity, so there is no `userId`
8
+ * to offer there; `invite` calls the broker's own endpoint, which returns
9
+ * one.
10
+ */
11
+ export type AuthEvent = {
12
+ type: 'signIn';
13
+ email: string;
14
+ } | {
15
+ type: 'signUp';
16
+ email: string;
17
+ } | {
18
+ type: 'invite';
19
+ email: string;
20
+ userId: string;
21
+ invited: boolean;
22
+ };
23
+ /**
24
+ * Why `accessToken` refused: no session (no cookie in cookie mode, no live
25
+ * record behind the namespace handle in namespace mode), a session with no
26
+ * refresh token (`no_refresh_token`), or the issuer refusing the refresh
27
+ * grant itself, including the same organisation/id-token validation the
28
+ * ordinary refresh path applies (`refused`). A transient outage is not in
29
+ * this list -- `accessToken` throws for that instead, see its own doc
30
+ * comment.
31
+ */
32
+ export type AccessTokenReason = 'no_session' | 'no_refresh_token' | 'refused';
33
+ export type AccessTokenResult = {
34
+ ok: true;
35
+ /** The issuer's own bearer token. Never the id token, and never what was in the cookie. */
36
+ accessToken: string;
37
+ /** Epoch milliseconds. */
38
+ expiresAt: number;
39
+ /**
40
+ * Cookie mode: always exactly one, even when the refresh token itself
41
+ * did not rotate, because the grant always returns a fresh id token
42
+ * too. Persist it, e.g. as a `Set-Cookie`, before the value passed in
43
+ * is used again: the refresh token in it is spent.
44
+ *
45
+ * Namespace mode: always empty. The record updates itself under its
46
+ * own lease; there is nothing for the caller to persist.
47
+ */
48
+ cookies: SetCookie[];
49
+ } | {
50
+ ok: false;
51
+ reason: AccessTokenReason;
52
+ };
3
53
  export interface AuthOptions {
4
54
  /** Opt-in namespace mode. Resolve this from trusted workspace/host configuration. */
5
55
  namespace?: NamespaceSelection;
@@ -59,6 +109,13 @@ export interface AuthOptions {
59
109
  */
60
110
  freshness?: number;
61
111
  fetch?: typeof fetch;
112
+ /**
113
+ * Called after a sign-in, a sign-up, or an invite succeeds, so an app can
114
+ * add audit logging without threading it through every call site by hand.
115
+ * Never awaited; a throw from it is caught and logged rather than failing
116
+ * the call it rode in on.
117
+ */
118
+ onAuthEvent?: (event: AuthEvent) => void;
62
119
  }
63
120
  export interface ResolvedOptions {
64
121
  namespace: ReturnType<typeof resolveNamespaceContext> | null;
@@ -93,6 +150,7 @@ export interface ResolvedOptions {
93
150
  freshness: number;
94
151
  secure: boolean;
95
152
  fetch: typeof fetch;
153
+ onAuthEvent: ((event: AuthEvent) => void) | null;
96
154
  }
97
155
  export declare const ISSUER = "https://auth.wtfalch.dev";
98
156
  export declare function resolveOptions(options: AuthOptions): ResolvedOptions;
package/dist/config.js CHANGED
@@ -59,6 +59,7 @@ export function resolveOptions(options) {
59
59
  freshness: seconds(options.freshness, 'freshness', 5 * 60),
60
60
  secure: appUrl.protocol === 'https:',
61
61
  fetch: options.fetch ?? globalThis.fetch,
62
+ onAuthEvent: options.onAuthEvent ?? null,
62
63
  };
63
64
  }
64
65
  function required(value, name) {
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- export { type AccessTokenReason, type AccessTokenResult, type Auth, type AuthRequest, type Gate, type GateRules, type Intent, type MaxAge, type NewUser, type PasskeyRegistrationResult, type PasskeyRegistrationStart, type PasskeySignInStart, type Prompt, type QrPollResult, type QrPollStatus, type QrSignInStart, type ReadResult, type ResetError, type SignInError, type SignInResult, type SignUpError, type SignUpResult, type SignedIn, type User, createAuth, } from './auth.js';
2
- export { type AuthOptions, ISSUER } from './config.js';
1
+ export { type Auth, type AuthRequest, type Gate, type GateRules, type Intent, type MaxAge, type NewUser, type PasskeyRegistrationResult, type PasskeyRegistrationStart, type PasskeySignInStart, type Prompt, type QrPollResult, type QrPollStatus, type QrSignInStart, type ReadResult, type ResetError, type SignInError, type SignInResult, type SignUpError, type SignUpResult, type SignedIn, type User, createAuth, } from './auth.js';
2
+ export { type AccessTokenReason, type AccessTokenResult, type AuthEvent, type AuthOptions, ISSUER, } from './config.js';
3
3
  export type { SetCookie } from './cookies.js';
4
4
  export type { SessionRecord, SessionStore, SessionUpdate } from './sessions.js';
5
5
  export { BrokerError } from './broker.js';
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  export { createAuth, } from './auth.js';
2
- export { ISSUER } from './config.js';
2
+ export { ISSUER, } from './config.js';
3
3
  export { BrokerError } from './broker.js';
4
4
  export { AuthError, ORG_CLAIM } from './oidc.js';
5
5
  export { safeNextPath } from './redirect.js';
@@ -1,6 +1,6 @@
1
1
  import type { JWTPayload } from 'jose';
2
2
  import { type Broker } from './broker.js';
3
- import type { ResolvedOptions } from './config.js';
3
+ import type { AccessTokenResult, ResolvedOptions } from './config.js';
4
4
  import { type SetCookie } from './cookies.js';
5
5
  import { type Oidc, type Tokens } from './oidc.js';
6
6
  import type { SessionRecord } from './sessions.js';
@@ -42,4 +42,5 @@ export declare function namespaceSessions(deps: {
42
42
  listSessions: (subject: string) => Promise<SessionRecord[]>;
43
43
  endSession: (key: string, subject: string) => Promise<boolean>;
44
44
  keyOf: (value: string | undefined) => Promise<string | null>;
45
+ accessToken: (value: string | undefined) => Promise<AccessTokenResult>;
45
46
  };
@@ -266,6 +266,116 @@ export function namespaceSessions(deps) {
266
266
  tokens: { k: key, idt: fresh.idToken, rt: fresh.refreshToken },
267
267
  };
268
268
  };
269
+ /**
270
+ * A fresh access token for this namespace session, under the record's own
271
+ * lease -- so this can never race the rotation a due `read` triggers, and
272
+ * two calls for the same session serialise rather than spend the refresh
273
+ * token twice.
274
+ *
275
+ * Unlike `refresh`, this can never settle for "another request just
276
+ * refreshed it, that generation bump is good enough": the access token
277
+ * from that grant was never kept (`RecordPayload` holds only an id token
278
+ * and a refresh token, see `namespace-session.ts`'s own module comment),
279
+ * so the only way to answer is a grant this call runs itself. It waits
280
+ * out whoever holds the lease first, the same wait `refresh` does, but
281
+ * always performs its own grant once it takes the lease rather than
282
+ * reusing `refreshLeased`'s "give up and use what's there" shortcut.
283
+ */
284
+ const accessToken = async (value) => {
285
+ const key = await keyOf(value);
286
+ if (!key)
287
+ return { ok: false, reason: 'no_session' };
288
+ // A suspended binding mints nothing, the same gate `read` and every
289
+ // sign-in entry point apply. It throws rather than answering `ok: false`,
290
+ // as `startSignIn` does: a suspension is not a verdict on this session,
291
+ // and the fail-closed answer while the service is unreachable is not
292
+ // definitive either.
293
+ if (!(await bindingActive()))
294
+ throw new AuthError('unavailable', 'the namespace binding is not active');
295
+ let current = await load(key);
296
+ if (!current)
297
+ return { ok: false, reason: 'no_session' };
298
+ if (!current.tokens.rt)
299
+ return { ok: false, reason: 'no_refresh_token' };
300
+ for (let attempt = 0;; attempt++) {
301
+ const { record } = current;
302
+ const leased = record.leaseUntil !== null && record.leaseUntil > now();
303
+ if (!leased) {
304
+ const generation = record.generation + 1;
305
+ const acquired = await store().advance(key, record.generation, {
306
+ payload: record.payload,
307
+ expiresAt: record.expiresAt,
308
+ leaseUntil: now() + LEASE_SECONDS,
309
+ });
310
+ if (acquired)
311
+ return runGrant(key, current, generation);
312
+ }
313
+ // Still leased after the wait: someone else's grant is in flight, or a
314
+ // crashed holder's lease has not run out yet. Neither is a verdict on
315
+ // this session, so this throws like a transient issuer outage rather
316
+ // than answering `refused`, which a caller may treat as signed out.
317
+ if (attempt >= WAIT_STEPS)
318
+ throw new Error('@wtfalch/auth: accessToken timed out waiting for the session lease');
319
+ await sleep(WAIT_STEP_MS);
320
+ const latest = await load(key);
321
+ if (!latest)
322
+ return { ok: false, reason: 'no_session' };
323
+ if (!latest.tokens.rt)
324
+ return { ok: false, reason: 'no_refresh_token' };
325
+ current = latest;
326
+ }
327
+ };
328
+ /** The grant `accessToken` runs once it holds the lease itself. */
329
+ const runGrant = async (key, leased, generation) => {
330
+ const { record, tokens } = leased;
331
+ let fresh;
332
+ try {
333
+ fresh = await deps.oidc().refresh(tokens.rt);
334
+ }
335
+ catch (error) {
336
+ await store()
337
+ .advance(key, generation, {
338
+ payload: record.payload,
339
+ expiresAt: record.expiresAt,
340
+ leaseUntil: null,
341
+ })
342
+ .catch(() => { });
343
+ if (definitive(error)) {
344
+ // The lease means nobody else spent the token: the issuer has ended it.
345
+ await store().revoke(key, now());
346
+ return { ok: false, reason: 'refused' };
347
+ }
348
+ // A network error or a 5xx: no access token was ever minted, and
349
+ // unlike a plain session read there is no still-valid one to fall
350
+ // back to -- so this throws, exactly as the cookie-mode accessToken
351
+ // does for the same case.
352
+ throw error;
353
+ }
354
+ const committed = await store().advance(key, generation, {
355
+ payload: await seal(key, fresh),
356
+ expiresAt: now() + options().sessionMaxAge,
357
+ leaseUntil: null,
358
+ });
359
+ if (!committed) {
360
+ // Revoked while the grant was in flight, or the lease ran out and
361
+ // another request took over. The fresh tokens are kept nowhere, so
362
+ // the refresh token is not left live if it rotated.
363
+ if (fresh.refreshToken !== tokens.rt)
364
+ revokeToken(fresh.refreshToken);
365
+ return { ok: false, reason: 'refused' };
366
+ }
367
+ if (!fresh.accessToken || fresh.accessTokenExpiresAt === undefined) {
368
+ // The issuer answered the grant but sent no access token -- treat it
369
+ // the same as a refusal rather than handing back an unusable result.
370
+ return { ok: false, reason: 'refused' };
371
+ }
372
+ return {
373
+ ok: true,
374
+ accessToken: fresh.accessToken,
375
+ expiresAt: fresh.accessTokenExpiresAt,
376
+ cookies: [],
377
+ };
378
+ };
269
379
  /**
270
380
  * The person behind this cookie. Never sets a cookie: a dead or revoked
271
381
  * handle just reads as nobody until the next sign-in replaces it.
@@ -298,7 +408,17 @@ export function namespaceSessions(deps) {
298
408
  return { kind: 'none' };
299
409
  }
300
410
  };
301
- return { bindingActive, create, end, read, revokeSubject, listSessions, endSession, keyOf };
411
+ return {
412
+ bindingActive,
413
+ create,
414
+ end,
415
+ read,
416
+ revokeSubject,
417
+ listSessions,
418
+ endSession,
419
+ keyOf,
420
+ accessToken,
421
+ };
302
422
  }
303
423
  /**
304
424
  * These apps sit behind a proxy, so the client never reaches this process
package/dist/next.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { type NextRequest, NextResponse } from 'next/server';
2
2
  import { type Auth, type AuthRequest, type ChangePasswordResult, type DeletePasskeyResult, type EmailChangeResult, type GateRules, type Intent, type MaxAge, type NewUser, type Passkey, type PasskeyRegistrationResult, type PasskeyRegistrationStart, type PasskeySignInStart, type Prompt, type QrPollStatus, type ResetError, type SignInError, type SignUpError, type User } from './auth.js';
3
- import type { AuthOptions } from './config.js';
3
+ import type { AuthEvent, AuthOptions } from './config.js';
4
4
  import type { SessionRecord } from './sessions.js';
5
5
  type SearchParams = Record<string, string | string[] | undefined>;
6
6
  export interface QrSignIn {
@@ -157,4 +157,4 @@ export interface NextAuth {
157
157
  }>;
158
158
  }
159
159
  export declare function nextAuth(options: AuthOptions): NextAuth;
160
- export type { Auth, AuthOptions, AuthRequest, ChangePasswordResult, DeletePasskeyResult, EmailChangeResult, GateRules, Intent, MaxAge, NewUser, Passkey, PasskeyRegistrationResult, PasskeyRegistrationStart, PasskeySignInStart, Prompt, QrPollStatus, SessionRecord, User, };
160
+ export type { Auth, AuthEvent, AuthOptions, AuthRequest, ChangePasswordResult, DeletePasskeyResult, EmailChangeResult, GateRules, Intent, MaxAge, NewUser, Passkey, PasskeyRegistrationResult, PasskeyRegistrationStart, PasskeySignInStart, Prompt, QrPollStatus, SessionRecord, User, };
@@ -0,0 +1,31 @@
1
+ import type { Auth, User } from './auth.js';
2
+ export declare function setSessionUser(user: User | null): void;
3
+ /** A User shaped like the SDK's, for `setSessionUser`. */
4
+ export declare function testUser(id: string, overrides?: Partial<User>): User;
5
+ export declare function getUser(): Promise<User | null>;
6
+ export declare function requireUser(_next?: string): Promise<User>;
7
+ export declare const handlers: () => never;
8
+ export declare const proxy: () => never;
9
+ export declare const authRequest: () => never;
10
+ export declare const signIn: () => never;
11
+ export declare const signUp: () => never;
12
+ export declare const requestPasswordReset: () => never;
13
+ export declare const resetPassword: () => never;
14
+ export declare const sendLink: () => never;
15
+ export declare const resendVerification: () => never;
16
+ /**
17
+ * The core SDK instance, as far as a device sign-in module reaches into it.
18
+ *
19
+ * The real one talks to the issuer. This one records what was asked and
20
+ * answers with whatever a test set, so a device/link sign-in flow can be
21
+ * exercised against a real database without one. Only the members named
22
+ * here are stubbed; anything else read off `auth` is a mistake worth a loud
23
+ * one.
24
+ */
25
+ export declare const sentLinks: {
26
+ email: string;
27
+ next?: string;
28
+ }[];
29
+ export declare function setFollowLinkResult(result: unknown): void;
30
+ export declare function setCookieUser(user: User | null): void;
31
+ export declare const auth: Auth;
@@ -0,0 +1,81 @@
1
+ /**
2
+ * A stub for whatever `@/lib/auth` resolves to in an app's integration test
3
+ * tier. The real module reads a sealed cookie through `next/headers`, which
4
+ * does not load outside a Next.js build; this one answers with whatever
5
+ * `setSessionUser` last set, so a test can be "signed in" as one person,
6
+ * call the app's own authorization logic for real, then sign in as another.
7
+ * Nothing here talks to the issuer.
8
+ *
9
+ * Alias the app's auth module to `@wtfalch/auth/testing` for that tier (for
10
+ * example in a vitest config's `resolve.alias`), or re-export from it.
11
+ */
12
+ let current = null;
13
+ export function setSessionUser(user) {
14
+ current = user;
15
+ }
16
+ /** A User shaped like the SDK's, for `setSessionUser`. */
17
+ export function testUser(id, overrides = {}) {
18
+ return {
19
+ id,
20
+ email: `${id}@example.test`,
21
+ emailVerified: true,
22
+ name: `Person ${id}`,
23
+ givenName: null,
24
+ familyName: null,
25
+ preferredUsername: null,
26
+ organizationId: 'org-test',
27
+ // Never fresh, which is what a stubbed session should be: the issuer only
28
+ // owes `auth_time` when the request asked for it, and nothing here asks.
29
+ authTime: null,
30
+ claims: {},
31
+ ...overrides,
32
+ };
33
+ }
34
+ export async function getUser() {
35
+ return current;
36
+ }
37
+ export async function requireUser(_next) {
38
+ if (!current)
39
+ throw new Error('requireUser: no session set; call setSessionUser first');
40
+ return current;
41
+ }
42
+ function unavailable(name) {
43
+ return () => {
44
+ throw new Error(`${name} is not available in the integration tier`);
45
+ };
46
+ }
47
+ export const handlers = unavailable('handlers');
48
+ export const proxy = unavailable('proxy');
49
+ export const authRequest = unavailable('authRequest');
50
+ export const signIn = unavailable('signIn');
51
+ export const signUp = unavailable('signUp');
52
+ export const requestPasswordReset = unavailable('requestPasswordReset');
53
+ export const resetPassword = unavailable('resetPassword');
54
+ export const sendLink = unavailable('sendLink');
55
+ export const resendVerification = unavailable('resendVerification');
56
+ /**
57
+ * The core SDK instance, as far as a device sign-in module reaches into it.
58
+ *
59
+ * The real one talks to the issuer. This one records what was asked and
60
+ * answers with whatever a test set, so a device/link sign-in flow can be
61
+ * exercised against a real database without one. Only the members named
62
+ * here are stubbed; anything else read off `auth` is a mistake worth a loud
63
+ * one.
64
+ */
65
+ export const sentLinks = [];
66
+ let linkResult = { ok: false, error: 'invalid_code', message: 'no result set' };
67
+ let cookieUser = null;
68
+ export function setFollowLinkResult(result) {
69
+ linkResult = result;
70
+ }
71
+ export function setCookieUser(user) {
72
+ cookieUser = user;
73
+ }
74
+ export const auth = {
75
+ sessionCookieName: '__Host-wtfalch_auth',
76
+ sendLink: async (email, next) => {
77
+ sentLinks.push({ email, next });
78
+ },
79
+ followLink: async () => linkResult,
80
+ readCookie: async () => ({ user: cookieUser, cookies: [] }),
81
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wtfalch/auth",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "description": "Sign in against auth.wtfalch.dev: a server session for a Next.js app, and a browser client with silent single sign-on across subdomains.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -37,6 +37,10 @@
37
37
  "./sessions": {
38
38
  "types": "./dist/sessions.d.ts",
39
39
  "default": "./dist/sessions.js"
40
+ },
41
+ "./testing": {
42
+ "types": "./dist/testing.d.ts",
43
+ "default": "./dist/testing.js"
40
44
  }
41
45
  },
42
46
  "sideEffects": false,