@wtfalch/auth 0.16.0 → 0.20.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/dist/broker.d.ts CHANGED
@@ -15,6 +15,18 @@ export interface NewUser {
15
15
  * and words it will not have. Without it the email is the login name.
16
16
  */
17
17
  username?: string;
18
+ /**
19
+ * Creates the account with `password` as a one-time password: the person
20
+ * is signed in on nothing until they choose their own (`signUp` answers
21
+ * `password_change_required`, then `setNewPassword`).
22
+ */
23
+ changeRequired?: boolean;
24
+ /**
25
+ * Where this person's reset links go instead of their own login address --
26
+ * for somebody who cannot yet open the mailbox that address names. See
27
+ * `Auth['adminResetPassword']` for the person this does not cover.
28
+ */
29
+ personalEmail?: string;
18
30
  }
19
31
  /** One of the organisation's people, as the sign-in service reports them. */
20
32
  export interface Person {
@@ -35,7 +47,11 @@ export declare class BrokerError extends Error {
35
47
  readonly status: number;
36
48
  readonly error: string;
37
49
  readonly detail?: string | undefined;
38
- constructor(status: number, error: string, detail?: string | undefined);
50
+ /** Only `password_change_required` carries one: see `setNewPassword`. */
51
+ readonly changeToken?: string | undefined;
52
+ constructor(status: number, error: string, detail?: string | undefined,
53
+ /** Only `password_change_required` carries one: see `setNewPassword`. */
54
+ changeToken?: string | undefined);
39
55
  }
40
56
  /**
41
57
  * The privileged half, over HTTP. The app holds a key that reaches its own
@@ -54,12 +70,23 @@ export declare class Broker {
54
70
  userId: string;
55
71
  state: string;
56
72
  }>;
73
+ /** An admin's reset for somebody requestPasswordReset cannot get back in: see `Auth['adminResetPassword']`. */
74
+ adminResetPassword(userId: string): Promise<{
75
+ userId: string;
76
+ password: string;
77
+ }>;
57
78
  authRequest(id: string): Promise<AuthRequest>;
58
79
  signIn(input: {
59
80
  authRequestId: string;
60
81
  email: string;
61
82
  password: string;
62
83
  }): Promise<string>;
84
+ /** The call after `password_change_required`: no old password, only the token that sign-in answered with. */
85
+ setNewPassword(input: {
86
+ authRequestId: string;
87
+ changeToken: string;
88
+ newPassword: string;
89
+ }): Promise<string>;
63
90
  signUp(input: {
64
91
  authRequestId: string;
65
92
  } & NewUser): Promise<string>;
@@ -103,9 +130,11 @@ export declare class Broker {
103
130
  givenName: string;
104
131
  familyName: string;
105
132
  next: string;
133
+ personalEmail?: string;
106
134
  }): Promise<{
107
135
  userId: string;
108
136
  invited: boolean;
137
+ personalEmailStored: boolean;
109
138
  }>;
110
139
  sendLink(email: string, next: string): Promise<unknown>;
111
140
  sendReset(email: string, next: string): Promise<unknown>;
package/dist/broker.js CHANGED
@@ -2,11 +2,15 @@ export class BrokerError extends Error {
2
2
  status;
3
3
  error;
4
4
  detail;
5
- constructor(status, error, detail) {
5
+ changeToken;
6
+ constructor(status, error, detail,
7
+ /** Only `password_change_required` carries one: see `setNewPassword`. */
8
+ changeToken) {
6
9
  super(detail ?? error);
7
10
  this.status = status;
8
11
  this.error = error;
9
12
  this.detail = detail;
13
+ this.changeToken = changeToken;
10
14
  this.name = 'BrokerError';
11
15
  }
12
16
  }
@@ -48,7 +52,7 @@ export class Broker {
48
52
  throw new BrokerError(response.status, 'unavailable', `non-JSON response from ${path}`);
49
53
  }
50
54
  if (!response.ok) {
51
- throw new BrokerError(response.status, String(data.error ?? 'unavailable'), data.message);
55
+ throw new BrokerError(response.status, String(data.error ?? 'unavailable'), data.message, typeof data.changeToken === 'string' ? data.changeToken : undefined);
52
56
  }
53
57
  return data;
54
58
  }
@@ -63,6 +67,10 @@ export class Broker {
63
67
  setPersonActive(userId, active) {
64
68
  return this.call('POST', active ? '/people/reactivate' : '/people/deactivate', { userId });
65
69
  }
70
+ /** An admin's reset for somebody requestPasswordReset cannot get back in: see `Auth['adminResetPassword']`. */
71
+ adminResetPassword(userId) {
72
+ return this.call('POST', '/people/reset-password', { userId });
73
+ }
66
74
  authRequest(id) {
67
75
  return this.call('GET', '/auth-request', { id });
68
76
  }
@@ -70,6 +78,11 @@ export class Broker {
70
78
  const { callbackUrl } = await this.call('POST', '/sign-in', input);
71
79
  return callbackUrl;
72
80
  }
81
+ /** The call after `password_change_required`: no old password, only the token that sign-in answered with. */
82
+ async setNewPassword(input) {
83
+ const { callbackUrl } = await this.call('POST', '/password/set', input);
84
+ return callbackUrl;
85
+ }
73
86
  async signUp(input) {
74
87
  const { callbackUrl } = await this.call('POST', '/sign-up', {
75
88
  ...input,
package/dist/browser.d.ts CHANGED
@@ -57,7 +57,7 @@ export interface BrowserAuthStore {
57
57
  export interface BrowserAuthOptions {
58
58
  /** Public namespace registry projection; never include broker or cookie secrets. */
59
59
  namespace?: NamespaceSelection;
60
- /** The issuer, e.g. `https://auth.wtfalch.dev`. */
60
+ /** The issuer, e.g. `https://auth.example.com`. */
61
61
  issuer: string;
62
62
  /** This app's client id at the issuer. */
63
63
  clientId: string;
@@ -84,8 +84,10 @@ export interface BrowserAuthOptions {
84
84
  open?: BrowserOpener;
85
85
  /**
86
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.
87
+ * wraps `sessionStorage` with keys bound to issuer, client, origin, callback
88
+ * and scopes, and never throws. Custom stores retain fixed keys: supply
89
+ * separate backing storage per context, and reuse the same store object
90
+ * for instances that should coordinate writes and refreshes.
89
91
  */
90
92
  store?: BrowserAuthStore;
91
93
  }
@@ -123,10 +125,9 @@ export type RefreshOutcome = {
123
125
  kind: 'no-refresh-token';
124
126
  }
125
127
  /**
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.
128
+ * The issuer refused rotation, or this operation was cancelled. An issuer
129
+ * refusal drops the held session because the old refresh token was spent.
130
+ * Cancellation discards the old response and preserves any newer session.
130
131
  */
131
132
  | {
132
133
  kind: 'refused';
@@ -167,13 +168,14 @@ export interface BrowserAuth {
167
168
  * `currentSession()` never carries the refresh token itself — a value
168
169
  * passed around the app should not be able to mint new access tokens — so
169
170
  * 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
+ * Concurrent calls in the same store/context share one in-flight exchange, so callers racing
171
172
  * near expiry both get the freshly rotated session rather than one losing
172
173
  * to the other's now-spent refresh token.
173
174
  */
174
175
  refresh(): Promise<RefreshOutcome>;
175
176
  /** 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
+ * the store's writes and invalidates pending sign-in/refresh completions.
178
+ * After it resolves, an older write cannot restore the forgotten session. */
177
179
  forget(): Promise<void>;
178
180
  }
179
181
  export declare function createBrowserAuth(options: BrowserAuthOptions): BrowserAuth;
package/dist/browser.js CHANGED
@@ -51,6 +51,9 @@ function publicSession(session) {
51
51
  const { refreshToken, ...rest } = session;
52
52
  return rest;
53
53
  }
54
+ // Instances using the same store and context share cancellation and refreshes.
55
+ // The network is outside this queue: forget must not wait for a token response.
56
+ const coordinators = new WeakMap();
54
57
  export function createBrowserAuth(options) {
55
58
  if (options.namespace) {
56
59
  // The namespace path coordinates a shared session across tabs and
@@ -69,11 +72,67 @@ export function createBrowserAuth(options) {
69
72
  const scope = Array.from(new Set([...baseScope.split(/\s+/).filter(Boolean), ...extraScopes])).join(' ');
70
73
  const redirectUri = `${origin}${callbackPath}`;
71
74
  const store = options.store ?? defaultStore;
75
+ // Custom stores retain their established keys. Their owner supplies a separate
76
+ // store per context; default storage is bound to the complete client context.
77
+ const context = options.store
78
+ ? ''
79
+ : JSON.stringify([
80
+ options.issuer.replace(/\/$/, ''),
81
+ options.clientId,
82
+ new URL(origin).origin,
83
+ redirectUri,
84
+ scope.split(' ').sort(),
85
+ ]);
86
+ const key = (name) => (context ? `wtfalch.auth.browser.v1:${context}:${name}` : name);
87
+ const tokenKey = key(TOKEN);
88
+ const verifierKey = key(VERIFIER);
89
+ const returnKey = key(RETURN);
90
+ const triedKey = key(TRIED);
91
+ let contexts = coordinators.get(store);
92
+ if (!contexts) {
93
+ contexts = new Map();
94
+ coordinators.set(store, contexts);
95
+ }
96
+ let shared = contexts.get(context);
97
+ if (!shared) {
98
+ shared = { generation: 0, writes: Promise.resolve() };
99
+ contexts.set(context, shared);
100
+ }
101
+ const coordinator = shared;
102
+ const mutate = (operation) => {
103
+ const result = coordinator.writes.then(operation);
104
+ coordinator.writes = result.then(() => { }, () => { });
105
+ return result;
106
+ };
107
+ const current = (generation) => coordinator.generation === generation;
108
+ const cancelled = () => ({
109
+ kind: 'error',
110
+ error: 'This sign-in was cancelled; start again',
111
+ });
112
+ const cancelledRefresh = () => ({
113
+ kind: 'refused',
114
+ error: 'This refresh was cancelled',
115
+ });
72
116
  const open = options.open ?? ((url) => window.location.assign(url));
73
117
  const authorize = async (prompt, returnTo) => {
118
+ const generation = ++coordinator.generation;
74
119
  const proof = verifier();
75
- await store.set(VERIFIER, proof);
76
- await store.set(RETURN, returnTo);
120
+ await mutate(async () => {
121
+ if (!current(generation))
122
+ return;
123
+ if (prompt)
124
+ await store.set(triedKey, '1');
125
+ else
126
+ await store.drop(triedKey);
127
+ if (!current(generation))
128
+ return;
129
+ await store.set(verifierKey, proof);
130
+ if (!current(generation))
131
+ return;
132
+ await store.set(returnKey, returnTo);
133
+ });
134
+ if (!current(generation))
135
+ return false;
77
136
  const url = new URL(`${options.issuer}/oauth/v2/authorize`);
78
137
  url.searchParams.set('client_id', options.clientId);
79
138
  url.searchParams.set('redirect_uri', redirectUri);
@@ -83,10 +142,13 @@ export function createBrowserAuth(options) {
83
142
  url.searchParams.set('code_challenge_method', 'S256');
84
143
  if (prompt)
85
144
  url.searchParams.set('prompt', prompt);
145
+ if (!current(generation))
146
+ return false;
86
147
  await open(url.toString());
148
+ return true;
87
149
  };
88
150
  const readStored = () => {
89
- const raw = store.get(TOKEN);
151
+ const raw = store.get(tokenKey);
90
152
  if (!raw)
91
153
  return null;
92
154
  try {
@@ -102,13 +164,12 @@ export function createBrowserAuth(options) {
102
164
  return null;
103
165
  return session.expiresAt - 60_000 > Date.now() ? publicSession(session) : null;
104
166
  };
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 () => {
167
+ const silentSignInAvailable = () => !currentSession() && !store.get(triedKey);
168
+ const performRefresh = async (generation) => {
169
+ // Wait for earlier store operations, without occupying the write queue while fetching.
170
+ await coordinator.writes;
171
+ if (!current(generation))
172
+ return cancelledRefresh();
112
173
  const session = readStored();
113
174
  if (!session?.refreshToken)
114
175
  return { kind: 'no-refresh-token' };
@@ -121,29 +182,42 @@ export function createBrowserAuth(options) {
121
182
  refresh_token: session.refreshToken,
122
183
  }),
123
184
  });
185
+ if (!current(generation))
186
+ return cancelledRefresh();
124
187
  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
188
  const body = await response.text();
130
- return {
131
- kind: 'refused',
132
- error: `the issuer refused the refresh (${response.status}): ${body.slice(0, 200)}`,
133
- };
189
+ return mutate(async () => {
190
+ if (!current(generation))
191
+ return cancelledRefresh();
192
+ await store.drop(tokenKey);
193
+ if (!current(generation))
194
+ return cancelledRefresh();
195
+ return {
196
+ kind: 'refused',
197
+ error: `the issuer refused the refresh (${response.status}): ${body.slice(0, 200)}`,
198
+ };
199
+ });
134
200
  }
135
201
  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) };
202
+ return mutate(async () => {
203
+ if (!current(generation))
204
+ return cancelledRefresh();
205
+ if (!token.access_token || !token.refresh_token) {
206
+ await store.drop(tokenKey);
207
+ if (!current(generation))
208
+ return cancelledRefresh();
209
+ return { kind: 'refused', error: 'the issuer returned an incomplete refresh response' };
210
+ }
211
+ const next = {
212
+ accessToken: token.access_token,
213
+ expiresAt: Date.now() + (token.expires_in ?? 3600) * 1000,
214
+ refreshToken: token.refresh_token,
215
+ };
216
+ await store.set(tokenKey, JSON.stringify(next));
217
+ if (!current(generation))
218
+ return cancelledRefresh();
219
+ return { kind: 'refreshed', session: publicSession(next) };
220
+ });
147
221
  };
148
222
  return {
149
223
  accountChanged: () => false,
@@ -152,30 +226,32 @@ export function createBrowserAuth(options) {
152
226
  async trySilentSignIn(returnTo = window.location.pathname) {
153
227
  if (!silentSignInAvailable())
154
228
  return false;
155
- await store.set(TRIED, '1');
156
- await authorize('none', returnTo);
157
- return true;
229
+ return authorize('none', returnTo);
158
230
  },
159
231
  async signIn(returnTo = window.location.pathname) {
160
- // A deliberate sign-in clears the mark: whatever the issuer said before,
161
- // the person is asking now.
162
- await store.drop(TRIED);
163
232
  await authorize(undefined, returnTo);
164
233
  },
165
234
  async completeSignIn(search = window.location.search) {
235
+ const generation = coordinator.generation;
166
236
  const params = new URLSearchParams(search);
167
- const proof = store.get(VERIFIER);
168
- const returnTo = store.get(RETURN) ?? '/';
169
- // Removed whatever happens: a code that failed to exchange must not be
170
- // retried against the same proof, and a stale verifier is what makes the
171
- // *next* sign-in fail confusingly.
172
- await store.drop(VERIFIER);
173
- await store.drop(RETURN);
237
+ // Reading and consuming the proof is one queued operation, so recreated
238
+ // instances cannot consume the same transaction while its drop is pending.
239
+ const transaction = await mutate(async () => {
240
+ if (!current(generation))
241
+ return null;
242
+ const proof = store.get(verifierKey);
243
+ const returnTo = store.get(returnKey) ?? '/';
244
+ await store.drop(verifierKey);
245
+ if (!current(generation))
246
+ return null;
247
+ await store.drop(returnKey);
248
+ return { proof, returnTo };
249
+ });
250
+ if (!transaction || !current(generation))
251
+ return cancelled();
252
+ const { proof, returnTo } = transaction;
174
253
  const issuerError = params.get('error');
175
254
  if (issuerError) {
176
- // The three the spec gives for "I would have had to ask them
177
- // something". Any of them answers a silent attempt rather than
178
- // failing it.
179
255
  if (['login_required', 'interaction_required', 'consent_required'].includes(issuerError)) {
180
256
  return { kind: 'silent-refused' };
181
257
  }
@@ -184,12 +260,11 @@ export function createBrowserAuth(options) {
184
260
  const code = params.get('code');
185
261
  if (!code)
186
262
  return { kind: 'error', error: 'the issuer sent no code' };
187
- if (!proof) {
263
+ if (!proof)
188
264
  return {
189
265
  kind: 'error',
190
266
  error: 'this tab has no record of starting a sign-in — start again from the app',
191
267
  };
192
- }
193
268
  const response = await fetch(`${options.issuer}/oauth/v2/token`, {
194
269
  method: 'POST',
195
270
  headers: { 'content-type': 'application/x-www-form-urlencoded' },
@@ -201,42 +276,59 @@ export function createBrowserAuth(options) {
201
276
  code_verifier: proof,
202
277
  }),
203
278
  });
279
+ if (!current(generation))
280
+ return cancelled();
204
281
  if (!response.ok) {
205
282
  const body = await response.text();
283
+ if (!current(generation))
284
+ return cancelled();
206
285
  return {
207
286
  kind: 'error',
208
287
  error: `the issuer refused the code (${response.status}): ${body.slice(0, 200)}`,
209
288
  };
210
289
  }
211
290
  const token = (await response.json());
212
- if (!token.access_token)
213
- return { kind: 'error', error: 'the issuer returned no access token' };
214
- const session = {
215
- accessToken: token.access_token,
216
- expiresAt: Date.now() + (token.expires_in ?? 3600) * 1000,
217
- refreshToken: token.refresh_token,
218
- };
219
- await store.set(TOKEN, JSON.stringify(session));
220
- // Signed in: a later silent attempt in this tab is allowed again, which
221
- // matters when the token expires and the page reloads.
222
- await store.drop(TRIED);
223
- return { kind: 'signed-in', session: publicSession(session), returnTo };
291
+ return mutate(async () => {
292
+ if (!current(generation))
293
+ return cancelled();
294
+ if (!token.access_token)
295
+ return { kind: 'error', error: 'the issuer returned no access token' };
296
+ const session = {
297
+ accessToken: token.access_token,
298
+ expiresAt: Date.now() + (token.expires_in ?? 3600) * 1000,
299
+ refreshToken: token.refresh_token,
300
+ };
301
+ await store.set(tokenKey, JSON.stringify(session));
302
+ if (!current(generation))
303
+ return cancelled();
304
+ await store.drop(triedKey);
305
+ if (!current(generation))
306
+ return cancelled();
307
+ return { kind: 'signed-in', session: publicSession(session), returnTo };
308
+ });
224
309
  },
225
310
  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;
311
+ const generation = coordinator.generation;
312
+ if (coordinator.refreshing?.generation !== generation) {
313
+ const promise = performRefresh(generation).finally(() => {
314
+ if (coordinator.refreshing?.promise === promise)
315
+ coordinator.refreshing = undefined;
316
+ });
317
+ coordinator.refreshing = { generation, promise };
318
+ }
319
+ return coordinator.refreshing.promise;
234
320
  },
235
321
  async forget() {
236
- await store.drop(TOKEN);
237
- // The mark stays set: a sign-out that silently signs you back in is not
238
- // a sign-out.
239
- await store.set(TRIED, '1');
322
+ // Invalidate synchronously, before the first async store call. A write
323
+ // already underway finishes before this queued cleanup, so after forget
324
+ // resolves it cannot resurrect the old session or clear the tried mark.
325
+ ++coordinator.generation;
326
+ await mutate(async () => {
327
+ await store.drop(tokenKey);
328
+ await store.drop(verifierKey);
329
+ await store.drop(returnKey);
330
+ await store.set(triedKey, '1');
331
+ });
240
332
  },
241
333
  };
242
334
  }
package/dist/config.d.ts CHANGED
@@ -19,6 +19,11 @@ export type AuthEvent = {
19
19
  email: string;
20
20
  userId: string;
21
21
  invited: boolean;
22
+ }
23
+ /** Never the password: an audit record of who was reset, not a copy of the credential. */
24
+ | {
25
+ type: 'adminResetPassword';
26
+ userId: string;
22
27
  };
23
28
  /**
24
29
  * Why `accessToken` refused: no session (no cookie in cookie mode, no live
@@ -82,8 +87,8 @@ export interface AuthOptions {
82
87
  * the key.
83
88
  */
84
89
  cookieSecret?: string | undefined;
85
- /** Default https://auth.wtfalch.dev. */
86
- issuer?: string;
90
+ /** e.g. https://auth.example.com. Required -- there is no estate-wide default. */
91
+ issuer: string | undefined;
87
92
  /** Where the routes are mounted. Default /auth. */
88
93
  basePath?: string;
89
94
  /** Default /. */
@@ -152,5 +157,4 @@ export interface ResolvedOptions {
152
157
  fetch: typeof fetch;
153
158
  onAuthEvent: ((event: AuthEvent) => void) | null;
154
159
  }
155
- export declare const ISSUER = "https://auth.wtfalch.dev";
156
160
  export declare function resolveOptions(options: AuthOptions): ResolvedOptions;
package/dist/config.js CHANGED
@@ -1,9 +1,9 @@
1
1
  import { resolveNamespaceContext } from './namespaces.js';
2
- export const ISSUER = 'https://auth.wtfalch.dev';
3
2
  export function resolveOptions(options) {
4
3
  const appUrl = parseAppUrl(options.appUrl);
5
4
  required(options.clientId, 'clientId');
6
5
  required(options.organizationId, 'organizationId');
6
+ required(options.issuer, 'issuer');
7
7
  const basePath = options.basePath ?? '/auth';
8
8
  if (!basePath.startsWith('/') || basePath.endsWith('/')) {
9
9
  throw new Error(`@wtfalch/auth: basePath must start with "/" and not end with one, got "${basePath}"`);
@@ -20,7 +20,7 @@ export function resolveOptions(options) {
20
20
  */
21
21
  if (options.cookieSecret !== undefined)
22
22
  decodeKey(options.cookieSecret);
23
- const issuer = (options.issuer ?? ISSUER).replace(/\/$/, '');
23
+ const issuer = options.issuer.replace(/\/$/, '');
24
24
  const namespace = options.namespace
25
25
  ? resolveNamespaceContext(options.namespace, {
26
26
  issuer,
@@ -46,14 +46,14 @@ export function resolveOptions(options) {
46
46
  cookieKey ??= decodeKey(options.cookieSecret);
47
47
  return cookieKey;
48
48
  },
49
- issuer: (options.issuer ?? ISSUER).replace(/\/$/, ''),
49
+ issuer,
50
50
  basePath,
51
51
  afterLogin: options.afterLogin ?? '/',
52
52
  afterLogout: options.afterLogout ?? '/',
53
53
  onError: options.onError ?? '/',
54
54
  afterVerify: options.afterVerify ?? '/',
55
55
  appKey: options.appKey || null,
56
- brokerUrl: (options.brokerUrl ?? `${(options.issuer ?? ISSUER).replace(/\/$/, '')}/api`).replace(/\/$/, ''),
56
+ brokerUrl: (options.brokerUrl ?? `${issuer}/api`).replace(/\/$/, ''),
57
57
  sessionMaxAge: seconds(options.sessionMaxAge, 'sessionMaxAge', 30 * 24 * 60 * 60),
58
58
  refreshWindow: seconds(options.refreshWindow, 'refreshWindow', 5 * 60),
59
59
  freshness: seconds(options.freshness, 'freshness', 5 * 60),
package/dist/cookies.d.ts CHANGED
@@ -15,6 +15,8 @@ export interface TransactionPayload extends JWTPayload {
15
15
  nc: string;
16
16
  cv: string;
17
17
  nx: string;
18
+ /** The leg was a silent pass (`prompt=none`): an error coming back falls back to interactive sign-in. */
19
+ sl?: true;
18
20
  }
19
21
  export interface CookieAttributes {
20
22
  path: '/';
@@ -57,6 +59,11 @@ export declare function digest(value: string): Promise<string>;
57
59
  export declare function sealTransaction(options: ResolvedOptions, payload: Omit<TransactionPayload, keyof JWTPayload>): Promise<SetCookie>;
58
60
  export declare function openTransaction(options: ResolvedOptions, value: string | undefined): Promise<TransactionPayload | null>;
59
61
  export declare function clearTransaction(options: ResolvedOptions): SetCookie;
62
+ export declare function silentCookieName(options: ResolvedOptions): string;
63
+ /** `try`: the gate asked for one silent pass. `done`: it ran, so every later sign-in is interactive. */
64
+ export type SilentState = 'try' | 'done';
65
+ export declare function sealSilent(options: ResolvedOptions, state: SilentState): Promise<SetCookie>;
66
+ export declare function openSilent(options: ResolvedOptions, value: string | undefined): Promise<SilentState | null>;
60
67
  export declare function qrTransactionCookieName(options: ResolvedOptions): string;
61
68
  /**
62
69
  * `ttl` is the issuer's own `expires_in` for the device code, not a fixed
package/dist/cookies.js CHANGED
@@ -4,6 +4,9 @@ const SESSION = 'wtfalch_auth';
4
4
  const TRANSACTION = 'wtfalch_auth_tx';
5
5
  const LINK = 'wtfalch_auth_link';
6
6
  const QR = 'wtfalch_auth_qr';
7
+ const SILENT = 'wtfalch_auth_silent';
8
+ /** How long the gate remembers it has tried (or finished) a silent pass, so a failed one is never retried in a loop. */
9
+ const SILENT_TTL = 5 * 60;
7
10
  const TRANSACTION_TTL = 10 * 60;
8
11
  /**
9
12
  * A namespace session handle lives as long as a browser will keep a cookie.
@@ -81,6 +84,17 @@ export async function openTransaction(options, value) {
81
84
  export function clearTransaction(options) {
82
85
  return setCookie(transactionCookieName(options), '', 0, options.secure);
83
86
  }
87
+ export function silentCookieName(options) {
88
+ return options.secure ? `__Host-${SILENT}` : SILENT;
89
+ }
90
+ export async function sealSilent(options, state) {
91
+ const value = await seal(options, 'silent', { s: state }, SILENT_TTL);
92
+ return setCookie(silentCookieName(options), value, SILENT_TTL, options.secure);
93
+ }
94
+ export async function openSilent(options, value) {
95
+ const payload = await open(options, 'silent', value);
96
+ return payload?.s === 'try' || payload?.s === 'done' ? payload.s : null;
97
+ }
84
98
  export function qrTransactionCookieName(options) {
85
99
  return options.secure ? `__Host-${QR}` : QR;
86
100
  }
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- export { type Auth, type AuthRequest, type Gate, type GateRules, type Intent, type MaxAge, type NewUser, type PasskeyRegistrationResult, type PasskeyRegistrationStart, type PasskeySignInStart, type Prompt, type QrPollResult, type QrPollStatus, type QrSignInStart, type ReadResult, type ResetError, type SignInError, type SignInResult, type SignUpError, type SignUpResult, type SignedIn, type User, createAuth, } from './auth.js';
2
- export { type AccessTokenReason, type AccessTokenResult, type AuthEvent, type AuthOptions, ISSUER, } from './config.js';
1
+ export { type Auth, type AuthRequest, type Gate, type GateRules, type Intent, type MaxAge, type NewUser, type PasskeyRegistrationResult, type PasswordChangeRequired, type PasskeyRegistrationStart, type PasskeySignInStart, type Prompt, type QrPollResult, type QrPollStatus, type QrSignInStart, type ReadResult, type ResetError, type SetNewPasswordError, type SetNewPasswordResult, type SignInError, type SignInResult, type SignUpError, type SignUpResult, type SignedIn, type User, STEP_UP_MAX_AGE, createAuth, } from './auth.js';
2
+ export type { AccessTokenReason, AccessTokenResult, AuthEvent, AuthOptions } 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,4 @@
1
- export { createAuth, } from './auth.js';
2
- export { ISSUER, } from './config.js';
1
+ export { STEP_UP_MAX_AGE, createAuth, } from './auth.js';
3
2
  export { BrokerError } from './broker.js';
4
3
  export { AuthError, ORG_CLAIM } from './oidc.js';
5
4
  export { safeNextPath } from './redirect.js';
package/dist/names.d.ts CHANGED
@@ -20,8 +20,9 @@
20
20
  * read as an `i` and as an `l`, so `wa1id` is `walid` too.
21
21
  *
22
22
  * The letter rules -- what characters, how long -- are the caller's: this is a
23
- * login name in ZITADEL and one part of `name@tf.auth.wtfalch.dev`, so the
24
- * default is ASCII letters, digits, `_`, `.` and `-`, three to thirty of them.
23
+ * login name in ZITADEL and one part of an email-shaped login handle at the
24
+ * issuer (`name@tf.<issuer-host>`), so the default is ASCII letters, digits,
25
+ * `_`, `.` and `-`, three to thirty of them.
25
26
  */
26
27
  export type Refusal = 'too_short' | 'too_long' | 'characters' | 'reserved' | 'disallowed';
27
28
  export type Verdict = {
@@ -181,7 +181,9 @@ export const SYSTEM = [
181
181
  'webmaster',
182
182
  'listmaster',
183
183
  'abuse',
184
+ 'dmarc',
184
185
  'noc',
186
+ 'ops',
185
187
  'security',
186
188
  'usenet',
187
189
  'news',