@wtfalch/auth 0.11.0 → 0.12.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
+ ### Hub 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 a hub 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 hub'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
@@ -163,6 +163,33 @@ 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
+ };
166
193
  export interface Auth {
167
194
  readonly sessionCookieName: string;
168
195
  /** GET {basePath}/start?next=&intent= : begins the OIDC flow; the issuer sends the browser to {basePath}/login?authRequest= */
@@ -387,6 +414,36 @@ export interface Auth {
387
414
  * failed callback rather than throwing.
388
415
  */
389
416
  pollQrSignIn(request: Request): Promise<QrPollResult>;
417
+ /**
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`.
425
+ *
426
+ * 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.
432
+ *
433
+ * `ok: false` is a definitive refusal -- no session, no refresh token in
434
+ * it, or the issuer refusing the grant (`isOAuthRefusal`, or the same
435
+ * organisation/id-token check `readCookie` applies). A transient issuer
436
+ * outage (network error, 5xx, 429) throws instead of answering
437
+ * `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.
445
+ */
446
+ accessToken(cookieValue: string): Promise<AccessTokenResult>;
390
447
  }
391
448
  export declare function createAuth(input: AuthOptions): Auth;
392
449
  export type { AuthRequest, NewUser, SetCookie };
package/dist/auth.js CHANGED
@@ -175,11 +175,21 @@ export function createAuth(input) {
175
175
  /**
176
176
  * Where a sign-out lands: the issuer's own logout, so its session ends and
177
177
  * the next sign-in asks who again, rather than straight back to
178
- * `afterLogout`. What this function can see fail -- no id token to hint
179
- * with, no `end_session_endpoint` published, the issuer not answering --
180
- * falls back to the plain redirect a sign-out has always ended with.
181
- * Losing the issuer's co-operation is never a reason to fail somebody's
182
- * sign-out.
178
+ * `afterLogout`. What this function can see fail -- no `end_session_endpoint`
179
+ * published, the issuer not answering -- falls back to the plain redirect a
180
+ * sign-out has always ended with. Losing the issuer's co-operation is never
181
+ * a reason to fail somebody's sign-out.
182
+ *
183
+ * Having no id token is the one failure that does not fall back, because the
184
+ * plain redirect leaves the issuer's session standing and the next sign-in is
185
+ * silent. `end_session` cannot rescue it either: without a hint the issuer
186
+ * takes its v1 branch and ends v1 user-agent sessions, of which a v2 sign-in
187
+ * has none. Routing a hint-less `end_session` to the hosted login instead
188
+ * needs the instance's `loginV2.required`, which overrides every app's own
189
+ * `loginVersion.loginV2.baseUri` with the instance's -- and each app here
190
+ * hosts its own sign-in at `/auth`, so that flag is not available to us.
191
+ * The hosted login's `/logout` sits at the issuer's origin, where
192
+ * `scripts/provision.mjs` points the instance's `loginV2.baseUri`.
183
193
  *
184
194
  * `afterLogout` not being a registered `post_logout_redirect_uri` is a
185
195
  * failure this function cannot see: that request reaches the issuer, which
@@ -191,11 +201,12 @@ export function createAuth(input) {
191
201
  */
192
202
  const endIssuerSession = async (idToken, cookies) => {
193
203
  const afterLogout = new URL(options().afterLogout, options().appUrl).href;
194
- const target = idToken
195
- ? await oidc()
196
- .endSessionUrl(idToken, afterLogout)
197
- .catch(() => null)
198
- : null;
204
+ if (!idToken) {
205
+ return redirect(new URL('/logout', options().issuer).href, cookies, 303);
206
+ }
207
+ const target = await oidc()
208
+ .endSessionUrl(idToken, afterLogout)
209
+ .catch(() => null);
199
210
  return redirect(target?.href ?? afterLogout, cookies, 303);
200
211
  };
201
212
  const logout = async (request) => {
@@ -644,7 +655,7 @@ export function createAuth(input) {
644
655
  const startQrSignIn = async () => {
645
656
  if (options().namespace && !(await sessions.bindingActive()))
646
657
  throw new AuthError('unavailable', 'the namespace binding is not active');
647
- const device = await requestDeviceAuthorization(options(), scopeFor(options().organizationId));
658
+ const device = await requestDeviceAuthorization(options(), scopeFor(options().organizationId, options().extraScopes));
648
659
  const cookie = await sealQrTransaction(options(), { dc: device.deviceCode, iv: device.interval }, device.expiresIn);
649
660
  return {
650
661
  url: qrVerificationUrl(options(), device.verificationUriComplete),
@@ -695,6 +706,42 @@ export function createAuth(input) {
695
706
  await replacePreviousSession(request.headers.get('cookie'));
696
707
  return { status: 'signed_in', cookies: [cleared, session] };
697
708
  };
709
+ 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
+ }
713
+ const session = await openSession(options(), cookieValue);
714
+ if (!session)
715
+ return { ok: false, reason: 'no_session' };
716
+ if (!session.rt)
717
+ return { ok: false, reason: 'no_refresh_token' };
718
+ let tokens;
719
+ try {
720
+ tokens = await oidc().refresh(session.rt);
721
+ }
722
+ catch (error) {
723
+ if (error instanceof AuthError || isOAuthRefusal(error))
724
+ return { ok: false, reason: 'refused' };
725
+ throw error;
726
+ }
727
+ if (!tokens.accessToken || tokens.accessTokenExpiresAt === undefined) {
728
+ // The issuer answered the grant but sent no access token -- treat it
729
+ // the same as a refusal rather than handing back an unusable result.
730
+ return { ok: false, reason: 'refused' };
731
+ }
732
+ // Always re-sealed, even when the refresh token itself did not rotate:
733
+ // the grant always returns a fresh id token, and readCookie's own
734
+ // refresh path re-seals on every successful refresh for the same reason.
735
+ const cookies = [
736
+ await sealSession(options(), { idt: tokens.idToken, rt: tokens.refreshToken }),
737
+ ];
738
+ return {
739
+ ok: true,
740
+ accessToken: tokens.accessToken,
741
+ expiresAt: tokens.accessTokenExpiresAt,
742
+ cookies,
743
+ };
744
+ };
698
745
  const failure = (reason, cookies) => {
699
746
  const url = new URL(options().onError, options().appUrl);
700
747
  url.searchParams.set('auth_error', reason);
@@ -748,6 +795,7 @@ export function createAuth(input) {
748
795
  finishPasskeySignIn,
749
796
  startQrSignIn,
750
797
  pollQrSignIn,
798
+ accessToken,
751
799
  };
752
800
  }
753
801
  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 hub.
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
@@ -14,6 +14,15 @@ export interface AuthOptions {
14
14
  clientId: string | undefined;
15
15
  /** From scripts/provisioned.json. Sent as a scope and checked on every token. */
16
16
  organizationId: string | undefined;
17
+ /**
18
+ * Extra scopes appended to `scopeFor`'s fixed set, on both the
19
+ * authorization request `start` builds and the QR device grant --
20
+ * a project's `:aud` scope, say. Each entry is split on whitespace,
21
+ * empty entries dropped, and duplicates (against the fixed scopes and
22
+ * each other) removed. Default: none, and the scope string this SDK has
23
+ * always sent is unchanged, byte for byte.
24
+ */
25
+ extraScopes?: string[];
17
26
  /**
18
27
  * 32 bytes, base64 or hex: `openssl rand -base64 32`. One per app.
19
28
  *
@@ -57,6 +66,7 @@ export interface ResolvedOptions {
57
66
  appUrl: URL;
58
67
  clientId: string;
59
68
  organizationId: string;
69
+ extraScopes: string[];
60
70
  /**
61
71
  * Decoded on first access, not at resolve time.
62
72
  *
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;
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
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';
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
2
  export { type AuthOptions, ISSUER } from './config.js';
3
3
  export type { SetCookie } from './cookies.js';
4
4
  export type { SessionRecord, SessionStore, SessionUpdate } from './sessions.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');
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) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wtfalch/auth",
3
- "version": "0.11.0",
3
+ "version": "0.12.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",