@wtfalch/auth 0.32.0 → 0.34.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
@@ -293,9 +293,43 @@ at `/auth/desktop/redeem`.
293
293
  whoever the issuer signed in, with no further check. It fails open: an app
294
294
  that needs an organisation, second-factor or linked-principal check and leaves
295
295
  `admit` out gets no warning. Pass the check explicitly.
296
+ - **The store.** A multi-replica app passes its Postgres client to
297
+ `createPostgresDesktopHandoffStore(client, { capacity, ttlMs, pollGapMs })`
298
+ and runs `DESKTOP_HANDOFFS_TABLE_SQL` once in a migration. The client is two
299
+ methods, so any driver fits: `query(text, values)` resolves to the rows, and
300
+ `transaction(fn)` runs `fn` with the same `query` shape on one connection,
301
+ committing when `fn` returns and rolling back when it throws. With `pg`:
302
+
303
+ ```ts
304
+ const client: DesktopHandoffSqlClient = {
305
+ query: async (text, values) => (await pool.query(text, [...values])).rows,
306
+ async transaction(fn) {
307
+ const connection = await pool.connect();
308
+ try {
309
+ await connection.query('BEGIN');
310
+ const result = await fn({
311
+ query: async (text, values) => (await connection.query(text, [...values])).rows,
312
+ });
313
+ await connection.query('COMMIT');
314
+ return result;
315
+ } catch (error) {
316
+ await connection.query('ROLLBACK');
317
+ throw error;
318
+ } finally {
319
+ connection.release();
320
+ }
321
+ },
322
+ };
323
+ ```
324
+
325
+ The table is Boule's and Archon's `desktop_handoffs` with a `caller_hash text`
326
+ column; an app that has the table without it runs
327
+ `ALTER TABLE desktop_handoffs ADD COLUMN IF NOT EXISTS caller_hash text`. Test
328
+ any store, this one included, with `checkDesktopHandoffStore`.
296
329
  - **The store must lock.** `lock(tx, id)` is `SELECT ... FOR UPDATE` in a real
297
330
  store. The memory store locks per row; a store that only reads lets two
298
- concurrent redeems both get the cookie.
331
+ concurrent redeems both get the cookie. `createMemoryDesktopHandoffStore`
332
+ serves one process only.
299
333
  - **Rate-limit `/auth/desktop/start` at the edge too.** The handler caps open
300
334
  sign-ins per caller (`maxStartsPerCaller`, default 8, keyed on the address in
301
335
  `clientAddressHeader`, default `cf-connecting-ip`) under the store's total
@@ -433,6 +467,56 @@ A phone that is already signed in can also approve a new device from a QR
433
467
  code, the issuer's OAuth device grant opted into per namespace binding with
434
468
  `qrSignIn: true`, through `startQrSignIn` and `pollQrSignIn`.
435
469
 
470
+ ## Workspaces for your app's customers
471
+
472
+ An app with `workspaces` on its entry gets `WORKSPACES_URL`, `WORKSPACES_API_KEY`
473
+ and `AUTH_ORGANIZATION_ID`. `@wtfalch/auth/workspaces` calls the broker with them.
474
+ The app keeps the current workspace id itself; this one holds it in a cookie.
475
+
476
+ ```ts
477
+ import { cookies } from 'next/headers';
478
+ import { workspaces, WorkspacesError } from '@wtfalch/auth/workspaces';
479
+ import { auth } from './auth'; // your createAuth() instance
480
+
481
+ const ws = workspaces(); // throws at once if an env value is missing
482
+
483
+ export async function currentWorkspace(request: Request) {
484
+ const { user } = await auth.read(request);
485
+ if (!user) return null;
486
+ const id = (await cookies()).get('workspace')?.value;
487
+ const role = id ? await ws.access(id, user) : null; // never trust the cookie alone
488
+ return id && role ? { id, role } : null;
489
+ }
490
+
491
+ export async function createWorkspace(request: Request, name: string) {
492
+ const { user } = await auth.read(request);
493
+ if (!user) throw new Error('Sign in first.');
494
+ const made = await ws.create({ name, owner: user });
495
+ (await cookies()).set('workspace', made.id, { httpOnly: true, sameSite: 'lax', secure: true });
496
+ return made;
497
+ }
498
+
499
+ export async function inviteBy(request: Request, id: string, email: string) {
500
+ const { user } = await auth.read(request);
501
+ if (!user) throw new Error('Sign in first.');
502
+ try {
503
+ await ws.invite(id, { email, role: 'member', actor: user }); // the broker mails the link
504
+ } catch (error) {
505
+ if (error instanceof WorkspacesError && error.status === 403) return 'Only an owner can invite.';
506
+ throw error;
507
+ }
508
+ }
509
+ // On the page behind `inviteUrl?token=`: await ws.accept(token, user) -> { workspaceId, role }
510
+
511
+ // Any member: who is in the workspace -> [{ sub, email, name, role }] (name is null; email is '' only for a creator from before this existed)
512
+ await ws.members(id, user);
513
+ // Owner only:
514
+ await ws.invitations(id, user); // pending -> [{ id, email, role, createdAt, expiresAt }]
515
+ await ws.revokeInvitation(id, invitationId, user); // the emailed link stops working
516
+ await ws.rename(id, 'New name', user);
517
+ await ws.archive(id, user); // leaves listFor; every later call on it fails with status 410
518
+ ```
519
+
436
520
  ## Preview routes
437
521
 
438
522
  For apps on live/idle releases. The balancer sends a request with the `bg_preview=1` cookie to the idle slot, after asking the live slot whether the person may see it.
@@ -441,16 +525,15 @@ For apps on live/idle releases. The balancer sends a request with the `bg_previe
441
525
  import { createPreviewRoutes } from '@wtfalch/auth';
442
526
 
443
527
  export const preview = createPreviewRoutes({
444
- readSession: async (request) => (await auth.read(request, { refresh: false })).user,
445
- mayPreview: (session) => canPreview(session), // `releases:preview` in the app's production authz
446
- appOrigin: 'https://app.example.test', // required: the app's own origin, the one `appUrl` names
528
+ auth, // the app's own `createAuth(...)` instance
529
+ mayPreview: (user) => canPreview(user), // `releases:preview` in the app's production authz
447
530
  });
448
531
  // GET /api/foundry/preview-check -> preview.check (200, 401 no session, 403 otherwise)
449
532
  // POST /api/foundry/preview -> preview.set (204 and the cookie, same-origin only)
450
533
  // DELETE /api/foundry/preview -> preview.clear (204, same-origin only, no permission needed)
451
534
  ```
452
535
 
453
- Answers carry no body and `cache-control: no-store`. A reader or permission check that throws answers 403 and logs one fixed line with the error name only. `appOrigin` is required and is never read from the request. `PREVIEW_COOKIE`, `PREVIEW_CHECK_PATH` and `PREVIEW_PATH` are exported too.
536
+ Answers carry no body and `cache-control: no-store`. A reader or permission check that throws answers 403 and logs one fixed line with the error name only. The session is read through `auth` (without refreshing), and the trusted origin and the cookie's `Secure` rule come from its `appUrl`, never from the request. They are read on the first request, so `next build` importing this module with no environment set does not throw. A request from another origin is refused with 403 and no log line. `PREVIEW_COOKIE`, `PREVIEW_CHECK_PATH` and `PREVIEW_PATH` are exported too.
454
537
 
455
538
  ## Documentation
456
539
 
package/dist/auth.d.ts CHANGED
@@ -151,6 +151,45 @@ export type PasskeySignInStart = {
151
151
  ok: false;
152
152
  error: SignInError;
153
153
  };
154
+ /** What a fresh sign-in step needs the caller to do. `cookies` are always to be set, success or not. */
155
+ export type FreshOutcome = {
156
+ ok: true;
157
+ cookies: SetCookie[];
158
+ } | {
159
+ ok: false;
160
+ error: SignInError | 'other_person' | 'invalid_code';
161
+ cookies: SetCookie[];
162
+ } | {
163
+ ok: false;
164
+ secondFactor: {
165
+ token: string;
166
+ methods: 'totp'[];
167
+ };
168
+ cookies: SetCookie[];
169
+ } | {
170
+ ok: false;
171
+ hosted: true;
172
+ next?: string;
173
+ cookies: SetCookie[];
174
+ };
175
+ export type FreshStart = {
176
+ ok: true;
177
+ authRequestId: string;
178
+ loginHint: string;
179
+ passkey: boolean;
180
+ cookies: SetCookie[];
181
+ } | {
182
+ ok: false;
183
+ hosted: true;
184
+ cookies: SetCookie[];
185
+ };
186
+ export type FreshPasskeyStart = {
187
+ ok: true;
188
+ options: unknown;
189
+ cookies: SetCookie[];
190
+ } | Exclude<FreshOutcome, {
191
+ ok: true;
192
+ }>;
154
193
  export interface Completed {
155
194
  location: string;
156
195
  cookies: SetCookie[];
@@ -195,6 +234,10 @@ export interface QrPollResult {
195
234
  }
196
235
  export interface Auth {
197
236
  readonly sessionCookieName: string;
237
+ /** The app's own origin, from `appUrl`. Read on first use, so it throws only if `appUrl` is missing or malformed. */
238
+ readonly appOrigin: string;
239
+ /** Whether this app's cookies carry `Secure`: true when `appUrl` is https. The same rule the session cookie uses. */
240
+ readonly cookiesSecure: boolean;
198
241
  /** GET {basePath}/start?next=&intent= : begins the OIDC flow; the issuer sends the browser to {basePath}/login?authRequest= */
199
242
  start(request: Request): Promise<Response>;
200
243
  /** GET {basePath}/callback */
@@ -421,6 +464,35 @@ export interface Auth {
421
464
  endSession(request: Request, key: string): Promise<void>;
422
465
  /** The auth request the issuer sent the browser here with. Refuses one for another application. */
423
466
  authRequest(id: string): Promise<AuthRequest>;
467
+ /**
468
+ * Starts a `prompt=login` auth request for whoever is signed in on `request`
469
+ * and seals its PKCE transaction in a cookie. Nothing redirects. `hosted`
470
+ * means the modal cannot be used: nobody is signed in, or the issuer did not
471
+ * start a request. The other four calls take the same `request` and the
472
+ * `authRequestId` this returned.
473
+ */
474
+ startFreshSignIn(request: Request, opts?: {
475
+ next?: string;
476
+ }): Promise<FreshStart>;
477
+ /** The password step. The email is the signed-in person's own, never an argument. */
478
+ finishFreshSignIn(request: Request, input: {
479
+ authRequestId: string;
480
+ password: string;
481
+ }): Promise<FreshOutcome>;
482
+ /** A passkey challenge for the signed-in person; `finishFreshPasskey` answers it. */
483
+ startFreshPasskey(request: Request, input: {
484
+ authRequestId: string;
485
+ }): Promise<FreshPasskeyStart>;
486
+ finishFreshPasskey(request: Request, input: {
487
+ authRequestId: string;
488
+ credential: unknown;
489
+ }): Promise<FreshOutcome>;
490
+ /** The code step after a `secondFactor` outcome. */
491
+ verifyFreshSecondFactor(request: Request, input: {
492
+ authRequestId: string;
493
+ token: string;
494
+ code: string;
495
+ }): Promise<FreshOutcome>;
424
496
  signIn(input: {
425
497
  authRequestId: string;
426
498
  email: string;
package/dist/auth.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import * as client from 'openid-client';
2
+ import { declaredWithin, readBounded } from './body.js';
2
3
  import { Broker, BrokerError } from './broker.js';
3
4
  import { resolveOptions, } from './config.js';
4
5
  import { clearQrTransaction, clearSession, clearTransaction, cookieFrom, openQrTransaction, openRecord, openSession, openSilent, openTransaction, qrTransactionCookieName, sealQrTransaction, sealSession, sealSilent, sealTransaction, sessionCookieName, silentCookieName, transactionCookieName, } from './cookies.js';
@@ -6,7 +7,7 @@ import { namespaceSessions } from './namespace-session.js';
6
7
  import { AuthError, ORG_CLAIM, Oidc, isOAuthRefusal, rejected, scopeFor, } from './oidc.js';
7
8
  import { pollDeviceToken, requestDeviceAuthorization } from './qr.js';
8
9
  import { isSameOrigin, safeLoginHint, safeNextPath } from './redirect.js';
9
- import { expiryOf, issuedAtOf, recheckSeconds, timeoutSignal } from './util.js';
10
+ import { NO_STORE, expiryOf, issuedAtOf, recheckSeconds, timeoutSignal } from './util.js';
10
11
  export function createAuth(input) {
11
12
  // Resolved on first use so `next build`, which imports every route module, needs none of the values set.
12
13
  let resolved = null;
@@ -142,11 +143,11 @@ export function createAuth(input) {
142
143
  const type = request.headers.get('content-type')?.split(';')[0]?.trim().toLowerCase();
143
144
  if (type !== 'application/x-www-form-urlencoded')
144
145
  return new Response('Unsupported Media Type', { status: 415 });
145
- const length = request.headers.get('content-length');
146
- if (length === null || !/^\d{1,9}$/.test(length) || Number(length) > START_BODY_MAX)
146
+ if (!declaredWithin(request, START_BODY_MAX))
147
147
  return new Response('Payload Too Large', { status: 413 });
148
- const text = await request.text();
149
- if (text.length > START_BODY_MAX)
148
+ // The declared length is a hint; the read itself stops at the cap.
149
+ const text = await readBounded(request, START_BODY_MAX);
150
+ if (text === null)
150
151
  return new Response('Payload Too Large', { status: 413 });
151
152
  return new URLSearchParams(text);
152
153
  };
@@ -546,13 +547,13 @@ export function createAuth(input) {
546
547
  * An OIDC flow this app starts and finishes itself, for a person arriving
547
548
  * from an email rather than from a browser the app sent to the issuer.
548
549
  */
549
- const serverFlow = async () => {
550
+ const serverFlow = async (authorization = {}) => {
550
551
  if (options().namespace && !(await sessions.bindingActive()))
551
552
  throw new AuthError('unavailable', 'the namespace binding is not active');
552
553
  const state = client.randomState();
553
554
  const nonce = client.randomNonce();
554
555
  const verifier = client.randomPKCECodeVerifier();
555
- const target = await oidc().authorizationUrl(state, nonce, verifier);
556
+ const target = await oidc().authorizationUrl(state, nonce, verifier, authorization);
556
557
  const response = await options().fetch(target.href, {
557
558
  redirect: 'manual',
558
559
  signal: timeoutSignal(),
@@ -612,6 +613,187 @@ export function createAuth(input) {
612
613
  };
613
614
  }
614
615
  };
616
+ /**
617
+ * The fresh sign-in is the modal's version of `/auth/start` + `/auth/callback`:
618
+ * the same PKCE transaction, started and finished on the server, so the page
619
+ * never navigates. The transaction cookie holds the auth request id (`ar`),
620
+ * and every step refuses an id that is not that one.
621
+ */
622
+ const freshContext = async (request, authRequestId) => {
623
+ const { user } = await read(request, { refresh: false });
624
+ const transaction = await openTransaction(options(), cookieFrom(request.headers.get('cookie'), transactionCookieName(options())));
625
+ if (!user?.email)
626
+ return {
627
+ kind: 'refused',
628
+ outcome: { ok: false, hosted: true, next: transaction?.nx, cookies: [] },
629
+ };
630
+ if (!transaction || transaction.ar === undefined || transaction.ar !== authRequestId)
631
+ return { kind: 'refused', outcome: { ok: false, error: 'request', cookies: [] } };
632
+ return { kind: 'ready', user, email: user.email, transaction };
633
+ };
634
+ const startFreshSignIn = async (request, opts = {}) => {
635
+ const { user } = await read(request, { refresh: false });
636
+ const loginHint = user?.email ? safeLoginHint(user.email) : undefined;
637
+ if (!user || !loginHint)
638
+ return { ok: false, hosted: true, cookies: [] };
639
+ let flow;
640
+ try {
641
+ flow = await serverFlow({ prompt: 'login', loginHint });
642
+ }
643
+ catch (error) {
644
+ console.error('@wtfalch/auth: could not start a fresh sign-in', error instanceof Error ? error.name : 'unknown');
645
+ return { ok: false, hosted: true, cookies: [] };
646
+ }
647
+ const cookie = await sealTransaction(options(), {
648
+ st: flow.state,
649
+ nc: flow.nonce,
650
+ cv: flow.verifier,
651
+ nx: safeNextPath(opts.next, options().afterLogin),
652
+ ar: flow.id,
653
+ });
654
+ let passkey = false;
655
+ try {
656
+ await broker().startPasskeySignIn({ authRequestId: flow.id, email: user.email });
657
+ passkey = true;
658
+ }
659
+ catch (error) {
660
+ if (!(error instanceof BrokerError && error.status < 500)) {
661
+ console.error('@wtfalch/auth: could not check for a passkey', error instanceof Error ? error.name : 'unknown');
662
+ }
663
+ }
664
+ return { ok: true, authRequestId: flow.id, loginHint, passkey, cookies: [cookie] };
665
+ };
666
+ /** Turns what the broker said into the outcome the modal acts on. */
667
+ const freshFailure = (error, cookies = [], next) => {
668
+ if (error instanceof BrokerError) {
669
+ if (needsHostedLogin(error) || error.error === 'password_change_required')
670
+ return { ok: false, hosted: true, next, cookies };
671
+ if (error.error === 'second_factor_required') {
672
+ if (error.secondFactor?.methods.includes('totp'))
673
+ return {
674
+ ok: false,
675
+ secondFactor: { token: error.secondFactor.token, methods: ['totp'] },
676
+ cookies,
677
+ };
678
+ return { ok: false, hosted: true, next, cookies };
679
+ }
680
+ if (error.error === 'invalid_code')
681
+ return { ok: false, error: 'invalid_code', cookies };
682
+ if (error.error === 'invalid')
683
+ return { ok: false, error: 'request', cookies };
684
+ }
685
+ else {
686
+ console.error('@wtfalch/auth: a fresh sign-in step failed', error instanceof Error ? error.name : 'unknown');
687
+ }
688
+ return { ok: false, error: signInError(error), cookies };
689
+ };
690
+ /**
691
+ * Exchanges the callback and, only when it is the same person, replaces the
692
+ * session. The check comes before the session is made, so another person's
693
+ * sign-in leaves no record behind in namespace mode either.
694
+ */
695
+ const completeFresh = async (request, callbackUrl, user, transaction) => {
696
+ const cleared = clearTransaction(options());
697
+ if (!validCallback(callbackUrl))
698
+ return { ok: false, error: 'request', cookies: [cleared] };
699
+ if (options().namespace && !(await sessions.bindingActive()))
700
+ return { ok: false, error: 'unavailable', cookies: [cleared] };
701
+ let tokens;
702
+ try {
703
+ tokens = await oidc().exchange(new URL(callbackUrl).searchParams, {
704
+ state: transaction.st,
705
+ nonce: transaction.nc,
706
+ codeVerifier: transaction.cv,
707
+ });
708
+ }
709
+ catch (error) {
710
+ console.error('@wtfalch/auth: the fresh sign-in could not be exchanged', error instanceof Error ? error.name : 'unknown');
711
+ return { ok: false, error: 'request', cookies: [cleared] };
712
+ }
713
+ if (String(tokens.claims.sub) !== user.id)
714
+ return { ok: false, error: 'other_person', cookies: [cleared] };
715
+ const session = await sessionCookie(tokens, request.headers);
716
+ await replacePreviousSession(request.headers.get('cookie'));
717
+ return { ok: true, cookies: [cleared, session] };
718
+ };
719
+ const finishFreshSignIn = async (request, input) => {
720
+ const context = await freshContext(request, input.authRequestId);
721
+ if (context.kind === 'refused')
722
+ return context.outcome;
723
+ let callbackUrl;
724
+ try {
725
+ callbackUrl = await broker().signIn({
726
+ authRequestId: input.authRequestId,
727
+ email: context.email,
728
+ password: input.password,
729
+ secondFactor: true,
730
+ });
731
+ }
732
+ catch (error) {
733
+ return freshFailure(error, [], context.transaction.nx);
734
+ }
735
+ return completeFresh(request, callbackUrl, context.user, context.transaction);
736
+ };
737
+ const startFreshPasskey = async (request, input) => {
738
+ const context = await freshContext(request, input.authRequestId);
739
+ if (context.kind === 'refused')
740
+ return context.outcome;
741
+ try {
742
+ const { sessionId, options: challenge } = await broker().startPasskeySignIn({
743
+ authRequestId: input.authRequestId,
744
+ email: context.email,
745
+ });
746
+ const cookie = await sealTransaction(options(), {
747
+ st: context.transaction.st,
748
+ nc: context.transaction.nc,
749
+ cv: context.transaction.cv,
750
+ nx: context.transaction.nx,
751
+ ar: input.authRequestId,
752
+ fp: sessionId,
753
+ });
754
+ return { ok: true, options: challenge, cookies: [cookie] };
755
+ }
756
+ catch (error) {
757
+ return freshFailure(error, [], context.transaction.nx);
758
+ }
759
+ };
760
+ const finishFreshPasskey = async (request, input) => {
761
+ const context = await freshContext(request, input.authRequestId);
762
+ if (context.kind === 'refused')
763
+ return context.outcome;
764
+ if (!context.transaction.fp)
765
+ return { ok: false, error: 'request', cookies: [] };
766
+ let callbackUrl;
767
+ try {
768
+ callbackUrl = await broker().finishPasskeySignIn({
769
+ authRequestId: input.authRequestId,
770
+ sessionId: context.transaction.fp,
771
+ credential: input.credential,
772
+ secondFactor: true,
773
+ });
774
+ }
775
+ catch (error) {
776
+ return freshFailure(error, [], context.transaction.nx);
777
+ }
778
+ return completeFresh(request, callbackUrl, context.user, context.transaction);
779
+ };
780
+ const verifyFreshSecondFactor = async (request, input) => {
781
+ const context = await freshContext(request, input.authRequestId);
782
+ if (context.kind === 'refused')
783
+ return context.outcome;
784
+ let callbackUrl;
785
+ try {
786
+ callbackUrl = await broker().verifySecondFactor({
787
+ authRequestId: input.authRequestId,
788
+ token: input.token,
789
+ code: input.code,
790
+ });
791
+ }
792
+ catch (error) {
793
+ return freshFailure(error, [], context.transaction.nx);
794
+ }
795
+ return completeFresh(request, callbackUrl, context.user, context.transaction);
796
+ };
615
797
  const resendVerification = (email, next) => quietly(() => broker().sendVerification(email, safeNextPath(next, '')));
616
798
  const requestPasswordReset = (email, next) => quietly(() => broker().sendReset(email, safeNextPath(next, '')));
617
799
  const sendLink = (email, next) => quietly(() => broker().sendLink(email, safeNextPath(next, '')));
@@ -1032,6 +1214,12 @@ export function createAuth(input) {
1032
1214
  get sessionCookieName() {
1033
1215
  return sessionCookieName(options());
1034
1216
  },
1217
+ get appOrigin() {
1218
+ return options().appUrl.origin;
1219
+ },
1220
+ get cookiesSecure() {
1221
+ return options().secure;
1222
+ },
1035
1223
  start,
1036
1224
  callback,
1037
1225
  complete,
@@ -1061,6 +1249,11 @@ export function createAuth(input) {
1061
1249
  sessions: listSessions,
1062
1250
  endSession,
1063
1251
  authRequest,
1252
+ startFreshSignIn,
1253
+ finishFreshSignIn,
1254
+ startFreshPasskey,
1255
+ finishFreshPasskey,
1256
+ verifyFreshSecondFactor,
1064
1257
  signIn,
1065
1258
  setNewPassword,
1066
1259
  setNewPasswordHeadless,
@@ -1087,38 +1280,8 @@ function redirect(location, cookies, status = 302) {
1087
1280
  headers.append('Set-Cookie', cookie.header);
1088
1281
  return new Response(null, { status, headers });
1089
1282
  }
1090
- const NO_STORE = { 'Cache-Control': 'no-store' };
1091
1283
  /** A logout token is a few hundred bytes; this is generous and still small. */
1092
1284
  const BACKCHANNEL_LOGOUT_MAX_BODY_BYTES = 16 * 1024;
1093
- /** The request body as text, or null when it is larger than `max` bytes. Stops reading at the cap. */
1094
- async function readBounded(request, max) {
1095
- const declared = Number(request.headers.get('content-length'));
1096
- if (Number.isFinite(declared) && declared > max)
1097
- return null;
1098
- if (!request.body)
1099
- return '';
1100
- const reader = request.body.getReader();
1101
- const chunks = [];
1102
- let size = 0;
1103
- for (;;) {
1104
- const { done, value } = await reader.read();
1105
- if (done)
1106
- break;
1107
- size += value.byteLength;
1108
- if (size > max) {
1109
- await reader.cancel();
1110
- return null;
1111
- }
1112
- chunks.push(value);
1113
- }
1114
- const bytes = new Uint8Array(size);
1115
- let offset = 0;
1116
- for (const chunk of chunks) {
1117
- bytes.set(chunk, offset);
1118
- offset += chunk.byteLength;
1119
- }
1120
- return new TextDecoder().decode(bytes);
1121
- }
1122
1285
  /** A back-channel logout endpoint's 400 error body, per OpenID Connect Back-Channel Logout 1.0 §2.6. */
1123
1286
  function backchannelLogoutError(error, description, status = 400) {
1124
1287
  return new Response(JSON.stringify({ error, error_description: description }), {
package/dist/body.d.ts ADDED
@@ -0,0 +1,5 @@
1
+ /** Bounded request bodies: the one place the package decides how much of a request it will read. */
2
+ /** True when the request declares a plain decimal length of at most `max`. A missing or odd length is refused too. */
3
+ export declare function declaredWithin(request: Request, max: number): boolean;
4
+ /** The request body as text, or null when it is larger than `max` bytes. Stops reading at the cap. */
5
+ export declare function readBounded(request: Request, max: number): Promise<string | null>;
package/dist/body.js ADDED
@@ -0,0 +1,35 @@
1
+ /** Bounded request bodies: the one place the package decides how much of a request it will read. */
2
+ /** True when the request declares a plain decimal length of at most `max`. A missing or odd length is refused too. */
3
+ export function declaredWithin(request, max) {
4
+ const length = request.headers.get('content-length');
5
+ return length !== null && /^\d{1,9}$/.test(length) && Number(length) <= max;
6
+ }
7
+ /** The request body as text, or null when it is larger than `max` bytes. Stops reading at the cap. */
8
+ export async function readBounded(request, max) {
9
+ const declared = Number(request.headers.get('content-length'));
10
+ if (Number.isFinite(declared) && declared > max)
11
+ return null;
12
+ if (!request.body)
13
+ return '';
14
+ const reader = request.body.getReader();
15
+ const chunks = [];
16
+ let size = 0;
17
+ for (;;) {
18
+ const { done, value } = await reader.read();
19
+ if (done)
20
+ break;
21
+ size += value.byteLength;
22
+ if (size > max) {
23
+ await reader.cancel();
24
+ return null;
25
+ }
26
+ chunks.push(value);
27
+ }
28
+ const bytes = new Uint8Array(size);
29
+ let offset = 0;
30
+ for (const chunk of chunks) {
31
+ bytes.set(chunk, offset);
32
+ offset += chunk.byteLength;
33
+ }
34
+ return new TextDecoder().decode(bytes);
35
+ }
package/dist/broker.d.ts CHANGED
@@ -49,9 +49,19 @@ export declare class BrokerError extends Error {
49
49
  readonly detail?: string | undefined;
50
50
  /** Only `password_change_required` carries one: see `setNewPassword`. */
51
51
  readonly changeToken?: string | undefined;
52
+ /** Only `second_factor_required` carries these: see `Broker.signIn`'s `secondFactor`. */
53
+ readonly secondFactor?: {
54
+ token: string;
55
+ methods: string[];
56
+ } | undefined;
52
57
  constructor(status: number, error: string, detail?: string | undefined,
53
58
  /** Only `password_change_required` carries one: see `setNewPassword`. */
54
- changeToken?: string | undefined);
59
+ changeToken?: string | undefined,
60
+ /** Only `second_factor_required` carries these: see `Broker.signIn`'s `secondFactor`. */
61
+ secondFactor?: {
62
+ token: string;
63
+ methods: string[];
64
+ } | undefined);
55
65
  }
56
66
  /**
57
67
  * The privileged half, over HTTP. The app holds a key that reaches its own
@@ -76,10 +86,16 @@ export declare class Broker {
76
86
  password: string;
77
87
  }>;
78
88
  authRequest(id: string): Promise<AuthRequest>;
89
+ /**
90
+ * `secondFactor: true` asks the service to answer 403 `second_factor_required`
91
+ * (a token to send to `verifySecondFactor`) where it would otherwise answer
92
+ * `hosted_login_required` only because of an enrolled one-time-code factor.
93
+ */
79
94
  signIn(input: {
80
95
  authRequestId: string;
81
96
  email: string;
82
97
  password: string;
98
+ secondFactor?: true;
83
99
  }): Promise<string>;
84
100
  /** The call after `password_change_required`: no old password, only the token that sign-in answered with. */
85
101
  setNewPassword(input: {
@@ -113,6 +129,13 @@ export declare class Broker {
113
129
  authRequestId: string;
114
130
  sessionId: string;
115
131
  credential: unknown;
132
+ secondFactor?: true;
133
+ }): Promise<string>;
134
+ /** The code half of a `second_factor_required` answer; finishes the auth request like `signIn`. */
135
+ verifySecondFactor(input: {
136
+ authRequestId: string;
137
+ token: string;
138
+ code: string;
116
139
  }): Promise<string>;
117
140
  followLink(input: {
118
141
  authRequestId: string;
package/dist/broker.js CHANGED
@@ -4,14 +4,18 @@ export class BrokerError extends Error {
4
4
  error;
5
5
  detail;
6
6
  changeToken;
7
+ secondFactor;
7
8
  constructor(status, error, detail,
8
9
  /** Only `password_change_required` carries one: see `setNewPassword`. */
9
- changeToken) {
10
+ changeToken,
11
+ /** Only `second_factor_required` carries these: see `Broker.signIn`'s `secondFactor`. */
12
+ secondFactor) {
10
13
  super(detail ?? error);
11
14
  this.status = status;
12
15
  this.error = error;
13
16
  this.detail = detail;
14
17
  this.changeToken = changeToken;
18
+ this.secondFactor = secondFactor;
15
19
  this.name = 'BrokerError';
16
20
  }
17
21
  }
@@ -53,7 +57,14 @@ export class Broker {
53
57
  throw new BrokerError(response.status, 'unavailable', `non-JSON response from ${path}`);
54
58
  }
55
59
  if (!response.ok) {
56
- throw new BrokerError(response.status, String(data.error ?? 'unavailable'), data.message, typeof data.changeToken === 'string' ? data.changeToken : undefined);
60
+ throw new BrokerError(response.status, String(data.error ?? 'unavailable'), data.message, typeof data.changeToken === 'string' ? data.changeToken : undefined, data.error === 'second_factor_required' && typeof data.token === 'string'
61
+ ? {
62
+ token: data.token,
63
+ methods: Array.isArray(data.methods)
64
+ ? data.methods.filter((m) => typeof m === 'string')
65
+ : [],
66
+ }
67
+ : undefined);
57
68
  }
58
69
  return data;
59
70
  }
@@ -75,6 +86,11 @@ export class Broker {
75
86
  authRequest(id) {
76
87
  return this.call('GET', '/auth-request', { id });
77
88
  }
89
+ /**
90
+ * `secondFactor: true` asks the service to answer 403 `second_factor_required`
91
+ * (a token to send to `verifySecondFactor`) where it would otherwise answer
92
+ * `hosted_login_required` only because of an enrolled one-time-code factor.
93
+ */
78
94
  async signIn(input) {
79
95
  const { callbackUrl } = await this.call('POST', '/sign-in', input);
80
96
  return callbackUrl;
@@ -113,6 +129,11 @@ export class Broker {
113
129
  const { callbackUrl } = await this.call('POST', '/passkey/finish', input);
114
130
  return callbackUrl;
115
131
  }
132
+ /** The code half of a `second_factor_required` answer; finishes the auth request like `signIn`. */
133
+ async verifySecondFactor(input) {
134
+ const { callbackUrl } = await this.call('POST', '/second-factor/verify', input);
135
+ return callbackUrl;
136
+ }
116
137
  async followLink(input) {
117
138
  const { callbackUrl } = await this.call('POST', '/link/follow', input);
118
139
  return callbackUrl;