@wtfalch/auth 0.5.0 → 0.6.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/auth.d.ts CHANGED
@@ -3,6 +3,9 @@ import { type AuthRequest, type NewUser, type PeoplePage } from './broker.js';
3
3
  import { type AuthOptions } from './config.js';
4
4
  import { type SetCookie } from './cookies.js';
5
5
  export interface User {
6
+ /** Present only for validated namespace sessions. Membership is app-owned. */
7
+ namespaceId?: string;
8
+ bindingId?: string;
6
9
  id: string;
7
10
  email: string | null;
8
11
  emailVerified: boolean;
@@ -14,6 +17,10 @@ export interface User {
14
17
  claims: JWTPayload;
15
18
  }
16
19
  export interface ReadResult {
20
+ /** A cookie exists for another binding/revision or cannot be opened. Do not auto-switch. */
21
+ accountChanged?: true;
22
+ /** Namespace mode: the binding is suspended or its status cannot be confirmed. Do not start a sign-in. */
23
+ unavailable?: true;
17
24
  user: User | null;
18
25
  /** What the response must set. Empty unless the session was refreshed or ended. */
19
26
  cookies: SetCookie[];
@@ -35,6 +42,7 @@ export type Gate = {
35
42
  | {
36
43
  kind: 'deny';
37
44
  cookies: SetCookie[];
45
+ reason?: 'account_changed' | 'unavailable';
38
46
  };
39
47
  export type Intent = 'login' | 'register';
40
48
  export type SignInError = 'invalid_credentials' | 'too_many' | 'request' | 'unavailable';
@@ -135,7 +143,10 @@ export interface Auth {
135
143
  invited: boolean;
136
144
  }>;
137
145
  handle(request: Request): Promise<Response>;
138
- /** `refresh: false` for callers that cannot set cookies; they get a user only while the token is valid. */
146
+ /**
147
+ * `refresh: false` for callers that cannot set cookies; they get a user only while the token is valid.
148
+ * Namespace sessions refresh on the server and never set a cookie, so they ignore it.
149
+ */
139
150
  read(request: Request, opts?: {
140
151
  refresh?: boolean;
141
152
  }): Promise<ReadResult>;
@@ -171,6 +182,13 @@ export interface Auth {
171
182
  userId: string;
172
183
  state: string;
173
184
  }>;
185
+ /**
186
+ * Namespace mode: end every session this person has in this service, on
187
+ * every replica, at their next request. Deactivating someone does this too.
188
+ * Their sign-in at the issuer is untouched, so an active person can sign
189
+ * straight back in.
190
+ */
191
+ revokeSessions(userId: string): Promise<number>;
174
192
  /** The auth request the issuer sent the browser here with. Refuses one for another application. */
175
193
  authRequest(id: string): Promise<AuthRequest>;
176
194
  signIn(input: {
package/dist/auth.js CHANGED
@@ -3,15 +3,48 @@ import * as client from 'openid-client';
3
3
  import { Broker, BrokerError } from './broker.js';
4
4
  import { resolveOptions } from './config.js';
5
5
  import { clearSession, clearTransaction, cookieFrom, openSession, openTransaction, sealSession, sealTransaction, sessionCookieName, transactionCookieName, } from './cookies.js';
6
+ import { namespaceSessions } from './namespace-session.js';
6
7
  import { AuthError, ORG_CLAIM, Oidc } from './oidc.js';
7
8
  import { safeNextPath } from './redirect.js';
8
9
  export function createAuth(input) {
9
10
  // Resolved on first use so `next build`, which imports every route module, needs none of the values set.
10
11
  let resolved = null;
11
12
  const options = () => {
12
- resolved ??= resolveOptions(input);
13
+ if (!resolved) {
14
+ const candidate = resolveOptions(input);
15
+ if (candidate.namespace && (!candidate.sessionStore || !candidate.appKey))
16
+ throw new Error('@wtfalch/auth: namespace mode needs a sessionStore and an appKey');
17
+ resolved = candidate;
18
+ }
13
19
  return resolved;
14
20
  };
21
+ const boundUser = (claims) => {
22
+ const namespace = options().namespace;
23
+ return {
24
+ ...userFrom(claims),
25
+ ...(namespace
26
+ ? {
27
+ namespaceId: namespace.namespaceId,
28
+ bindingId: namespace.id,
29
+ }
30
+ : {}),
31
+ };
32
+ };
33
+ const validCallback = (raw) => {
34
+ if (!options().namespace)
35
+ return true;
36
+ try {
37
+ const url = new URL(raw);
38
+ return (!url.username &&
39
+ !url.password &&
40
+ !url.hash &&
41
+ `${url.origin}${url.pathname}` ===
42
+ new URL(`${options().basePath}/callback`, options().appUrl).href);
43
+ }
44
+ catch {
45
+ return false;
46
+ }
47
+ };
15
48
  let oidcInstance = null;
16
49
  const oidc = () => {
17
50
  oidcInstance ??= new Oidc(options());
@@ -22,6 +55,11 @@ export function createAuth(input) {
22
55
  brokerInstance ??= new Broker(options());
23
56
  return brokerInstance;
24
57
  };
58
+ const sessions = namespaceSessions({ options, oidc, broker });
59
+ const sessionCookie = (tokens) => options().namespace
60
+ ? sessions.create(tokens)
61
+ : sealSession(options(), { idt: tokens.idToken, rt: tokens.refreshToken });
62
+ const unavailable = () => new Response('Sign-in is unavailable for this workspace', { status: 503 });
25
63
  const startUrl = (next, intent = 'login') => {
26
64
  const url = new URL(`${options().basePath}/start`, options().appUrl);
27
65
  const path = safeNextPath(next, '');
@@ -34,6 +72,8 @@ export function createAuth(input) {
34
72
  const start = async (request) => {
35
73
  if (!isNavigation(request))
36
74
  return new Response('Unauthorized', { status: 401 });
75
+ if (options().namespace && !(await sessions.bindingActive()))
76
+ return unavailable();
37
77
  const params = new URL(request.url).searchParams;
38
78
  const next = safeNextPath(params.get('next'), options().afterLogin);
39
79
  const state = client.randomState();
@@ -51,13 +91,17 @@ export function createAuth(input) {
51
91
  return redirect(target.href, [transaction]);
52
92
  };
53
93
  const complete = async (callbackUrl, cookieHeader) => {
94
+ if (!validCallback(callbackUrl))
95
+ return failure('request', []);
54
96
  const cleared = clearTransaction(options());
55
97
  const transaction = await openTransaction(options(), cookieFrom(cookieHeader, transactionCookieName(options())));
56
98
  if (!transaction)
57
- return failure('expired', [cleared]);
99
+ return failure('expired', options().namespace ? [] : [cleared]);
58
100
  const params = new URL(callbackUrl).searchParams;
59
101
  if (params.get('state') !== transaction.st)
60
- return failure('state', [cleared]);
102
+ return failure('state', options().namespace ? [] : [cleared]);
103
+ if (options().namespace && !(await sessions.bindingActive()))
104
+ return failure('unavailable', [cleared]);
61
105
  let session;
62
106
  try {
63
107
  const tokens = await oidc().exchange(params, {
@@ -65,11 +109,16 @@ export function createAuth(input) {
65
109
  nonce: transaction.nc,
66
110
  codeVerifier: transaction.cv,
67
111
  });
68
- session = await sealSession(options(), { idt: tokens.idToken, rt: tokens.refreshToken });
112
+ session = await sessionCookie(tokens);
69
113
  }
70
114
  catch (error) {
71
115
  return failure(reasonOf(error), [cleared]);
72
116
  }
117
+ // Signing in again replaces this binding's previous session outright.
118
+ if (options().namespace)
119
+ await sessions
120
+ .end(cookieFrom(cookieHeader, sessionCookieName(options())))
121
+ .catch((error) => console.error('@wtfalch/auth: could not end the replaced session', error));
73
122
  return {
74
123
  location: new URL(transaction.nx, options().appUrl).href,
75
124
  cookies: [cleared, session],
@@ -83,7 +132,14 @@ export function createAuth(input) {
83
132
  if (!isSameOrigin(request, options().appUrl.origin)) {
84
133
  return new Response('Forbidden', { status: 403 });
85
134
  }
86
- const session = await openSession(options(), cookieFrom(request.headers.get('cookie'), sessionCookieName(options())));
135
+ const value = cookieFrom(request.headers.get('cookie'), sessionCookieName(options()));
136
+ if (options().namespace) {
137
+ // Revoked in the store first: that is what ends copies of the cookie too.
138
+ if ((await sessions.end(value)) === 'changed')
139
+ return new Response('Account changed', { status: 409 });
140
+ return redirect(new URL(options().afterLogout, options().appUrl).href, [clearSession(options())], 303);
141
+ }
142
+ const session = await openSession(options(), value);
87
143
  if (session?.rt)
88
144
  await broker()
89
145
  .revoke(session.rt)
@@ -106,7 +162,13 @@ export function createAuth(input) {
106
162
  // The id token in the cookie still says unverified; the claim is what the
107
163
  // app reads, so it is refreshed here rather than at the next expiry.
108
164
  const cookies = [];
109
- const session = await openSession(options(), cookieFrom(request.headers.get('cookie'), sessionCookieName(options())));
165
+ const value = cookieFrom(request.headers.get('cookie'), sessionCookieName(options()));
166
+ if (options().namespace) {
167
+ await sessions.read(value, true).catch(() => { });
168
+ url.searchParams.set('verified', '1');
169
+ return redirect(url.href, cookies);
170
+ }
171
+ const session = await openSession(options(), value);
110
172
  if (session?.rt) {
111
173
  try {
112
174
  const tokens = await oidc().refresh(session.rt);
@@ -135,6 +197,16 @@ export function createAuth(input) {
135
197
  return new Response('Not found', { status: 404 });
136
198
  };
137
199
  const readCookie = async (value, { refresh = true } = {}) => {
200
+ if (options().namespace) {
201
+ const result = await sessions.read(value);
202
+ if (result.kind === 'user')
203
+ return { user: boundUser(result.claims), cookies: [] };
204
+ if (result.kind === 'changed')
205
+ return { user: null, cookies: [], accountChanged: true };
206
+ if (result.kind === 'unavailable')
207
+ return { user: null, cookies: [], unavailable: true };
208
+ return { user: null, cookies: [] };
209
+ }
138
210
  const session = await openSession(options(), value);
139
211
  if (!session)
140
212
  return { user: null, cookies: [] };
@@ -149,7 +221,7 @@ export function createAuth(input) {
149
221
  idt: tokens.idToken,
150
222
  rt: tokens.refreshToken,
151
223
  });
152
- return { user: userFrom(tokens.claims), cookies: [cookie] };
224
+ return { user: boundUser(tokens.claims), cookies: [cookie] };
153
225
  }
154
226
  catch (error) {
155
227
  // A failed refresh never clears the cookie: a valid token carries on, and a dead one is replaced by the next sign-in.
@@ -161,7 +233,7 @@ export function createAuth(input) {
161
233
  }
162
234
  try {
163
235
  const claims = await oidc().verify(session.idt);
164
- return { user: userFrom(claims), cookies: [] };
236
+ return { user: boundUser(claims), cookies: [] };
165
237
  }
166
238
  catch (error) {
167
239
  return { user: null, cookies: refresh && rejected(error) ? [clearSession(options())] : [] };
@@ -173,27 +245,47 @@ export function createAuth(input) {
173
245
  const isPublic = url.pathname === options().basePath ||
174
246
  url.pathname.startsWith(`${options().basePath}/`) ||
175
247
  matches(rules.public, url.pathname);
176
- const { user, cookies } = await read(request);
248
+ const { user, cookies, accountChanged, unavailable } = await read(request);
177
249
  if (user || isPublic)
178
250
  return { kind: 'next', user, cookies };
251
+ if (accountChanged)
252
+ return { kind: 'deny', cookies, reason: 'account_changed' };
253
+ if (unavailable)
254
+ return { kind: 'deny', cookies, reason: 'unavailable' };
179
255
  if (!isNavigation(request))
180
256
  return { kind: 'deny', cookies };
181
257
  return { kind: 'redirect', location: startUrl(`${url.pathname}${url.search}`), cookies };
182
258
  };
183
259
  const people = (options) => broker().people(options);
184
- const setPersonActive = (userId, active) => broker().setPersonActive(userId, active);
260
+ const setPersonActive = async (userId, active) => {
261
+ const result = await broker().setPersonActive(userId, active);
262
+ if (!active && options().namespace)
263
+ await sessions.revokeSubject(userId);
264
+ return result;
265
+ };
266
+ const revokeSessions = (userId) => {
267
+ if (!options().namespace)
268
+ throw new Error('@wtfalch/auth: revokeSessions needs namespace sessions');
269
+ return sessions.revokeSubject(userId);
270
+ };
185
271
  const authRequest = (id) => broker().authRequest(id);
186
272
  /**
187
273
  * An OIDC flow this app starts and finishes itself, for a person arriving
188
274
  * from an email rather than from a browser the app sent to the issuer.
189
275
  */
190
276
  const serverFlow = async () => {
277
+ if (options().namespace && !(await sessions.bindingActive()))
278
+ throw new AuthError('unavailable', 'the namespace binding is not active');
191
279
  const state = client.randomState();
192
280
  const nonce = client.randomNonce();
193
281
  const verifier = client.randomPKCECodeVerifier();
194
282
  const target = await oidc().authorizationUrl(state, nonce, verifier);
195
283
  const response = await options().fetch(target.href, { redirect: 'manual' });
196
284
  const location = response.headers.get('location');
285
+ if (options().namespace &&
286
+ location &&
287
+ new URL(location, options().issuer).origin !== options().namespace?.loginOrigin)
288
+ throw new AuthError('request', 'issuer returned a different namespace login origin');
197
289
  const id = location && new URL(location, options().issuer).searchParams.get('authRequest');
198
290
  if (!id)
199
291
  throw new AuthError('request', 'the issuer did not start an auth request');
@@ -201,16 +293,18 @@ export function createAuth(input) {
201
293
  };
202
294
  /** Exchanges a callback the service issued, and seals the cookie. */
203
295
  const finish = async (callbackUrl, flow, next) => {
296
+ if (!validCallback(callbackUrl))
297
+ throw new AuthError('request', 'callback does not match namespace binding');
204
298
  const tokens = await oidc().exchange(new URL(callbackUrl).searchParams, {
205
299
  state: flow.state,
206
300
  nonce: flow.nonce,
207
301
  codeVerifier: flow.verifier,
208
302
  });
209
- const cookie = await sealSession(options(), { idt: tokens.idToken, rt: tokens.refreshToken });
303
+ const cookie = await sessionCookie(tokens);
210
304
  return { location: new URL(next, options().appUrl).href, cookies: [cookie] };
211
305
  };
212
306
  const hostedUrl = (id) => {
213
- const url = new URL('/login', options().issuer);
307
+ const url = new URL('/login', options().namespace?.loginOrigin ?? options().issuer);
214
308
  url.searchParams.set('authRequest', id);
215
309
  return url.href;
216
310
  };
@@ -358,6 +452,7 @@ export function createAuth(input) {
358
452
  errorUrl,
359
453
  people,
360
454
  setPersonActive,
455
+ revokeSessions,
361
456
  authRequest,
362
457
  signIn,
363
458
  signUp,
package/dist/broker.d.ts CHANGED
@@ -86,6 +86,15 @@ export declare class Broker {
86
86
  sendLink(email: string, next: string): Promise<unknown>;
87
87
  sendReset(email: string, next: string): Promise<unknown>;
88
88
  sendVerification(email: string, next: string): Promise<unknown>;
89
+ /**
90
+ * What the service routes this key to. A suspended binding's key is refused.
91
+ * Bounded, because every namespace request waits on this answer when it is due.
92
+ */
93
+ binding(): Promise<{
94
+ organizationId: string;
95
+ clientIds: string[];
96
+ origins: string[];
97
+ }>;
89
98
  verifyEmail(userId: string, code: string): Promise<unknown>;
90
99
  /** A public client revoking its own token: no key, no service. */
91
100
  revoke(refreshToken: string): Promise<void>;
package/dist/broker.js CHANGED
@@ -20,7 +20,7 @@ export class Broker {
20
20
  constructor(options) {
21
21
  this.options = options;
22
22
  }
23
- async call(method, path, body) {
23
+ async call(method, path, body, signal) {
24
24
  if (!this.options.appKey) {
25
25
  throw new Error('@wtfalch/auth: appKey is required to sign people in on the app');
26
26
  }
@@ -30,6 +30,7 @@ export class Broker {
30
30
  url.searchParams.set(k, String(v));
31
31
  const response = await this.options.fetch(url.href, {
32
32
  method,
33
+ signal,
33
34
  headers: {
34
35
  authorization: `Bearer ${this.options.appKey}`,
35
36
  'content-type': 'application/json',
@@ -95,6 +96,13 @@ export class Broker {
95
96
  sendVerification(email, next) {
96
97
  return this.call('POST', '/verification/send', { email, next });
97
98
  }
99
+ /**
100
+ * What the service routes this key to. A suspended binding's key is refused.
101
+ * Bounded, because every namespace request waits on this answer when it is due.
102
+ */
103
+ binding() {
104
+ return this.call('GET', '/binding', {}, AbortSignal.timeout(5_000));
105
+ }
98
106
  verifyEmail(userId, code) {
99
107
  return this.call('POST', '/verify', { userId, code });
100
108
  }
package/dist/browser.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { NamespaceSelection } from './namespaces.js';
1
2
  /**
2
3
  * Signing in from a browser app, and being *already* signed in when another
3
4
  * subdomain did it.
@@ -37,6 +38,8 @@
37
38
  * bounce somebody straight back in — a sign-out that undoes itself is not one.
38
39
  */
39
40
  export interface BrowserAuthOptions {
41
+ /** Public namespace registry projection; never include broker or cookie secrets. */
42
+ namespace?: NamespaceSelection;
40
43
  /** The issuer, e.g. `https://auth.wtfalch.dev`. */
41
44
  issuer: string;
42
45
  /** This app's client id at the issuer. */
@@ -56,6 +59,8 @@ export interface BrowserAuthOptions {
56
59
  scope?: string;
57
60
  }
58
61
  export interface BrowserSession {
62
+ namespaceId?: string;
63
+ bindingId?: string;
59
64
  accessToken: string;
60
65
  /** Epoch milliseconds. */
61
66
  expiresAt: number;
@@ -78,6 +83,8 @@ export type SignInOutcome = {
78
83
  error: string;
79
84
  };
80
85
  export interface BrowserAuth {
86
+ /** Another account/binding replaced this context; require a deliberate sign-in. */
87
+ accountChanged(): boolean;
81
88
  /** The session this tab holds, or null. Expiry is checked with a minute of
82
89
  * slack: a token that dies mid-request is worse than one renewed early. */
83
90
  currentSession(): BrowserSession | null;
package/dist/browser.js CHANGED
@@ -1,41 +1,4 @@
1
- /**
2
- * Signing in from a browser app, and being *already* signed in when another
3
- * subdomain did it.
4
- *
5
- * The rest of this package is for a server: it holds a session in a cookie and
6
- * the person never sees a token. A browser app cannot do that — whatever it
7
- * holds, the person holding the browser holds too — so this is a public client
8
- * using PKCE, the flow designed for exactly that.
9
- *
10
- * **Why a session cannot simply be shared across subdomains.** The server
11
- * side's cookie is `__Host-` prefixed, and that prefix is defined as `Secure`,
12
- * `Path=/` and *no* `Domain` attribute: host-only, by specification. It is
13
- * what stops one subdomain writing a session cookie the next one would trust.
14
- * Widening it to `.<apex>` to get single sign-on would trade that away, so the
15
- * shared thing is not a cookie: it is the **session at the issuer**. Every
16
- * origin gets its own token from it, and the person is asked for credentials
17
- * once.
18
- *
19
- * **`prompt=none` is what makes that invisible.** On load, an app with no token
20
- * asks the issuer to authorise without interacting. Either a code comes back —
21
- * a redirect out and in, a flash, and the app is signed in — or the issuer
22
- * answers `login_required`, and the app shows its own sign-in affordance
23
- * having lost nothing. `trySilentSignIn` is that attempt, `completeSignIn`
24
- * tells the two apart, and `SilentRefused` is the outcome to draw a button for
25
- * rather than an error.
26
- *
27
- * **A top-level redirect, not a hidden iframe.** The classic silent-auth trick
28
- * loads the issuer in an iframe, which needs the issuer's cookie in a
29
- * third-party context — blocked by default in Safari and increasingly
30
- * elsewhere. It fails *silently* and intermittently, which is the worst
31
- * failure mode available. A redirect always works and costs a flash.
32
- *
33
- * **Every silent attempt is made at most once per tab.** Without that, an
34
- * issuer answering `login_required` sends the app back to a page whose load
35
- * asks again, forever, and the loop is invisible: the address bar settles and
36
- * the tab spins. `forget()` sets the same mark, so pressing sign out does not
37
- * bounce somebody straight back in — a sign-out that undoes itself is not one.
38
- */
1
+ import { createNamespaceBrowserAuth } from './namespace-browser.js';
39
2
  const TOKEN = 'wtfalch.auth.token';
40
3
  const VERIFIER = 'wtfalch.auth.verifier';
41
4
  const RETURN = 'wtfalch.auth.return';
@@ -85,6 +48,8 @@ function verifier() {
85
48
  return base64url(crypto.getRandomValues(new Uint8Array(32)).buffer);
86
49
  }
87
50
  export function createBrowserAuth(options) {
51
+ if (options.namespace)
52
+ return createNamespaceBrowserAuth(options, options.namespace);
88
53
  const origin = options.origin ?? window.location.origin;
89
54
  const callbackPath = options.callbackPath ?? '/auth/callback';
90
55
  const scope = options.scope ?? 'openid email profile offline_access';
@@ -118,6 +83,7 @@ export function createBrowserAuth(options) {
118
83
  };
119
84
  const silentSignInAvailable = () => !currentSession() && !store.get(TRIED);
120
85
  return {
86
+ accountChanged: () => false,
121
87
  currentSession,
122
88
  silentSignInAvailable,
123
89
  async trySilentSignIn(returnTo = window.location.pathname) {
package/dist/config.d.ts CHANGED
@@ -1,4 +1,13 @@
1
+ import { type NamespaceSelection, resolveNamespaceContext } from './namespaces.js';
2
+ import type { SessionStore } from './sessions.js';
1
3
  export interface AuthOptions {
4
+ /** Opt-in namespace mode. Resolve this from trusted workspace/host configuration. */
5
+ namespace?: NamespaceSelection;
6
+ /**
7
+ * Where namespace sessions are kept. Required with `namespace`, together with
8
+ * `appKey`; unused without it. Every replica of the app must share one.
9
+ */
10
+ sessionStore?: SessionStore;
2
11
  /** This app's origin, e.g. https://portal.valet.wtfalch.dev. Every URL the SDK builds starts here, never from the request's Host. */
3
12
  appUrl: string | undefined;
4
13
  /** From scripts/provisioned.json. */
@@ -30,13 +39,15 @@ export interface AuthOptions {
30
39
  appKey?: string;
31
40
  /** The sign-in service. Defaults to the issuer's /api. */
32
41
  brokerUrl?: string;
33
- /** Cookie lifetime in seconds, sliding. Default 30 days, the issuer's idle expiry for a refresh token. */
42
+ /** Session lifetime in seconds, sliding. Default 30 days, the issuer's idle expiry for a refresh token. */
34
43
  sessionMaxAge?: number;
35
44
  /** Refresh when the id token has fewer seconds left than this. Default 300. */
36
45
  refreshWindow?: number;
37
46
  fetch?: typeof fetch;
38
47
  }
39
48
  export interface ResolvedOptions {
49
+ namespace: ReturnType<typeof resolveNamespaceContext> | null;
50
+ sessionStore: SessionStore | null;
40
51
  appUrl: URL;
41
52
  clientId: string;
42
53
  organizationId: string;
package/dist/config.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { resolveNamespaceContext } from './namespaces.js';
1
2
  export const ISSUER = 'https://auth.wtfalch.dev';
2
3
  export function resolveOptions(options) {
3
4
  const appUrl = parseAppUrl(options.appUrl);
@@ -19,9 +20,24 @@ export function resolveOptions(options) {
19
20
  */
20
21
  if (options.cookieSecret !== undefined)
21
22
  decodeKey(options.cookieSecret);
23
+ const issuer = (options.issuer ?? ISSUER).replace(/\/$/, '');
24
+ const namespace = options.namespace
25
+ ? resolveNamespaceContext(options.namespace, {
26
+ issuer,
27
+ clientId: options.clientId,
28
+ organizationId: options.organizationId,
29
+ appOrigin: appUrl.origin,
30
+ redirectUri: new URL(`${basePath}/callback`, appUrl).href,
31
+ })
32
+ : null;
33
+ if (namespace &&
34
+ !namespace.postLogoutRedirectUris.includes(new URL(options.afterLogout ?? '/', appUrl).href))
35
+ throw new Error('@wtfalch/auth: logout destination disagrees with binding');
22
36
  // Held here so the key is decoded at most once however often it is read.
23
37
  let cookieKey;
24
38
  return {
39
+ namespace,
40
+ sessionStore: options.sessionStore ?? null,
25
41
  appUrl,
26
42
  clientId: options.clientId,
27
43
  organizationId: options.organizationId,
package/dist/cookies.d.ts CHANGED
@@ -4,6 +4,12 @@ export interface SessionPayload extends JWTPayload {
4
4
  idt: string;
5
5
  rt?: string;
6
6
  }
7
+ /** The tokens inside a namespace session record, bound to that record's key. */
8
+ export interface RecordPayload extends JWTPayload {
9
+ k: string;
10
+ idt: string;
11
+ rt?: string;
12
+ }
7
13
  export interface TransactionPayload extends JWTPayload {
8
14
  st: string;
9
15
  nc: string;
@@ -33,6 +39,16 @@ export declare function transactionCookieName(options: ResolvedOptions): string;
33
39
  export declare function sealSession(options: ResolvedOptions, payload: Omit<SessionPayload, keyof JWTPayload>): Promise<SetCookie>;
34
40
  export declare function openSession(options: ResolvedOptions, value: string | undefined): Promise<SessionPayload | null>;
35
41
  export declare function clearSession(options: ResolvedOptions): SetCookie;
42
+ /** The namespace session cookie: a random handle to a server record, nothing else. */
43
+ export declare function sealHandle(options: ResolvedOptions, sid: string): Promise<SetCookie>;
44
+ export declare function openHandle(options: ResolvedOptions, value: string | undefined): Promise<string | null>;
45
+ export declare function sealRecord(options: ResolvedOptions, payload: Omit<RecordPayload, keyof JWTPayload>): Promise<string>;
46
+ /** The tokens, if this payload was sealed for this record under this binding. */
47
+ export declare function openRecord(options: ResolvedOptions, key: string, value: string): Promise<RecordPayload | null>;
48
+ /** The binding digest a namespace record is filed under. */
49
+ export declare function contextDigest(options: ResolvedOptions): Promise<string>;
50
+ /** SHA-256, base64url. */
51
+ export declare function digest(value: string): Promise<string>;
36
52
  export declare function sealTransaction(options: ResolvedOptions, payload: Omit<TransactionPayload, keyof JWTPayload>): Promise<SetCookie>;
37
53
  export declare function openTransaction(options: ResolvedOptions, value: string | undefined): Promise<TransactionPayload | null>;
38
54
  export declare function clearTransaction(options: ResolvedOptions): SetCookie;
package/dist/cookies.js CHANGED
@@ -4,6 +4,13 @@ const SESSION = 'wtfalch_auth';
4
4
  const TRANSACTION = 'wtfalch_auth_tx';
5
5
  const LINK = 'wtfalch_auth_link';
6
6
  const TRANSACTION_TTL = 10 * 60;
7
+ /**
8
+ * A namespace session handle lives as long as a browser will keep a cookie.
9
+ * It grants nothing by itself: the server record decides, and slides its own
10
+ * expiry. Refreshing never sets it again, which is what stops a late response
11
+ * from putting back a cookie the person has since replaced.
12
+ */
13
+ const HANDLE_TTL = 400 * 24 * 60 * 60;
7
14
  export function sessionCookieName(options) {
8
15
  return options.secure ? `__Host-${SESSION}` : SESSION;
9
16
  }
@@ -25,6 +32,37 @@ export async function openSession(options, value) {
25
32
  export function clearSession(options) {
26
33
  return setCookie(sessionCookieName(options), '', 0, options.secure);
27
34
  }
35
+ /** The namespace session cookie: a random handle to a server record, nothing else. */
36
+ export async function sealHandle(options, sid) {
37
+ const value = await seal(options, 'handle', { sid }, HANDLE_TTL);
38
+ return setCookie(sessionCookieName(options), value, HANDLE_TTL, options.secure);
39
+ }
40
+ export async function openHandle(options, value) {
41
+ const payload = await open(options, 'handle', value);
42
+ return payload && typeof payload.sid === 'string' ? payload.sid : null;
43
+ }
44
+ export function sealRecord(options, payload) {
45
+ return seal(options, 'record', payload, HANDLE_TTL);
46
+ }
47
+ /** The tokens, if this payload was sealed for this record under this binding. */
48
+ export async function openRecord(options, key, value) {
49
+ const payload = await open(options, 'record', value);
50
+ return payload && payload.k === key && typeof payload.idt === 'string' ? payload : null;
51
+ }
52
+ /** The binding digest a namespace record is filed under. */
53
+ export async function contextDigest(options) {
54
+ if (!options.namespace)
55
+ throw new Error('@wtfalch/auth: no namespace binding');
56
+ return digest(options.namespace.context);
57
+ }
58
+ /** SHA-256, base64url. */
59
+ export async function digest(value) {
60
+ const bytes = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(value));
61
+ return btoa(String.fromCharCode(...new Uint8Array(bytes)))
62
+ .replaceAll('+', '-')
63
+ .replaceAll('/', '_')
64
+ .replace(/=+$/, '');
65
+ }
28
66
  export async function sealTransaction(options, payload) {
29
67
  const value = await seal(options, 'transaction', payload, TRANSACTION_TTL);
30
68
  return setCookie(transactionCookieName(options), value, TRANSACTION_TTL, options.secure);
@@ -61,11 +99,21 @@ export async function openLink(options, value) {
61
99
  export function clearLink(options) {
62
100
  return setCookie(linkCookieName(options), '', 0, options.secure);
63
101
  }
102
+ // A different audience also prevents old static consumers from accepting a
103
+ // namespace cookie. Hashing keeps the encrypted session below browser limits.
104
+ async function audience(options, kind) {
105
+ if (!options.namespace)
106
+ return kind;
107
+ return `${kind}:namespace:1:${await digest(options.namespace.context)}`;
108
+ }
64
109
  async function seal(options, kind, payload, ttl) {
65
- return new EncryptJWT(payload)
110
+ return new EncryptJWT({
111
+ ...payload,
112
+ ...(options.namespace ? { ns: options.namespace.namespaceId } : {}),
113
+ })
66
114
  .setProtectedHeader({ alg: 'dir', enc: 'A256GCM' })
67
115
  .setIssuer(options.appUrl.origin)
68
- .setAudience(kind)
116
+ .setAudience(await audience(options, kind))
69
117
  .setIssuedAt()
70
118
  .setExpirationTime(Math.floor(Date.now() / 1000) + ttl)
71
119
  .encrypt(options.cookieKey);
@@ -76,7 +124,7 @@ async function open(options, kind, value) {
76
124
  try {
77
125
  const { payload } = await jwtDecrypt(value, options.cookieKey, {
78
126
  issuer: options.appUrl.origin,
79
- audience: kind,
127
+ audience: await audience(options, kind),
80
128
  contentEncryptionAlgorithms: ['A256GCM'],
81
129
  keyManagementAlgorithms: ['dir'],
82
130
  });
package/dist/index.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  export { type Auth, type AuthRequest, type Gate, type GateRules, type Intent, type NewUser, 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
+ export type { SessionRecord, SessionStore, SessionUpdate } from './sessions.js';
4
5
  export { BrokerError } from './broker.js';
5
6
  export { AuthError, type AuthErrorReason, ORG_CLAIM } from './oidc.js';
6
7
  export { safeNextPath } from './redirect.js';
@@ -0,0 +1,4 @@
1
+ import type { BrowserAuth, BrowserAuthOptions } from './browser.js';
2
+ import { type NamespaceSelection } from './namespaces.js';
3
+ /** Isolated implementation: legacy storage and callback behavior stay unchanged. */
4
+ export declare function createNamespaceBrowserAuth(options: BrowserAuthOptions, selection: NamespaceSelection): BrowserAuth;