@wtfalch/auth 0.6.0 → 0.8.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
@@ -139,11 +139,24 @@ app URL, and refreshes itself when the id token is close to expiring.
139
139
  `ORG_CLAIM` is enforced on every token: a token from another organisation is
140
140
  refused even when the issuer and the signature are good.
141
141
 
142
+ ## Passkeys and QR sign-in
143
+
144
+ Passkeys live on each app's own domain: the relying-party ID is the app's
145
+ origin, so one made on one app never signs in on another. `@wtfalch/auth/passkey`
146
+ is the browser half of the WebAuthn ceremony; `startPasskeyRegistration`,
147
+ `finishPasskeyRegistration`, `startPasskeySignIn` and `finishPasskeySignIn` are
148
+ the server half.
149
+
150
+ A phone that is already signed in can also approve a new device from a QR
151
+ code, the issuer's OAuth device grant opted into per namespace binding with
152
+ `qrSignIn: true`, through `startQrSignIn` and `pollQrSignIn`.
153
+
142
154
  ## Documentation
143
155
 
144
156
  `docs/adopting.md` in [wtfalch/auth](https://github.com/wtfalch/auth) covers a
145
- full adoption: the files to add, the email flows, invitations, refresh, and the
146
- checks to run against a real issuer.
157
+ full adoption: the files to add, the email flows, invitations, refresh,
158
+ passkeys, signing in a new device from a QR code, and the checks to run
159
+ against a real issuer.
147
160
 
148
161
  ## Licence
149
162
 
package/dist/auth.d.ts CHANGED
@@ -65,6 +65,26 @@ export type SignUpResult = {
65
65
  error: SignUpError;
66
66
  message: string;
67
67
  };
68
+ export interface PasskeyRegistrationStart {
69
+ passkeyId: string;
70
+ /** ZITADEL's `publicKeyCredentialCreationOptions`, opaque here -- `@wtfalch/auth/passkey`'s `createPasskey` knows its shape. */
71
+ options: unknown;
72
+ }
73
+ export type PasskeyRegistrationResult = {
74
+ ok: true;
75
+ } | {
76
+ ok: false;
77
+ error: 'invalid';
78
+ };
79
+ export type PasskeySignInStart = {
80
+ ok: true;
81
+ sessionId: string;
82
+ /** ZITADEL's `publicKeyCredentialRequestOptions`, opaque here -- `@wtfalch/auth/passkey`'s `getPasskey` knows its shape. */
83
+ options: unknown;
84
+ } | {
85
+ ok: false;
86
+ error: SignInError;
87
+ };
68
88
  export interface Completed {
69
89
  location: string;
70
90
  cookies: SetCookie[];
@@ -80,6 +100,27 @@ export type SignedIn = {
80
100
  error: ResetError;
81
101
  message: string;
82
102
  };
103
+ export interface QrSignInStart {
104
+ /** The verification URL, complete with the user code -- what the QR code encodes. */
105
+ url: string;
106
+ /** The same code, for a person to type in by hand instead of scanning. */
107
+ userCode: string;
108
+ expiresIn: number;
109
+ /** Seconds between polls; the issuer's own pace. */
110
+ interval: number;
111
+ cookies: SetCookie[];
112
+ }
113
+ export type QrPollStatus = 'pending' | 'slow_down' | 'expired' | 'denied'
114
+ /** Namespace mode: the binding stopped being active between approval and this poll. */
115
+ | 'unavailable'
116
+ /** The issuer's token response or the id token it carried was rejected -- `reason` is the same vocabulary `auth_error` uses. */
117
+ | 'failed' | 'signed_in';
118
+ export interface QrPollResult {
119
+ status: QrPollStatus;
120
+ /** Set only when `status` is `'failed'`. */
121
+ reason?: string;
122
+ cookies: SetCookie[];
123
+ }
83
124
  export interface Auth {
84
125
  readonly sessionCookieName: string;
85
126
  /** GET {basePath}/start?next=&intent= : begins the OIDC flow; the issuer sends the browser to {basePath}/login?authRequest= */
@@ -199,6 +240,48 @@ export interface Auth {
199
240
  signUp(input: {
200
241
  authRequestId: string;
201
242
  } & NewUser): Promise<SignUpResult>;
243
+ /**
244
+ * Starts registering a passkey on this app's own domain, for whoever is
245
+ * signed in on `request` -- never for a stranger. Throws `AuthError` when
246
+ * nobody is: `account_changed`/`unavailable` exactly as `read` reports
247
+ * them, `unauthenticated` otherwise.
248
+ */
249
+ startPasskeyRegistration(request: Request): Promise<PasskeyRegistrationStart>;
250
+ /** The second half of `startPasskeyRegistration`. Same signed-in requirement. */
251
+ finishPasskeyRegistration(request: Request, input: {
252
+ passkeyId: string;
253
+ credential: unknown;
254
+ name: string;
255
+ }): Promise<PasskeyRegistrationResult>;
256
+ /** The passkey half of `signIn`: a challenge to answer with `@wtfalch/auth/passkey`'s `getPasskey`. */
257
+ startPasskeySignIn(input: {
258
+ authRequestId: string;
259
+ email: string;
260
+ }): Promise<PasskeySignInStart>;
261
+ /** The second half of a passkey sign-in -- otherwise exactly `signIn`: same redirect and hosted-login handling, same error mapping. */
262
+ finishPasskeySignIn(input: {
263
+ authRequestId: string;
264
+ sessionId: string;
265
+ credential: unknown;
266
+ }): Promise<SignInResult>;
267
+ /**
268
+ * Starts a QR sign-in: the OAuth device authorization grant, on this app's
269
+ * own web client. `url` is what the QR code encodes (and `userCode` what a
270
+ * person types instead); a phone already signed in approves it, and
271
+ * `pollQrSignIn` is how this device finds out. In namespace mode, throws
272
+ * `AuthError('unavailable', ...)` when the binding is not active -- unlike
273
+ * `start`, which answers a 503 `Response` for the same case rather than
274
+ * throwing.
275
+ */
276
+ startQrSignIn(): Promise<QrSignInStart>;
277
+ /**
278
+ * Polls the transaction `startQrSignIn` began; `request` carries its
279
+ * cookie. Never throws for an outcome of the poll itself -- a suspended
280
+ * binding or a rejected token response answers `'unavailable'`/`'failed'`
281
+ * with the transaction cookie cleared, the same way `complete` answers a
282
+ * failed callback rather than throwing.
283
+ */
284
+ pollQrSignIn(request: Request): Promise<QrPollResult>;
202
285
  }
203
286
  export declare function createAuth(input: AuthOptions): Auth;
204
287
  export type { AuthRequest, NewUser, SetCookie };
package/dist/auth.js CHANGED
@@ -2,9 +2,10 @@ import { errors } from 'jose';
2
2
  import * as client from 'openid-client';
3
3
  import { Broker, BrokerError } from './broker.js';
4
4
  import { resolveOptions } from './config.js';
5
- import { clearSession, clearTransaction, cookieFrom, openSession, openTransaction, sealSession, sealTransaction, sessionCookieName, transactionCookieName, } from './cookies.js';
5
+ import { clearQrTransaction, clearSession, clearTransaction, cookieFrom, openQrTransaction, openSession, openTransaction, qrTransactionCookieName, sealQrTransaction, sealSession, sealTransaction, sessionCookieName, transactionCookieName, } from './cookies.js';
6
6
  import { namespaceSessions } from './namespace-session.js';
7
- import { AuthError, ORG_CLAIM, Oidc } from './oidc.js';
7
+ import { AuthError, ORG_CLAIM, Oidc, scopeFor } from './oidc.js';
8
+ import { pollDeviceToken, requestDeviceAuthorization } from './qr.js';
8
9
  import { safeNextPath } from './redirect.js';
9
10
  export function createAuth(input) {
10
11
  // Resolved on first use so `next build`, which imports every route module, needs none of the values set.
@@ -59,6 +60,19 @@ export function createAuth(input) {
59
60
  const sessionCookie = (tokens) => options().namespace
60
61
  ? sessions.create(tokens)
61
62
  : sealSession(options(), { idt: tokens.idToken, rt: tokens.refreshToken });
63
+ /**
64
+ * Signing in again replaces this binding's previous session outright: in
65
+ * namespace mode, ends whatever session record `cookieHeader` already
66
+ * named. Shared by `complete` and `pollQrSignIn`, the two places a fresh
67
+ * session is created for a browser that may already hold one.
68
+ */
69
+ const replacePreviousSession = async (cookieHeader) => {
70
+ if (!options().namespace)
71
+ return;
72
+ await sessions
73
+ .end(cookieFrom(cookieHeader, sessionCookieName(options())))
74
+ .catch((error) => console.error('@wtfalch/auth: could not end the replaced session', error));
75
+ };
62
76
  const unavailable = () => new Response('Sign-in is unavailable for this workspace', { status: 503 });
63
77
  const startUrl = (next, intent = 'login') => {
64
78
  const url = new URL(`${options().basePath}/start`, options().appUrl);
@@ -114,11 +128,7 @@ export function createAuth(input) {
114
128
  catch (error) {
115
129
  return failure(reasonOf(error), [cleared]);
116
130
  }
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));
131
+ await replacePreviousSession(cookieHeader);
122
132
  return {
123
133
  location: new URL(transaction.nx, options().appUrl).href,
124
134
  cookies: [cleared, session],
@@ -256,6 +266,22 @@ export function createAuth(input) {
256
266
  return { kind: 'deny', cookies };
257
267
  return { kind: 'redirect', location: startUrl(`${url.pathname}${url.search}`), cookies };
258
268
  };
269
+ /**
270
+ * The person signed in on `request`, for the passkey routes -- never a
271
+ * stranger. Adapts `requireUser`'s reasons for a caller that throws rather
272
+ * than redirects: `account_changed`/`unavailable` are read's own, and
273
+ * plain absence becomes `unauthenticated`, which read has no reason for.
274
+ */
275
+ const currentUser = async (request) => {
276
+ const { user, accountChanged, unavailable } = await read(request, { refresh: false });
277
+ if (accountChanged)
278
+ throw new AuthError('account_changed', 'Choose an account to continue');
279
+ if (unavailable)
280
+ throw new AuthError('unavailable', 'Sign-in is unavailable for this workspace');
281
+ if (!user)
282
+ throw new AuthError('unauthenticated', 'sign in to use a passkey');
283
+ return user;
284
+ };
259
285
  const people = (options) => broker().people(options);
260
286
  const setPersonActive = async (userId, active) => {
261
287
  const result = await broker().setPersonActive(userId, active);
@@ -417,6 +443,114 @@ export function createAuth(input) {
417
443
  return { ok: false, error: 'unavailable', message: 'the sign-in service did not answer' };
418
444
  }
419
445
  };
446
+ const startPasskeyRegistration = async (request) => {
447
+ const user = await currentUser(request);
448
+ return broker().startPasskeyRegistration(user.id);
449
+ };
450
+ const finishPasskeyRegistration = async (request, input) => {
451
+ const user = await currentUser(request);
452
+ // Built from named fields, never spread: `input` crosses a trust
453
+ // boundary (a Next.js Server Action does not enforce its TS parameter
454
+ // types on the wire), and a spread after `userId: user.id` would let a
455
+ // caller-supplied `userId` key overwrite the session's own identity.
456
+ const passkeyId = input?.passkeyId;
457
+ const name = typeof input?.name === 'string' ? input.name.trim() : '';
458
+ const credential = input?.credential;
459
+ if (typeof passkeyId !== 'string' ||
460
+ passkeyId.length === 0 ||
461
+ name.length === 0 ||
462
+ name.length > 64 ||
463
+ !credential ||
464
+ typeof credential !== 'object' ||
465
+ Array.isArray(credential)) {
466
+ return { ok: false, error: 'invalid' };
467
+ }
468
+ try {
469
+ await broker().finishPasskeyRegistration({ userId: user.id, passkeyId, credential, name });
470
+ return { ok: true };
471
+ }
472
+ catch (error) {
473
+ if (error instanceof BrokerError && error.status < 500)
474
+ return { ok: false, error: 'invalid' };
475
+ throw error;
476
+ }
477
+ };
478
+ const startPasskeySignIn = async ({ authRequestId, email, }) => {
479
+ try {
480
+ const { sessionId, options } = await broker().startPasskeySignIn({ authRequestId, email });
481
+ return { ok: true, sessionId, options };
482
+ }
483
+ catch (error) {
484
+ return { ok: false, error: signInError(error) };
485
+ }
486
+ };
487
+ const finishPasskeySignIn = async ({ authRequestId, sessionId, credential, }) => {
488
+ try {
489
+ return {
490
+ ok: true,
491
+ redirectTo: await broker().finishPasskeySignIn({ authRequestId, sessionId, credential }),
492
+ };
493
+ }
494
+ catch (error) {
495
+ if (needsHostedLogin(error))
496
+ return { ok: true, redirectTo: hostedUrl(authRequestId), hosted: true };
497
+ return { ok: false, error: signInError(error) };
498
+ }
499
+ };
500
+ const startQrSignIn = async () => {
501
+ if (options().namespace && !(await sessions.bindingActive()))
502
+ throw new AuthError('unavailable', 'the namespace binding is not active');
503
+ const device = await requestDeviceAuthorization(options(), scopeFor(options().organizationId));
504
+ const cookie = await sealQrTransaction(options(), { dc: device.deviceCode, iv: device.interval }, device.expiresIn);
505
+ return {
506
+ url: device.verificationUriComplete,
507
+ userCode: device.userCode,
508
+ expiresIn: device.expiresIn,
509
+ interval: device.interval,
510
+ cookies: [cookie],
511
+ };
512
+ };
513
+ const pollQrSignIn = async (request) => {
514
+ const cleared = clearQrTransaction(options());
515
+ const transaction = await openQrTransaction(options(), cookieFrom(request.headers.get('cookie'), qrTransactionCookieName(options())));
516
+ // Missing, tampered, another binding's, or its own `exp` (the issuer's
517
+ // `expires_in`, sealed in by `startQrSignIn`) already past: all the same
518
+ // refusal a stranger's cookie gets everywhere else in this file.
519
+ if (!transaction)
520
+ return { status: 'expired', cookies: [cleared] };
521
+ // An outcome of the poll itself -- a rejected token response, or an id
522
+ // token that fails validation (wrong organisation, bad signature) --
523
+ // answers rather than throws, exactly as `complete` answers a failed
524
+ // exchange with `failure(reasonOf(error), ...)` instead of throwing it.
525
+ let outcome;
526
+ try {
527
+ outcome = await pollDeviceToken(options(), oidc(), transaction.dc);
528
+ }
529
+ catch (error) {
530
+ return { status: 'failed', reason: reasonOf(error), cookies: [cleared] };
531
+ }
532
+ if (outcome.status === 'pending')
533
+ return { status: 'pending', cookies: [] };
534
+ if (outcome.status === 'slow_down')
535
+ return { status: 'slow_down', cookies: [] };
536
+ if (outcome.status === 'expired')
537
+ return { status: 'expired', cookies: [cleared] };
538
+ if (outcome.status === 'denied')
539
+ return { status: 'denied', cookies: [cleared] };
540
+ // The binding can go suspended between approval and this poll landing;
541
+ // that is this outcome, not a programmer error, so it answers too.
542
+ if (options().namespace && !(await sessions.bindingActive()))
543
+ return { status: 'unavailable', cookies: [cleared] };
544
+ let session;
545
+ try {
546
+ session = await sessionCookie(outcome.tokens);
547
+ }
548
+ catch (error) {
549
+ return { status: 'failed', reason: reasonOf(error), cookies: [cleared] };
550
+ }
551
+ await replacePreviousSession(request.headers.get('cookie'));
552
+ return { status: 'signed_in', cookies: [cleared, session] };
553
+ };
420
554
  const failure = (reason, cookies) => {
421
555
  const url = new URL(options().onError, options().appUrl);
422
556
  url.searchParams.set('auth_error', reason);
@@ -456,6 +590,12 @@ export function createAuth(input) {
456
590
  authRequest,
457
591
  signIn,
458
592
  signUp,
593
+ startPasskeyRegistration,
594
+ finishPasskeyRegistration,
595
+ startPasskeySignIn,
596
+ finishPasskeySignIn,
597
+ startQrSignIn,
598
+ pollQrSignIn,
459
599
  };
460
600
  }
461
601
  function redirect(location, cookies, status = 302) {
package/dist/broker.d.ts CHANGED
@@ -63,6 +63,30 @@ export declare class Broker {
63
63
  signUp(input: {
64
64
  authRequestId: string;
65
65
  } & NewUser): Promise<string>;
66
+ /** A passkey for somebody already signed in on the app's own domain. */
67
+ startPasskeyRegistration(userId: string): Promise<{
68
+ passkeyId: string;
69
+ options: unknown;
70
+ }>;
71
+ finishPasskeyRegistration(input: {
72
+ userId: string;
73
+ passkeyId: string;
74
+ credential: unknown;
75
+ name: string;
76
+ }): Promise<void>;
77
+ /** The passkey half of `signIn`: a challenge to answer, not yet a session. */
78
+ startPasskeySignIn(input: {
79
+ authRequestId: string;
80
+ email: string;
81
+ }): Promise<{
82
+ sessionId: string;
83
+ options: unknown;
84
+ }>;
85
+ finishPasskeySignIn(input: {
86
+ authRequestId: string;
87
+ sessionId: string;
88
+ credential: unknown;
89
+ }): Promise<string>;
66
90
  followLink(input: {
67
91
  authRequestId: string;
68
92
  sessionId: string;
@@ -89,11 +113,14 @@ export declare class Broker {
89
113
  /**
90
114
  * What the service routes this key to. A suspended binding's key is refused.
91
115
  * Bounded, because every namespace request waits on this answer when it is due.
116
+ * `revoked` names the organization's recently deactivated people. It is absent
117
+ * from an older service, or while the service cannot ask the issuer.
92
118
  */
93
119
  binding(): Promise<{
94
120
  organizationId: string;
95
121
  clientIds: string[];
96
122
  origins: string[];
123
+ revoked?: string[];
97
124
  }>;
98
125
  verifyEmail(userId: string, code: string): Promise<unknown>;
99
126
  /** A public client revoking its own token: no key, no service. */
package/dist/broker.js CHANGED
@@ -76,6 +76,21 @@ export class Broker {
76
76
  });
77
77
  return callbackUrl;
78
78
  }
79
+ /** A passkey for somebody already signed in on the app's own domain. */
80
+ startPasskeyRegistration(userId) {
81
+ return this.call('POST', '/passkey/register/start', { userId });
82
+ }
83
+ async finishPasskeyRegistration(input) {
84
+ await this.call('POST', '/passkey/register/finish', input);
85
+ }
86
+ /** The passkey half of `signIn`: a challenge to answer, not yet a session. */
87
+ startPasskeySignIn(input) {
88
+ return this.call('POST', '/passkey/start', input);
89
+ }
90
+ async finishPasskeySignIn(input) {
91
+ const { callbackUrl } = await this.call('POST', '/passkey/finish', input);
92
+ return callbackUrl;
93
+ }
79
94
  async followLink(input) {
80
95
  const { callbackUrl } = await this.call('POST', '/link/follow', input);
81
96
  return callbackUrl;
@@ -99,6 +114,8 @@ export class Broker {
99
114
  /**
100
115
  * What the service routes this key to. A suspended binding's key is refused.
101
116
  * Bounded, because every namespace request waits on this answer when it is due.
117
+ * `revoked` names the organization's recently deactivated people. It is absent
118
+ * from an older service, or while the service cannot ask the issuer.
102
119
  */
103
120
  binding() {
104
121
  return this.call('GET', '/binding', {}, AbortSignal.timeout(5_000));
package/dist/cookies.d.ts CHANGED
@@ -28,6 +28,11 @@ export interface LinkPayload extends JWTPayload {
28
28
  stk: string;
29
29
  ar: string;
30
30
  }
31
+ /** The QR device-grant transaction: the device_code, never handed to the caller, and the interval the issuer asked us to poll at. Its own `exp` -- set to the issuer's `expires_in` -- is what makes an unsealed-past-expiry cookie fail open with the others below. */
32
+ export interface QrTransactionPayload extends JWTPayload {
33
+ dc: string;
34
+ iv: number;
35
+ }
31
36
  export interface SetCookie {
32
37
  name: string;
33
38
  value: string;
@@ -52,6 +57,15 @@ export declare function digest(value: string): Promise<string>;
52
57
  export declare function sealTransaction(options: ResolvedOptions, payload: Omit<TransactionPayload, keyof JWTPayload>): Promise<SetCookie>;
53
58
  export declare function openTransaction(options: ResolvedOptions, value: string | undefined): Promise<TransactionPayload | null>;
54
59
  export declare function clearTransaction(options: ResolvedOptions): SetCookie;
60
+ export declare function qrTransactionCookieName(options: ResolvedOptions): string;
61
+ /**
62
+ * `ttl` is the issuer's own `expires_in` for the device code, not a fixed
63
+ * constant like the other transactions: the cookie must not outlive what it
64
+ * points at.
65
+ */
66
+ export declare function sealQrTransaction(options: ResolvedOptions, payload: Omit<QrTransactionPayload, keyof JWTPayload>, ttl: number): Promise<SetCookie>;
67
+ export declare function openQrTransaction(options: ResolvedOptions, value: string | undefined): Promise<QrTransactionPayload | null>;
68
+ export declare function clearQrTransaction(options: ResolvedOptions): SetCookie;
55
69
  export declare function linkCookieName(options: ResolvedOptions): string;
56
70
  export declare function sealLink(options: ResolvedOptions, payload: Omit<LinkPayload, keyof JWTPayload>): Promise<SetCookie>;
57
71
  export declare function openLink(options: ResolvedOptions, value: string | undefined): Promise<LinkPayload | null>;
package/dist/cookies.js CHANGED
@@ -3,6 +3,7 @@ import { AuthError } from './oidc.js';
3
3
  const SESSION = 'wtfalch_auth';
4
4
  const TRANSACTION = 'wtfalch_auth_tx';
5
5
  const LINK = 'wtfalch_auth_link';
6
+ const QR = 'wtfalch_auth_qr';
6
7
  const TRANSACTION_TTL = 10 * 60;
7
8
  /**
8
9
  * A namespace session handle lives as long as a browser will keep a cookie.
@@ -80,6 +81,27 @@ export async function openTransaction(options, value) {
80
81
  export function clearTransaction(options) {
81
82
  return setCookie(transactionCookieName(options), '', 0, options.secure);
82
83
  }
84
+ export function qrTransactionCookieName(options) {
85
+ return options.secure ? `__Host-${QR}` : QR;
86
+ }
87
+ /**
88
+ * `ttl` is the issuer's own `expires_in` for the device code, not a fixed
89
+ * constant like the other transactions: the cookie must not outlive what it
90
+ * points at.
91
+ */
92
+ export async function sealQrTransaction(options, payload, ttl) {
93
+ const value = await seal(options, 'qr', payload, ttl);
94
+ return setCookie(qrTransactionCookieName(options), value, ttl, options.secure);
95
+ }
96
+ export async function openQrTransaction(options, value) {
97
+ const payload = await open(options, 'qr', value);
98
+ return payload && typeof payload.dc === 'string' && typeof payload.iv === 'number'
99
+ ? payload
100
+ : null;
101
+ }
102
+ export function clearQrTransaction(options) {
103
+ return setCookie(qrTransactionCookieName(options), '', 0, options.secure);
104
+ }
83
105
  export function linkCookieName(options) {
84
106
  return options.secure ? `__Host-${LINK}` : LINK;
85
107
  }
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
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';
1
+ export { type Auth, type AuthRequest, type Gate, type GateRules, type Intent, type NewUser, type PasskeyRegistrationResult, type PasskeyRegistrationStart, type PasskeySignInStart, 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';
@@ -1,7 +1,7 @@
1
1
  import { jwtDecrypt } from 'jose';
2
2
  import { resolveOptions } from './config.js';
3
3
  import { cookieFrom, openTransaction, transactionCookieName } from './cookies.js';
4
- import { parseNamespaceRegistry, } from './namespaces.js';
4
+ import { isWebService, parseNamespaceRegistry, } from './namespaces.js';
5
5
  /** Resolve a shared callback from its authenticated transaction, never a URL
6
6
  * namespace hint. The second open checks the entire current registry binding. */
7
7
  export async function resolveNamespaceCallback(input) {
@@ -9,12 +9,16 @@ export async function resolveNamespaceCallback(input) {
9
9
  const registry = parseNamespaceRegistry(input.registry);
10
10
  const callback = new URL(`${input.basePath ?? '/auth'}/callback`, input.appOrigin).href;
11
11
  const candidates = registry.namespaces.filter((n) => n.status === 'active' &&
12
- n.services.some((s) => s.serviceId === input.serviceId &&
12
+ n.services
13
+ .filter(isWebService)
14
+ .some((s) => s.serviceId === input.serviceId &&
13
15
  s.deploymentId === input.deploymentId &&
14
16
  s.appOrigin === input.appOrigin &&
15
17
  s.redirectUris.includes(callback)));
16
18
  const optionsFor = (namespace) => {
17
- const service = namespace.services.find((s) => s.serviceId === input.serviceId && s.deploymentId === input.deploymentId);
19
+ const service = namespace.services
20
+ .filter(isWebService)
21
+ .find((s) => s.serviceId === input.serviceId && s.deploymentId === input.deploymentId);
18
22
  if (!service)
19
23
  throw new Error('Missing service');
20
24
  const selection = {
@@ -35,15 +35,40 @@ export function namespaceSessions(deps) {
35
35
  let status = null;
36
36
  let retryAt = 0;
37
37
  let pending = null;
38
+ // People whose sessions this instance has already ended for the current
39
+ // deactivation. Someone who leaves the list, by reactivation or by ageing
40
+ // out of the service's window, is forgotten, so a second deactivation
41
+ // ends their sessions again.
42
+ const ended = new Set();
43
+ const endDeactivated = async (revoked) => {
44
+ if (!Array.isArray(revoked))
45
+ return;
46
+ const current = new Set(revoked.filter((s) => typeof s === 'string' && !!s));
47
+ for (const subject of ended)
48
+ if (!current.has(subject))
49
+ ended.delete(subject);
50
+ for (const subject of current) {
51
+ if (ended.has(subject))
52
+ continue;
53
+ await revokeSubject(subject);
54
+ ended.add(subject);
55
+ }
56
+ };
38
57
  const askService = async () => {
39
58
  const namespace = options().namespace;
40
59
  if (!namespace)
41
60
  return false;
42
61
  try {
43
62
  const routed = await deps.broker().binding();
44
- return (routed.organizationId === namespace.organizationId &&
63
+ const active = routed.organizationId === namespace.organizationId &&
45
64
  routed.clientIds.includes(namespace.clientId) &&
46
- routed.origins.includes(namespace.appOrigin));
65
+ routed.origins.includes(namespace.appOrigin);
66
+ // Deactivated anywhere, by any service or in the issuer's console: their
67
+ // sessions here end before this answer is used, so within the same bound
68
+ // as a suspension. Only a list about this binding's own organization counts.
69
+ if (routed.organizationId === namespace.organizationId)
70
+ await endDeactivated(routed.revoked);
71
+ return active;
47
72
  }
48
73
  catch (error) {
49
74
  // Suspension removes the binding's key, so the service no longer knows it.