@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 +88 -5
- package/dist/auth.d.ts +72 -0
- package/dist/auth.js +200 -37
- package/dist/body.d.ts +5 -0
- package/dist/body.js +35 -0
- package/dist/broker.d.ts +24 -1
- package/dist/broker.js +23 -2
- package/dist/cookies.d.ts +7 -0
- package/dist/cookies.js +9 -4
- package/dist/desktop-crypto.d.ts +26 -0
- package/dist/desktop-crypto.js +62 -0
- package/dist/desktop-store-contract.d.ts +13 -0
- package/dist/desktop-store-contract.js +106 -0
- package/dist/desktop-store-postgres.d.ts +40 -0
- package/dist/desktop-store-postgres.js +133 -0
- package/dist/desktop-store.d.ts +130 -0
- package/dist/desktop-store.js +111 -0
- package/dist/desktop.d.ts +11 -76
- package/dist/desktop.js +156 -286
- package/dist/fresh.d.ts +14 -0
- package/dist/fresh.js +21 -0
- package/dist/next.d.ts +74 -1
- package/dist/next.js +91 -0
- package/dist/preview.d.ts +11 -7
- package/dist/preview.js +20 -24
- package/dist/redirect.d.ts +8 -0
- package/dist/redirect.js +8 -0
- package/dist/util.d.ts +4 -0
- package/dist/util.js +2 -0
- package/dist/workspaces.d.ts +79 -0
- package/dist/workspaces.js +137 -0
- package/package.json +12 -2
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
|
-
|
|
445
|
-
mayPreview: (
|
|
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. `
|
|
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
|
-
|
|
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
|
-
|
|
149
|
-
|
|
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;
|