@wtfalch/auth 0.11.1 → 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,6 +99,83 @@ 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
+ ### Multi-session seams: a non-default opener, storage, refresh and scopes
103
+
104
+ Four options on `BrowserAuthOptions` exist for a client that is not a
105
+ plain same-origin web page — a phone driving a system browser, a desktop app,
106
+ or an app juggling more than one issuer session. Every default matches the
107
+ behaviour above exactly, so a caller that sets none of them sees no change.
108
+
109
+ ```ts
110
+ const auth = createBrowserAuth({
111
+ issuer: 'https://auth.wtfalch.dev',
112
+ clientId: '…',
113
+ open: (url) => Browser.open({ url }), // Capacitor's system-browser opener
114
+ store: capacitorSecureStore, // see "Storage stays synchronous" below
115
+ extraScopes: ['urn:zitadel:iam:org:project:id:<mail project>:aud'],
116
+ });
117
+ ```
118
+
119
+ - **`open(url)`** replaces `window.location.assign` — what leaves the tab for
120
+ the issuer. It may return a promise, which is awaited before `authorize()`
121
+ returns; the default stays synchronous.
122
+ - **`store`** replaces the `sessionStorage` wrapper that holds the token,
123
+ verifier, return path and silent-tried mark, with a plain
124
+ `{ get, set, drop }` shape — `get` synchronous, `set`/`drop` allowed to
125
+ return a promise (see "Storage: `get` stays synchronous" below). Two
126
+ `createBrowserAuth` instances given different stores never see each other's
127
+ values — one app's session cannot leak into another's held in the same tab.
128
+ - **`refresh()`**, a new method on the returned client, posts the
129
+ `refresh_token` grant and stores the session it gets back — awaiting the
130
+ store's write before it resolves, so an app that awaits `refresh()` knows
131
+ the rotated token is durable, not just in memory. ZITADEL rotates the
132
+ refresh token on every use — the one just spent cannot be reused — so a
133
+ refusal (`{ kind: 'refused' }`) drops the held session rather than leaving a
134
+ dead one behind; `{ kind: 'no-refresh-token' }` means there was nothing to
135
+ refresh. Concurrent calls share one in-flight exchange: two callers racing
136
+ near expiry both get the freshly rotated session, rather than the second
137
+ spending the first's now-dead token and refusing a session the first just
138
+ set. The refresh token itself never appears on the value `currentSession()`
139
+ or `refresh()` returns to app code — only `refresh()`'s own closure over
140
+ the store can reach it — because a value passed around the app should not
141
+ double as something that can mint new access tokens.
142
+ - **`extraScopes`** appends to `scope` (or its default) at `/authorize`. Each
143
+ entry is itself split on whitespace and de-duplicated against the rest —
144
+ `['read write']` and `['read', 'write']` do the same thing — so an audience
145
+ scope for one project, say, never lands twice or leaves a stray space when
146
+ an entry is empty. The refresh grant freezes the audience from first
147
+ sign-in, so this only matters there.
148
+ - **`forget()`** is now `Promise<void>` rather than `void`, the one signature
149
+ change here: it awaits the store's writes, the same as `completeSignIn()`
150
+ and `refresh()`.
151
+
152
+ **Storage: `get` stays synchronous; `set` and `drop` may be async.**
153
+ `currentSession()` and `silentSignInAvailable()` are synchronous methods on
154
+ every `createBrowserAuth` client, defaults included, and both read only
155
+ through the store's `get` — so `get` cannot become a promise without changing
156
+ that contract for every existing caller, not just a Capacitor one. `set` and
157
+ `drop` can, because the methods that write through them —
158
+ `completeSignIn()`, `refresh()` and `forget()` — are already async: each now
159
+ awaits the store's write before it returns, so a write is durable by the time
160
+ an app that awaits one of those methods acts on it.
161
+
162
+ A Capacitor secure-storage store bridges the two: keep a synchronous
163
+ in-memory mirror for `get` to read, and have `set`/`drop` write the mirror
164
+ *and* await the secure store, so the promise those methods return resolves
165
+ only once the write actually lands. Hydrate the mirror from the secure store
166
+ once at startup, before the app's first call to `currentSession()` — a `get`
167
+ before that has nothing to read and reports no session even when one is
168
+ held.
169
+
170
+ **Namespace clients (`namespace: {...}`) do not take a custom `store`.** That
171
+ path already coordinates a session across tabs and clients through its own
172
+ fixed `sessionStorage`/`localStorage` keys (see `namespace-browser.ts`), and
173
+ swapping in an injected store would mean redesigning that coordination, not
174
+ wiring one in — `createBrowserAuth` throws rather than silently ignoring it.
175
+ `open` and `extraScopes` do apply there, unchanged. `refresh()` is present but
176
+ always reports `no-refresh-token`: the namespace exchange never retains a
177
+ refresh token to begin with.
178
+
102
179
  ## Configuration
103
180
 
104
181
  | | |
@@ -139,6 +216,18 @@ app URL, and refreshes itself when the id token is close to expiring.
139
216
  `ORG_CLAIM` is enforced on every token: a token from another organisation is
140
217
  refused even when the issuer and the signature are good.
141
218
 
219
+ The cookie never carries an access token -- every exchange and refresh
220
+ discards it. `accessToken(cookieValue)` reads one back for a server that
221
+ needs to call a resource server on a signed-in person's behalf: it runs the
222
+ refresh grant itself and returns `{ ok: true, accessToken, expiresAt,
223
+ cookies }` or `{ ok: false, reason }`. The refresh token is spent on every
224
+ call, so `cookies` -- always the re-sealed session, carrying the grant's
225
+ fresh id token even when the refresh token itself did not rotate -- must be
226
+ persisted before the session is read or `accessToken` called again.
227
+ Not available in namespace mode. `extraScopes` on `createAuth`'s options
228
+ adds to the scope every sign-in and QR device grant asks for, for a
229
+ resource server's own audience scope. See `docs/adopting.md` for both.
230
+
142
231
  ## Passkeys and QR sign-in
143
232
 
144
233
  Passkeys live on each app's own domain: the relying-party ID is the app's
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 {
@@ -387,6 +387,36 @@ export interface Auth {
387
387
  * failed callback rather than throwing.
388
388
  */
389
389
  pollQrSignIn(request: Request): Promise<QrPollResult>;
390
+ /**
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`.
398
+ *
399
+ * The refresh token is SPENT on every call: the issuer rotates it on each
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.
409
+ *
410
+ * `ok: false` is a definitive refusal -- no session, no refresh token in
411
+ * it, or the issuer refusing the grant (`isOAuthRefusal`, or the same
412
+ * organisation/id-token check `readCookie` applies). A transient issuer
413
+ * outage (network error, 5xx, 429) throws instead of answering
414
+ * `ok: false`: unlike `readCookie`, there is no still-valid access token
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.
418
+ */
419
+ accessToken(cookieValue: string): Promise<AccessTokenResult>;
390
420
  }
391
421
  export declare function createAuth(input: AuthOptions): Auth;
392
422
  export type { AuthRequest, NewUser, SetCookie };
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
  }
@@ -655,7 +687,7 @@ export function createAuth(input) {
655
687
  const startQrSignIn = async () => {
656
688
  if (options().namespace && !(await sessions.bindingActive()))
657
689
  throw new AuthError('unavailable', 'the namespace binding is not active');
658
- const device = await requestDeviceAuthorization(options(), scopeFor(options().organizationId));
690
+ const device = await requestDeviceAuthorization(options(), scopeFor(options().organizationId, options().extraScopes));
659
691
  const cookie = await sealQrTransaction(options(), { dc: device.deviceCode, iv: device.interval }, device.expiresIn);
660
692
  return {
661
693
  url: qrVerificationUrl(options(), device.verificationUriComplete),
@@ -706,6 +738,41 @@ export function createAuth(input) {
706
738
  await replacePreviousSession(request.headers.get('cookie'));
707
739
  return { status: 'signed_in', cookies: [cleared, session] };
708
740
  };
741
+ const accessToken = async (cookieValue) => {
742
+ if (options().namespace)
743
+ return sessions.accessToken(cookieValue);
744
+ const session = await openSession(options(), cookieValue);
745
+ if (!session)
746
+ return { ok: false, reason: 'no_session' };
747
+ if (!session.rt)
748
+ return { ok: false, reason: 'no_refresh_token' };
749
+ let tokens;
750
+ try {
751
+ tokens = await oidc().refresh(session.rt);
752
+ }
753
+ catch (error) {
754
+ if (error instanceof AuthError || isOAuthRefusal(error))
755
+ return { ok: false, reason: 'refused' };
756
+ throw error;
757
+ }
758
+ if (!tokens.accessToken || tokens.accessTokenExpiresAt === undefined) {
759
+ // The issuer answered the grant but sent no access token -- treat it
760
+ // the same as a refusal rather than handing back an unusable result.
761
+ return { ok: false, reason: 'refused' };
762
+ }
763
+ // Always re-sealed, even when the refresh token itself did not rotate:
764
+ // the grant always returns a fresh id token, and readCookie's own
765
+ // refresh path re-seals on every successful refresh for the same reason.
766
+ const cookies = [
767
+ await sealSession(options(), { idt: tokens.idToken, rt: tokens.refreshToken }),
768
+ ];
769
+ return {
770
+ ok: true,
771
+ accessToken: tokens.accessToken,
772
+ expiresAt: tokens.accessTokenExpiresAt,
773
+ cookies,
774
+ };
775
+ };
709
776
  const failure = (reason, cookies) => {
710
777
  const url = new URL(options().onError, options().appUrl);
711
778
  url.searchParams.set('auth_error', reason);
@@ -759,6 +826,7 @@ export function createAuth(input) {
759
826
  finishPasskeySignIn,
760
827
  startQrSignIn,
761
828
  pollQrSignIn,
829
+ accessToken,
762
830
  };
763
831
  }
764
832
  function redirect(location, cookies, status = 302) {
package/dist/browser.d.ts CHANGED
@@ -37,6 +37,23 @@ import type { NamespaceSelection } from './namespaces.js';
37
37
  * the tab spins. `forget()` sets the same mark, so pressing sign out does not
38
38
  * bounce somebody straight back in — a sign-out that undoes itself is not one.
39
39
  */
40
+ /** Leaves the tab for the issuer instead of `window.location.assign`. A
41
+ * phone driving a system browser (Capacitor, say) supplies its own; may
42
+ * return a promise, which is awaited before `authorize()` returns. */
43
+ export type BrowserOpener = (url: string) => void | Promise<void>;
44
+ /**
45
+ * Where the token, verifier, return path and silent-tried mark live.
46
+ * `get` is synchronous: `currentSession()` and `silentSignInAvailable()` are
47
+ * synchronous on `BrowserAuth`, defaults included, and read only through it.
48
+ * `set` and `drop` may return a promise — for a store backed by something
49
+ * like Capacitor secure storage — which `completeSignIn`, `refresh` and
50
+ * `forget` await before they return. See the package README.
51
+ */
52
+ export interface BrowserAuthStore {
53
+ get(key: string): string | null;
54
+ set(key: string, value: string): void | Promise<void>;
55
+ drop(key: string): void | Promise<void>;
56
+ }
40
57
  export interface BrowserAuthOptions {
41
58
  /** Public namespace registry projection; never include broker or cookie secrets. */
42
59
  namespace?: NamespaceSelection;
@@ -57,6 +74,20 @@ export interface BrowserAuthOptions {
57
74
  callbackPath?: string;
58
75
  /** Scopes to ask for. `openid` is required; the rest is the app's business. */
59
76
  scope?: string;
77
+ /**
78
+ * Extra scopes appended to `scope` at `/authorize`, de-duplicated with it —
79
+ * an audience scope for one project, say. The refresh grant freezes the
80
+ * audience from first sign-in, so this has no effect on `refresh()`.
81
+ */
82
+ extraScopes?: string[];
83
+ /** Leaves the tab for the issuer. Default: `window.location.assign`. */
84
+ open?: BrowserOpener;
85
+ /**
86
+ * Holds the token, verifier, return path and silent-tried mark. Default
87
+ * wraps `sessionStorage` and never throws. Two instances given different
88
+ * stores never see each other's values — inject one per app.
89
+ */
90
+ store?: BrowserAuthStore;
60
91
  }
61
92
  export interface BrowserSession {
62
93
  namespaceId?: string;
@@ -82,6 +113,25 @@ export type SignInOutcome = {
82
113
  kind: 'error';
83
114
  error: string;
84
115
  };
116
+ /** What `refresh()` found. */
117
+ export type RefreshOutcome = {
118
+ kind: 'refreshed';
119
+ session: BrowserSession;
120
+ }
121
+ /** No session held, or the held one has no refresh token. Not an error. */
122
+ | {
123
+ kind: 'no-refresh-token';
124
+ }
125
+ /**
126
+ * The issuer would not rotate the refresh token. ZITADEL spends a refresh
127
+ * token on every use whether it accepts or refuses it, so either way the
128
+ * old one cannot be tried again — the held session is dropped before this
129
+ * returns.
130
+ */
131
+ | {
132
+ kind: 'refused';
133
+ error: string;
134
+ };
85
135
  export interface BrowserAuth {
86
136
  /** Another account/binding replaced this context; require a deliberate sign-in. */
87
137
  accountChanged(): boolean;
@@ -112,7 +162,18 @@ export interface BrowserAuth {
112
162
  signIn(returnTo?: string): Promise<void>;
113
163
  /** Finish the flow at the callback path. */
114
164
  completeSignIn(search?: string): Promise<SignInOutcome>;
115
- /** Drop the session, and do not silently take another in this tab. */
116
- forget(): void;
165
+ /**
166
+ * Rotate the held refresh token for a new session, when one is held.
167
+ * `currentSession()` never carries the refresh token itself — a value
168
+ * passed around the app should not be able to mint new access tokens — so
169
+ * this is the only way to use it. A refusal drops the held session.
170
+ * Concurrent calls share one in-flight exchange, so two callers racing
171
+ * near expiry both get the freshly rotated session rather than one losing
172
+ * to the other's now-spent refresh token.
173
+ */
174
+ refresh(): Promise<RefreshOutcome>;
175
+ /** Drop the session, and do not silently take another in this tab. Awaits
176
+ * the store's writes, so a caller that awaits this knows they landed. */
177
+ forget(): Promise<void>;
117
178
  }
118
179
  export declare function createBrowserAuth(options: BrowserAuthOptions): BrowserAuth;
package/dist/browser.js CHANGED
@@ -6,7 +6,7 @@ const RETURN = 'wtfalch.auth.return';
6
6
  const TRIED = 'wtfalch.auth.silent-tried';
7
7
  /** Storage that never throws. A browser refusing it signs in every visit,
8
8
  * which is worse than the alternative but not broken. */
9
- const store = {
9
+ const defaultStore = {
10
10
  get(key) {
11
11
  try {
12
12
  return sessionStorage.getItem(key);
@@ -47,17 +47,33 @@ function verifier() {
47
47
  // inside the 43..128 the spec allows.
48
48
  return base64url(crypto.getRandomValues(new Uint8Array(32)).buffer);
49
49
  }
50
+ function publicSession(session) {
51
+ const { refreshToken, ...rest } = session;
52
+ return rest;
53
+ }
50
54
  export function createBrowserAuth(options) {
51
- if (options.namespace)
55
+ if (options.namespace) {
56
+ // The namespace path coordinates a shared session across tabs and
57
+ // clients through fixed `sessionStorage`/`localStorage` keys (see
58
+ // `namespace-browser.ts`); swapping that for an injected store means
59
+ // redesigning that coordination, not wiring one in. Refused with a clear
60
+ // error rather than silently ignored.
61
+ if (options.store)
62
+ throw new Error('createBrowserAuth: a namespace client does not support a custom store');
52
63
  return createNamespaceBrowserAuth(options, options.namespace);
64
+ }
53
65
  const origin = options.origin ?? window.location.origin;
54
66
  const callbackPath = options.callbackPath ?? '/auth/callback';
55
- const scope = options.scope ?? 'openid email profile offline_access';
67
+ const baseScope = options.scope ?? 'openid email profile offline_access';
68
+ const extraScopes = (options.extraScopes ?? []).flatMap((s) => s.split(/\s+/).filter(Boolean));
69
+ const scope = Array.from(new Set([...baseScope.split(/\s+/).filter(Boolean), ...extraScopes])).join(' ');
56
70
  const redirectUri = `${origin}${callbackPath}`;
71
+ const store = options.store ?? defaultStore;
72
+ const open = options.open ?? ((url) => window.location.assign(url));
57
73
  const authorize = async (prompt, returnTo) => {
58
74
  const proof = verifier();
59
- store.set(VERIFIER, proof);
60
- store.set(RETURN, returnTo);
75
+ await store.set(VERIFIER, proof);
76
+ await store.set(RETURN, returnTo);
61
77
  const url = new URL(`${options.issuer}/oauth/v2/authorize`);
62
78
  url.searchParams.set('client_id', options.clientId);
63
79
  url.searchParams.set('redirect_uri', redirectUri);
@@ -67,21 +83,68 @@ export function createBrowserAuth(options) {
67
83
  url.searchParams.set('code_challenge_method', 'S256');
68
84
  if (prompt)
69
85
  url.searchParams.set('prompt', prompt);
70
- window.location.assign(url.toString());
86
+ await open(url.toString());
71
87
  };
72
- const currentSession = () => {
88
+ const readStored = () => {
73
89
  const raw = store.get(TOKEN);
74
90
  if (!raw)
75
91
  return null;
76
92
  try {
77
- const session = JSON.parse(raw);
78
- return session.expiresAt - 60_000 > Date.now() ? session : null;
93
+ return JSON.parse(raw);
79
94
  }
80
95
  catch {
81
96
  return null;
82
97
  }
83
98
  };
99
+ const currentSession = () => {
100
+ const session = readStored();
101
+ if (!session)
102
+ return null;
103
+ return session.expiresAt - 60_000 > Date.now() ? publicSession(session) : null;
104
+ };
84
105
  const silentSignInAvailable = () => !currentSession() && !store.get(TRIED);
106
+ // Shared by every `refresh()` call on this instance, so two callers racing
107
+ // near expiry share one exchange rather than each spending the same
108
+ // refresh token — ZITADEL rotates it on first use, so the second would be
109
+ // refused and drop the session the first just set.
110
+ let refreshing;
111
+ const performRefresh = async () => {
112
+ const session = readStored();
113
+ if (!session?.refreshToken)
114
+ return { kind: 'no-refresh-token' };
115
+ const response = await fetch(`${options.issuer}/oauth/v2/token`, {
116
+ method: 'POST',
117
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
118
+ body: new URLSearchParams({
119
+ grant_type: 'refresh_token',
120
+ client_id: options.clientId,
121
+ refresh_token: session.refreshToken,
122
+ }),
123
+ });
124
+ if (!response.ok) {
125
+ // ZITADEL spends the refresh token on every use, whether it accepts
126
+ // or refuses it: either way the old one cannot be tried again, so
127
+ // the held session goes with it.
128
+ await store.drop(TOKEN);
129
+ const body = await response.text();
130
+ return {
131
+ kind: 'refused',
132
+ error: `the issuer refused the refresh (${response.status}): ${body.slice(0, 200)}`,
133
+ };
134
+ }
135
+ const token = (await response.json());
136
+ if (!token.access_token || !token.refresh_token) {
137
+ await store.drop(TOKEN);
138
+ return { kind: 'refused', error: 'the issuer returned an incomplete refresh response' };
139
+ }
140
+ const next = {
141
+ accessToken: token.access_token,
142
+ expiresAt: Date.now() + (token.expires_in ?? 3600) * 1000,
143
+ refreshToken: token.refresh_token,
144
+ };
145
+ await store.set(TOKEN, JSON.stringify(next));
146
+ return { kind: 'refreshed', session: publicSession(next) };
147
+ };
85
148
  return {
86
149
  accountChanged: () => false,
87
150
  currentSession,
@@ -89,14 +152,14 @@ export function createBrowserAuth(options) {
89
152
  async trySilentSignIn(returnTo = window.location.pathname) {
90
153
  if (!silentSignInAvailable())
91
154
  return false;
92
- store.set(TRIED, '1');
155
+ await store.set(TRIED, '1');
93
156
  await authorize('none', returnTo);
94
157
  return true;
95
158
  },
96
159
  async signIn(returnTo = window.location.pathname) {
97
160
  // A deliberate sign-in clears the mark: whatever the issuer said before,
98
161
  // the person is asking now.
99
- store.drop(TRIED);
162
+ await store.drop(TRIED);
100
163
  await authorize(undefined, returnTo);
101
164
  },
102
165
  async completeSignIn(search = window.location.search) {
@@ -106,8 +169,8 @@ export function createBrowserAuth(options) {
106
169
  // Removed whatever happens: a code that failed to exchange must not be
107
170
  // retried against the same proof, and a stale verifier is what makes the
108
171
  // *next* sign-in fail confusingly.
109
- store.drop(VERIFIER);
110
- store.drop(RETURN);
172
+ await store.drop(VERIFIER);
173
+ await store.drop(RETURN);
111
174
  const issuerError = params.get('error');
112
175
  if (issuerError) {
113
176
  // The three the spec gives for "I would have had to ask them
@@ -151,18 +214,29 @@ export function createBrowserAuth(options) {
151
214
  const session = {
152
215
  accessToken: token.access_token,
153
216
  expiresAt: Date.now() + (token.expires_in ?? 3600) * 1000,
217
+ refreshToken: token.refresh_token,
154
218
  };
155
- store.set(TOKEN, JSON.stringify(session));
219
+ await store.set(TOKEN, JSON.stringify(session));
156
220
  // Signed in: a later silent attempt in this tab is allowed again, which
157
221
  // matters when the token expires and the page reloads.
158
- store.drop(TRIED);
159
- return { kind: 'signed-in', session, returnTo };
222
+ await store.drop(TRIED);
223
+ return { kind: 'signed-in', session: publicSession(session), returnTo };
224
+ },
225
+ async refresh() {
226
+ // One in-flight exchange per instance: two callers racing near expiry
227
+ // must not both spend the same refresh token — ZITADEL rotates it on
228
+ // first use, so the second would be refused and drop the session the
229
+ // first just set.
230
+ refreshing ??= performRefresh().finally(() => {
231
+ refreshing = undefined;
232
+ });
233
+ return refreshing;
160
234
  },
161
- forget() {
162
- store.drop(TOKEN);
235
+ async forget() {
236
+ await store.drop(TOKEN);
163
237
  // The mark stays set: a sign-out that silently signs you back in is not
164
238
  // a sign-out.
165
- store.set(TRIED, '1');
239
+ await store.set(TRIED, '1');
166
240
  },
167
241
  };
168
242
  }
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;
@@ -14,6 +64,15 @@ export interface AuthOptions {
14
64
  clientId: string | undefined;
15
65
  /** From scripts/provisioned.json. Sent as a scope and checked on every token. */
16
66
  organizationId: string | undefined;
67
+ /**
68
+ * Extra scopes appended to `scopeFor`'s fixed set, on both the
69
+ * authorization request `start` builds and the QR device grant --
70
+ * a project's `:aud` scope, say. Each entry is split on whitespace,
71
+ * empty entries dropped, and duplicates (against the fixed scopes and
72
+ * each other) removed. Default: none, and the scope string this SDK has
73
+ * always sent is unchanged, byte for byte.
74
+ */
75
+ extraScopes?: string[];
17
76
  /**
18
77
  * 32 bytes, base64 or hex: `openssl rand -base64 32`. One per app.
19
78
  *
@@ -50,6 +109,13 @@ export interface AuthOptions {
50
109
  */
51
110
  freshness?: number;
52
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;
53
119
  }
54
120
  export interface ResolvedOptions {
55
121
  namespace: ReturnType<typeof resolveNamespaceContext> | null;
@@ -57,6 +123,7 @@ export interface ResolvedOptions {
57
123
  appUrl: URL;
58
124
  clientId: string;
59
125
  organizationId: string;
126
+ extraScopes: string[];
60
127
  /**
61
128
  * Decoded on first access, not at resolve time.
62
129
  *
@@ -83,6 +150,7 @@ export interface ResolvedOptions {
83
150
  freshness: number;
84
151
  secure: boolean;
85
152
  fetch: typeof fetch;
153
+ onAuthEvent: ((event: AuthEvent) => void) | null;
86
154
  }
87
155
  export declare const ISSUER = "https://auth.wtfalch.dev";
88
156
  export declare function resolveOptions(options: AuthOptions): ResolvedOptions;
package/dist/config.js CHANGED
@@ -41,6 +41,7 @@ export function resolveOptions(options) {
41
41
  appUrl,
42
42
  clientId: options.clientId,
43
43
  organizationId: options.organizationId,
44
+ extraScopes: options.extraScopes ?? [],
44
45
  get cookieKey() {
45
46
  cookieKey ??= decodeKey(options.cookieSecret);
46
47
  return cookieKey;
@@ -58,6 +59,7 @@ export function resolveOptions(options) {
58
59
  freshness: seconds(options.freshness, 'freshness', 5 * 60),
59
60
  secure: appUrl.protocol === 'https:',
60
61
  fetch: options.fetch ?? globalThis.fetch,
62
+ onAuthEvent: options.onAuthEvent ?? null,
61
63
  };
62
64
  }
63
65
  function required(value, name) {
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
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 AuthOptions, ISSUER } from './config.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';
@@ -30,7 +30,9 @@ export function createNamespaceBrowserAuth(options, selection) {
30
30
  appOrigin: origin,
31
31
  redirectUri,
32
32
  });
33
- const scope = options.scope ?? 'openid email profile offline_access';
33
+ const open = options.open ?? ((url) => window.location.assign(url));
34
+ const baseScope = options.scope ?? 'openid email profile offline_access';
35
+ const scope = [baseScope, ...(options.extraScopes ?? [])].join(' ');
34
36
  const scopes = new Set(scope.split(/\s+/).filter(Boolean));
35
37
  if (!scopes.has('openid') ||
36
38
  [...scopes].some((s) => s.startsWith('urn:zitadel:iam:org:') &&
@@ -100,7 +102,7 @@ export function createNamespaceBrowserAuth(options, selection) {
100
102
  }).toString();
101
103
  if (!ownsPending(transaction.state))
102
104
  throw new Error('Another sign-in replaced this attempt');
103
- window.location.assign(url.href);
105
+ await open(url.href);
104
106
  };
105
107
  const completeSignIn = async (search = window.location.search) => {
106
108
  const reject = () => ({
@@ -205,6 +207,12 @@ export function createNamespaceBrowserAuth(options, selection) {
205
207
  currentSession,
206
208
  silentSignInAvailable,
207
209
  completeSignIn,
210
+ async refresh() {
211
+ // completeSignIn above never reads `token.refresh_token`, so this
212
+ // store never holds one to rotate: there is nothing offered here that
213
+ // isn't already true of every namespace session.
214
+ return { kind: 'no-refresh-token' };
215
+ },
208
216
  async trySilentSignIn(returnTo = window.location.pathname) {
209
217
  if (!silentSignInAvailable())
210
218
  return false;
@@ -216,7 +224,9 @@ export function createNamespaceBrowserAuth(options, selection) {
216
224
  sessionStorage.removeItem(triedKey);
217
225
  await authorize(undefined, returnTo);
218
226
  },
219
- forget() {
227
+ // Matches BrowserAuth.forget()'s Promise<void> signature; this path's
228
+ // storage is always synchronous, so there is nothing to await.
229
+ async forget() {
220
230
  sessionStorage.removeItem(tokenKey);
221
231
  sessionStorage.removeItem(txKey);
222
232
  sessionStorage.setItem(triedKey, '1');
@@ -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, };
package/dist/oidc.d.ts CHANGED
@@ -9,12 +9,28 @@ export declare const ORG_CLAIM = "urn:zitadel:iam:user:resourceowner:id";
9
9
  * or a request more than a minute behind, still gets the real refusal.
10
10
  */
11
11
  export declare const ROTATION_CACHE_MS = 60000;
12
- /** The scopes every flow asks for: the code-exchange authorization request and the QR device grant alike. */
13
- export declare function scopeFor(organizationId: string): string;
12
+ /**
13
+ * The scopes every flow asks for: the code-exchange authorization request
14
+ * and the QR device grant alike. `extraScopes` -- `AuthOptions.extraScopes`,
15
+ * passed straight through -- is appended after the fixed set: each entry is
16
+ * split on whitespace, empty entries dropped, and anything already present
17
+ * (in the fixed set or an earlier extra entry) skipped. With none, the
18
+ * result is exactly the fixed-set string, byte for byte.
19
+ */
20
+ export declare function scopeFor(organizationId: string, extraScopes?: readonly string[]): string;
14
21
  export interface Tokens {
15
22
  idToken: string;
16
23
  refreshToken?: string;
17
24
  claims: jose.JWTPayload;
25
+ /**
26
+ * The issuer's own access token from this exchange or refresh grant.
27
+ * Never persisted to a cookie or a namespace record -- `tokensFrom` is the
28
+ * only place that reads it off the response; `Auth.accessToken` is the
29
+ * only public caller, and it reads this off a refresh grant it runs itself.
30
+ */
31
+ accessToken?: string;
32
+ /** Epoch ms `accessToken` expires at. Present exactly when `accessToken` is and the issuer sent `expires_in`. */
33
+ accessTokenExpiresAt?: number;
18
34
  }
19
35
  export declare class Oidc {
20
36
  private readonly options;
package/dist/oidc.js CHANGED
@@ -9,16 +9,35 @@ export const ORG_CLAIM = 'urn:zitadel:iam:user:resourceowner:id';
9
9
  * or a request more than a minute behind, still gets the real refusal.
10
10
  */
11
11
  export const ROTATION_CACHE_MS = 60_000;
12
- /** The scopes every flow asks for: the code-exchange authorization request and the QR device grant alike. */
13
- export function scopeFor(organizationId) {
14
- return [
12
+ /**
13
+ * The scopes every flow asks for: the code-exchange authorization request
14
+ * and the QR device grant alike. `extraScopes` -- `AuthOptions.extraScopes`,
15
+ * passed straight through -- is appended after the fixed set: each entry is
16
+ * split on whitespace, empty entries dropped, and anything already present
17
+ * (in the fixed set or an earlier extra entry) skipped. With none, the
18
+ * result is exactly the fixed-set string, byte for byte.
19
+ */
20
+ export function scopeFor(organizationId, extraScopes) {
21
+ const scopes = [
15
22
  'openid',
16
23
  'email',
17
24
  'profile',
18
25
  'offline_access',
19
26
  `urn:zitadel:iam:org:id:${organizationId}`,
20
27
  'urn:zitadel:iam:user:resourceowner',
21
- ].join(' ');
28
+ ];
29
+ if (extraScopes?.length) {
30
+ const seen = new Set(scopes);
31
+ for (const entry of extraScopes) {
32
+ for (const scope of entry.split(/\s+/)) {
33
+ if (scope && !seen.has(scope)) {
34
+ seen.add(scope);
35
+ scopes.push(scope);
36
+ }
37
+ }
38
+ }
39
+ }
40
+ return scopes.join(' ');
22
41
  }
23
42
  export class Oidc {
24
43
  options;
@@ -67,7 +86,7 @@ export class Oidc {
67
86
  ...(create ? { prompt: 'create' } : prompt ? { prompt } : {}),
68
87
  ...(maxAge === undefined ? {} : { max_age: String(maxAge) }),
69
88
  redirect_uri: this.redirectUri.href,
70
- scope: scopeFor(this.options.organizationId),
89
+ scope: scopeFor(this.options.organizationId, this.options.extraScopes),
71
90
  state,
72
91
  nonce,
73
92
  code_challenge: await client.calculatePKCECodeChallenge(codeVerifier),
@@ -129,10 +148,13 @@ export class Oidc {
129
148
  throw new AuthError('exchange', 'the issuer returned no id token');
130
149
  }
131
150
  this.assertOrganization(claims);
151
+ const expiresIn = response.expiresIn();
132
152
  return {
133
153
  idToken: response.id_token,
134
154
  refreshToken: response.refresh_token ?? previousRefreshToken,
135
155
  claims,
156
+ accessToken: response.access_token,
157
+ accessTokenExpiresAt: expiresIn === undefined ? undefined : Date.now() + expiresIn * 1000,
136
158
  };
137
159
  }
138
160
  async verify(idToken) {
@@ -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.11.1",
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,