@wtfalch/auth 0.30.0 → 0.32.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 +131 -0
- package/dist/auth.d.ts +29 -1
- package/dist/auth.js +139 -11
- package/dist/broker.d.ts +6 -0
- package/dist/broker.js +5 -0
- package/dist/browser.d.ts +11 -1
- package/dist/browser.js +17 -1
- package/dist/config.d.ts +34 -4
- package/dist/config.js +1 -0
- package/dist/cookies.d.ts +1 -0
- package/dist/cookies.js +1 -1
- package/dist/desktop.d.ts +199 -0
- package/dist/desktop.js +528 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.js +2 -0
- package/dist/mcp.d.ts +15 -4
- package/dist/mcp.js +24 -10
- package/dist/names.d.ts +5 -3
- package/dist/names.js +15 -5
- package/dist/namespace-browser.js +4 -3
- package/dist/namespace-session.js +2 -2
- package/dist/next.js +1 -1
- package/dist/oidc.d.ts +24 -1
- package/dist/oidc.js +57 -1
- package/dist/onboarding.d.ts +0 -2
- package/dist/onboarding.js +0 -2
- package/dist/person-token.d.ts +44 -10
- package/dist/person-token.js +108 -36
- package/dist/preview.d.ts +24 -0
- package/dist/preview.js +60 -0
- package/dist/qr-door-scene.d.ts +5 -0
- package/dist/qr-door-scene.js +14 -0
- package/dist/qr-door.js +14 -3
- package/dist/redirect.d.ts +1 -0
- package/dist/redirect.js +10 -3
- package/dist/sessions.d.ts +0 -5
- package/dist/sessions.js +0 -60
- package/dist/util.d.ts +14 -0
- package/dist/util.js +16 -0
- package/dist/worker-session.d.ts +14 -0
- package/dist/worker-session.js +29 -16
- package/package.json +4 -4
- package/dist/testing.d.ts +0 -57
- package/dist/testing.js +0 -138
package/README.md
CHANGED
|
@@ -41,6 +41,14 @@ middleware for the paths that need a session, and read the person with
|
|
|
41
41
|
`createAuth` from `@wtfalch/auth` is the same thing without the framework, for
|
|
42
42
|
a CLI or a worker that has no router to import.
|
|
43
43
|
|
|
44
|
+
**Sign-in entry form.** `/auth/start` answers a GET (query) and a same-origin
|
|
45
|
+
form POST (`application/x-www-form-urlencoded`, at most 4096 bytes, length
|
|
46
|
+
declared). Post the form to keep an email `login_hint` out of URLs, access logs
|
|
47
|
+
and `Referer`: `<form method="post" action="/auth/start">` with `next`,
|
|
48
|
+
`intent`, `prompt`, `max_age` and `login_hint` as fields. A POST reads only its
|
|
49
|
+
body, refuses another origin (403), a non-form type (415) and a large or
|
|
50
|
+
undeclared body (413), and a `login_hint` still blocks a silent pass.
|
|
51
|
+
|
|
44
52
|
## One-time passwords
|
|
45
53
|
|
|
46
54
|
An account created with `changeRequired: true` holds a one-time password.
|
|
@@ -103,6 +111,14 @@ already has a query string keeps it. `expired` and `request` carry no `next`:
|
|
|
103
111
|
there is no transaction to read it from. Use it for the retry link
|
|
104
112
|
(`/auth/start?next=<path>`); a page that ignores it behaves as before.
|
|
105
113
|
|
|
114
|
+
**`next` on the error page is attacker-controllable.** The SDK only puts a safe
|
|
115
|
+
path there, but anyone can open `<onError>?next=//evil.test` themselves. The
|
|
116
|
+
error page must run `next` through `safeNextPath` (exported from
|
|
117
|
+
`@wtfalch/auth`) before building a retry link, and never use it raw in a link,
|
|
118
|
+
redirect or `Location`. `next` can also
|
|
119
|
+
carry an invite token: send no `Referer` from the error page
|
|
120
|
+
(`Referrer-Policy: no-referrer`) and keep its query string out of your logs.
|
|
121
|
+
|
|
106
122
|
## In a browser app
|
|
107
123
|
|
|
108
124
|
`@wtfalch/auth/browser` is the other half: a public PKCE client for an app that
|
|
@@ -142,6 +158,21 @@ again. `forget()` sets the same mark, so a sign-out does not undo itself.
|
|
|
142
158
|
`completeSignIn()` at the callback returns `signed-in`, `silent-refused` — the
|
|
143
159
|
answer to a silent attempt, not a failure — or `error`.
|
|
144
160
|
|
|
161
|
+
**Signing out at the issuer.** `forget()` only clears this tab. To end the
|
|
162
|
+
person's session at the issuer too, read the URL first, then forget, then go:
|
|
163
|
+
|
|
164
|
+
```ts
|
|
165
|
+
const url = auth.endSessionUrl('https://app.example.com/signed-out');
|
|
166
|
+
await auth.forget();
|
|
167
|
+
if (url) window.location.assign(url);
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
The SDK keeps the ID token it received at sign-in inside its stored session and
|
|
171
|
+
builds `id_token_hint` itself; it is never on `currentSession()` or any
|
|
172
|
+
outcome. The URL is the issuer's `/oidc/v1/end_session`, and the return address
|
|
173
|
+
must be registered for the client. It is `null` when no ID token is held, and
|
|
174
|
+
always on a namespace client.
|
|
175
|
+
|
|
145
176
|
### Multi-session seams: a non-default opener, storage, refresh and scopes
|
|
146
177
|
|
|
147
178
|
Four options on `BrowserAuthOptions` exist for a client that is not a
|
|
@@ -250,6 +281,42 @@ wiring one in — `createBrowserAuth` throws rather than silently ignoring it.
|
|
|
250
281
|
always reports `no-refresh-token`: the namespace exchange never retains a
|
|
251
282
|
refresh token to begin with.
|
|
252
283
|
|
|
284
|
+
## Native sign-in for a desktop or mobile shell
|
|
285
|
+
|
|
286
|
+
`@wtfalch/auth/desktop` mounts the handoff under `/auth/desktop/*`:
|
|
287
|
+
`createDesktopHandoff({ auth, appUrl, secret, appId, scheme, store, admit })`.
|
|
288
|
+
The shell opens `/auth/desktop/start` in the system browser, the person signs
|
|
289
|
+
in and confirms, and the shell trades its verifier for the session cookie once
|
|
290
|
+
at `/auth/desktop/redeem`.
|
|
291
|
+
|
|
292
|
+
- **Set `admit`.** The default, `alwaysAdmit`, hands the shell a session for
|
|
293
|
+
whoever the issuer signed in, with no further check. It fails open: an app
|
|
294
|
+
that needs an organisation, second-factor or linked-principal check and leaves
|
|
295
|
+
`admit` out gets no warning. Pass the check explicitly.
|
|
296
|
+
- **The store must lock.** `lock(tx, id)` is `SELECT ... FOR UPDATE` in a real
|
|
297
|
+
store. The memory store locks per row; a store that only reads lets two
|
|
298
|
+
concurrent redeems both get the cookie.
|
|
299
|
+
- **Rate-limit `/auth/desktop/start` at the edge too.** The handler caps open
|
|
300
|
+
sign-ins per caller (`maxStartsPerCaller`, default 8, keyed on the address in
|
|
301
|
+
`clientAddressHeader`, default `cf-connecting-ip`) under the store's total
|
|
302
|
+
capacity. Callers with no such header share one bucket.
|
|
303
|
+
- **Log unexpected failures.** The browser sees only `unavailable`; pass
|
|
304
|
+
`onError` to receive `{ step, reason, errorName }` (fixed text, never a
|
|
305
|
+
message, cookie or verifier) and alert on it.
|
|
306
|
+
|
|
307
|
+
## An MCP resource with a path
|
|
308
|
+
|
|
309
|
+
`@wtfalch/auth/mcp` serves several apps on one host, each at its own resource
|
|
310
|
+
URL (`https://host/a/mcp`, `https://host/b/mcp`). Serve each app's metadata at
|
|
311
|
+
`protectedResourceMetadataPath({ issuer, audience, resource })`, which is the
|
|
312
|
+
RFC 9728 path-suffixed form (`/.well-known/oauth-protected-resource/a/mcp`; a
|
|
313
|
+
resource at the root keeps `/.well-known/oauth-protected-resource`), with
|
|
314
|
+
`protectedResourceMetadata(...)` as the body. The path is exact: `/a/mcp` and
|
|
315
|
+
`/a/mcp2` never share one, and a trailing slash is part of it. `mcpChallenge`
|
|
316
|
+
names the same URL. Each app has its own `audience`, and a token issued for one
|
|
317
|
+
is refused (`invalid`) at the other. A stage copy passes its own host's
|
|
318
|
+
`resource`, never the live one.
|
|
319
|
+
|
|
253
320
|
## Configuration
|
|
254
321
|
|
|
255
322
|
| | |
|
|
@@ -309,6 +376,51 @@ Not available in namespace mode. `extraScopes` on `createAuth`'s options
|
|
|
309
376
|
adds to the scope every sign-in and QR device grant asks for, for a
|
|
310
377
|
resource server's own audience scope. See `docs/adopting.md` for both.
|
|
311
378
|
|
|
379
|
+
## Verifying a person's access token
|
|
380
|
+
|
|
381
|
+
`createPersonTokenVerifier` (`@wtfalch/auth/person-token`) is for a server that
|
|
382
|
+
accepts a signed-in person's bearer token. `verify(token)` returns
|
|
383
|
+
`{ sub, organizationId? }` or `null`; `null` covers a bad token and an issuer
|
|
384
|
+
that could not be asked alike. A JWT is checked against the issuer's keys only;
|
|
385
|
+
an opaque token is introspected (RFC 7662).
|
|
386
|
+
|
|
387
|
+
`verifyOutcome(token)` is for a caller that must tell those apart. It also
|
|
388
|
+
introspects a JWT after the signature checks, so a revoked JWT is refused
|
|
389
|
+
before its `exp`. It returns `{ ok: true, identity }` or `{ ok: false, reason }`
|
|
390
|
+
with `reason` one of `invalid`, `expired`, `revoked` or `unavailable`.
|
|
391
|
+
`unavailable` means the issuer could not be asked (network error, non-2xx,
|
|
392
|
+
unreadable answer): answer with a retryable error and do not remember it as a
|
|
393
|
+
refusal. It needs `introspectionClientId` and `introspectionClientSecret`;
|
|
394
|
+
without them every token is `invalid`. A positive introspection answer is
|
|
395
|
+
cached for up to 60 seconds; a refusal or an `unavailable` never is.
|
|
396
|
+
|
|
397
|
+
`organizationId` is present only when the token's organisation claim is a
|
|
398
|
+
non-empty string. An empty or non-string claim is treated as missing and the
|
|
399
|
+
token still verifies.
|
|
400
|
+
|
|
401
|
+
## Back-channel logout
|
|
402
|
+
|
|
403
|
+
In namespace mode, `auth.backchannelLogout` (mounted by `handle` at
|
|
404
|
+
`{basePath}/backchannel-logout`) receives the issuer's own POST of a
|
|
405
|
+
`logout_token` and ends every session of the subject it names. Register that
|
|
406
|
+
URL as the client's back-channel logout URI at the issuer. It takes no
|
|
407
|
+
same-origin check, because the issuer calls it, not a browser.
|
|
408
|
+
|
|
409
|
+
A token is refused with 400 unless it is signed by the issuer's published keys
|
|
410
|
+
for this client, carries the back-channel-logout event, no `nonce`, an `iat`
|
|
411
|
+
no older than two minutes (plus 60 seconds of clock tolerance) and an `exp`.
|
|
412
|
+
There is no `jti` store, so that window is all that limits a replay, and a
|
|
413
|
+
replay only ends sessions again. A body over 16 KiB is refused with 413. Outside
|
|
414
|
+
namespace mode the route answers 404.
|
|
415
|
+
|
|
416
|
+
A token that names only a `sid` (the issuer's own session id) is answered 200
|
|
417
|
+
and ends nothing: the session store is keyed by subject. Configure the issuer
|
|
418
|
+
to send `sub`, or sessions stay alive after logout.
|
|
419
|
+
|
|
420
|
+
Sessions are also re-checked against the issuer at least every 60 seconds
|
|
421
|
+
whatever `freshness` is set to, so a revoked or deactivated person is signed
|
|
422
|
+
out within a minute even if the logout call never arrives.
|
|
423
|
+
|
|
312
424
|
## Passkeys and QR sign-in
|
|
313
425
|
|
|
314
426
|
Passkeys live on each app's own domain: the relying-party ID is the app's
|
|
@@ -321,6 +433,25 @@ A phone that is already signed in can also approve a new device from a QR
|
|
|
321
433
|
code, the issuer's OAuth device grant opted into per namespace binding with
|
|
322
434
|
`qrSignIn: true`, through `startQrSignIn` and `pollQrSignIn`.
|
|
323
435
|
|
|
436
|
+
## Preview routes
|
|
437
|
+
|
|
438
|
+
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.
|
|
439
|
+
|
|
440
|
+
```ts
|
|
441
|
+
import { createPreviewRoutes } from '@wtfalch/auth';
|
|
442
|
+
|
|
443
|
+
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
|
|
447
|
+
});
|
|
448
|
+
// GET /api/foundry/preview-check -> preview.check (200, 401 no session, 403 otherwise)
|
|
449
|
+
// POST /api/foundry/preview -> preview.set (204 and the cookie, same-origin only)
|
|
450
|
+
// DELETE /api/foundry/preview -> preview.clear (204, same-origin only, no permission needed)
|
|
451
|
+
```
|
|
452
|
+
|
|
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.
|
|
454
|
+
|
|
324
455
|
## Documentation
|
|
325
456
|
|
|
326
457
|
`docs/adopting.md` in [wtfalch/auth](https://github.com/wtfalch/auth) covers a
|
package/dist/auth.d.ts
CHANGED
|
@@ -208,6 +208,14 @@ export interface Auth {
|
|
|
208
208
|
complete(callbackUrl: string, cookieHeader: string | null, requestHeaders?: Headers): Promise<Completed>;
|
|
209
209
|
/** POST {basePath}/logout */
|
|
210
210
|
logout(request: Request): Promise<Response>;
|
|
211
|
+
/**
|
|
212
|
+
* POST {basePath}/backchannel-logout: the issuer's own call, not the
|
|
213
|
+
* browser's. Needs namespace mode -- a plain cookie session holds no
|
|
214
|
+
* server-side record this can end. Throws if namespace mode is not
|
|
215
|
+
* configured; use the OIDC back-channel logout registration to point only
|
|
216
|
+
* namespace-mode deployments at it.
|
|
217
|
+
*/
|
|
218
|
+
backchannelLogout(request: Request): Promise<Response>;
|
|
211
219
|
/** GET {basePath}/verify?userId=&code= */
|
|
212
220
|
verify(request: Request): Promise<Response>;
|
|
213
221
|
/** GET {basePath}/link?sessionId=&code= : the sign-in link from an email, on any device. */
|
|
@@ -308,6 +316,25 @@ export interface Auth {
|
|
|
308
316
|
}): Promise<{
|
|
309
317
|
userId: string;
|
|
310
318
|
}>;
|
|
319
|
+
/**
|
|
320
|
+
* Replaces the developer's own session with one for a test user, `userId`
|
|
321
|
+
* -- act-as, refused everywhere but a stage or laptop entry (see
|
|
322
|
+
* `App.actAs` in the broker). Runs its own OIDC transaction, exactly as
|
|
323
|
+
* `resetPassword` does, so there is no `authRequestId` to receive.
|
|
324
|
+
*
|
|
325
|
+
* `actorId` is the developer's own id -- not resolved from a request here,
|
|
326
|
+
* because the caller already knows who is signed in -- and travels into
|
|
327
|
+
* the `act_as.started` event `onActAs` writes once the broker has made the
|
|
328
|
+
* session and finished the auth request. That write is required and
|
|
329
|
+
* awaited: unlike `onAuthEvent`, a throw or a rejection from `onActAs`
|
|
330
|
+
* fails this call, because an admin override nobody's audit trail caught
|
|
331
|
+
* is worse than one refused outright.
|
|
332
|
+
*/
|
|
333
|
+
actAs(input: {
|
|
334
|
+
actorId: string;
|
|
335
|
+
userId: string;
|
|
336
|
+
next?: string;
|
|
337
|
+
}, requestHeaders?: Headers): Promise<Completed>;
|
|
311
338
|
handle(request: Request): Promise<Response>;
|
|
312
339
|
/**
|
|
313
340
|
* `refresh: false` for callers that cannot set cookies; they get a user only while the token is valid.
|
|
@@ -336,7 +363,8 @@ export interface Auth {
|
|
|
336
363
|
/**
|
|
337
364
|
* Where to send somebody when a sign-in step failed for a reason they
|
|
338
365
|
* cannot fix by retrying it automatically: `onError` with `?auth_error=`,
|
|
339
|
-
* which is the
|
|
366
|
+
* which is the shape the callback's own failures use. Unlike a callback
|
|
367
|
+
* failure that had a transaction, it carries no `next`. Exposed because
|
|
340
368
|
* the Next adapter has to reach it from a server component, where a
|
|
341
369
|
* `Response` is no use.
|
|
342
370
|
*/
|
package/dist/auth.js
CHANGED
|
@@ -5,8 +5,8 @@ import { clearQrTransaction, clearSession, clearTransaction, cookieFrom, openQrT
|
|
|
5
5
|
import { namespaceSessions } from './namespace-session.js';
|
|
6
6
|
import { AuthError, ORG_CLAIM, Oidc, isOAuthRefusal, rejected, scopeFor, } from './oidc.js';
|
|
7
7
|
import { pollDeviceToken, requestDeviceAuthorization } from './qr.js';
|
|
8
|
-
import { safeLoginHint, safeNextPath } from './redirect.js';
|
|
9
|
-
import { expiryOf, issuedAtOf, timeoutSignal } from './util.js';
|
|
8
|
+
import { isSameOrigin, safeLoginHint, safeNextPath } from './redirect.js';
|
|
9
|
+
import { expiryOf, issuedAtOf, recheckSeconds, timeoutSignal } from './util.js';
|
|
10
10
|
export function createAuth(input) {
|
|
11
11
|
// Resolved on first use so `next build`, which imports every route module, needs none of the values set.
|
|
12
12
|
let resolved = null;
|
|
@@ -128,12 +128,36 @@ export function createAuth(input) {
|
|
|
128
128
|
return false;
|
|
129
129
|
return Date.now() / 1000 - user.authTime <= maxAge + CLOCK_TOLERANCE;
|
|
130
130
|
};
|
|
131
|
+
/**
|
|
132
|
+
* `/start` takes its parameters from the query on a GET and from a form-encoded
|
|
133
|
+
* body on a POST (the entry form posts, so an email hint stays out of URLs, logs
|
|
134
|
+
* and `Referer`). A POST ignores the query; it must come from this app's own
|
|
135
|
+
* origin, declare a small length and be a form body.
|
|
136
|
+
*/
|
|
137
|
+
const startParams = async (request) => {
|
|
138
|
+
if (request.method !== 'POST')
|
|
139
|
+
return new URL(request.url).searchParams;
|
|
140
|
+
if (!isSameOrigin(request, options().appUrl.origin))
|
|
141
|
+
return new Response('Forbidden', { status: 403 });
|
|
142
|
+
const type = request.headers.get('content-type')?.split(';')[0]?.trim().toLowerCase();
|
|
143
|
+
if (type !== 'application/x-www-form-urlencoded')
|
|
144
|
+
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)
|
|
147
|
+
return new Response('Payload Too Large', { status: 413 });
|
|
148
|
+
const text = await request.text();
|
|
149
|
+
if (text.length > START_BODY_MAX)
|
|
150
|
+
return new Response('Payload Too Large', { status: 413 });
|
|
151
|
+
return new URLSearchParams(text);
|
|
152
|
+
};
|
|
131
153
|
const start = async (request) => {
|
|
132
154
|
if (!isNavigation(request))
|
|
133
155
|
return new Response('Unauthorized', { status: 401 });
|
|
134
156
|
if (options().namespace && !(await sessions.bindingActive()))
|
|
135
157
|
return unavailable();
|
|
136
|
-
const params =
|
|
158
|
+
const params = await startParams(request);
|
|
159
|
+
if (params instanceof Response)
|
|
160
|
+
return params;
|
|
137
161
|
const next = safeNextPath(params.get('next'), options().afterLogin);
|
|
138
162
|
// Only the gate can ask for a silent pass: its sealed marker is the sole way
|
|
139
163
|
// in, and the query's own `prompt` never reaches `none`. An explicit choice
|
|
@@ -326,7 +350,7 @@ export function createAuth(input) {
|
|
|
326
350
|
const route = pathname.startsWith(`${options().basePath}/`)
|
|
327
351
|
? pathname.slice(options().basePath.length + 1)
|
|
328
352
|
: null;
|
|
329
|
-
if (route === 'start' && request.method === 'GET')
|
|
353
|
+
if (route === 'start' && (request.method === 'GET' || request.method === 'POST'))
|
|
330
354
|
return start(request);
|
|
331
355
|
if (route === 'callback' && request.method === 'GET')
|
|
332
356
|
return callback(request);
|
|
@@ -336,6 +360,8 @@ export function createAuth(input) {
|
|
|
336
360
|
return link(request);
|
|
337
361
|
if (route === 'logout' && request.method === 'POST')
|
|
338
362
|
return logout(request);
|
|
363
|
+
if (route === 'backchannel-logout' && request.method === 'POST')
|
|
364
|
+
return backchannelLogout(request);
|
|
339
365
|
return new Response('Not found', { status: 404 });
|
|
340
366
|
};
|
|
341
367
|
const readCookie = async (value, { refresh = true } = {}) => {
|
|
@@ -354,7 +380,7 @@ export function createAuth(input) {
|
|
|
354
380
|
const exp = expiryOf(session.idt);
|
|
355
381
|
const iat = issuedAtOf(session.idt);
|
|
356
382
|
const valid = exp !== null && exp > now - 60;
|
|
357
|
-
const stale = iat !== null && now - iat >= options().freshness;
|
|
383
|
+
const stale = iat !== null && now - iat >= recheckSeconds(options().freshness);
|
|
358
384
|
const due = exp === null || exp - options().refreshWindow <= now || stale;
|
|
359
385
|
if (due && refresh && session.rt) {
|
|
360
386
|
try {
|
|
@@ -461,6 +487,47 @@ export function createAuth(input) {
|
|
|
461
487
|
throw new Error('@wtfalch/auth: revokeSessions needs namespace sessions');
|
|
462
488
|
return sessions.revokeSubject(userId);
|
|
463
489
|
};
|
|
490
|
+
/**
|
|
491
|
+
* POST {basePath}/backchannel-logout: the issuer's own back-channel call,
|
|
492
|
+
* never the browser's -- so, unlike `logout`, this takes no same-origin
|
|
493
|
+
* check. A logout token names a session by `sub` and/or `sid`; this store
|
|
494
|
+
* keys a record by subject, not by the issuer's session id, so a `sid`-only
|
|
495
|
+
* token is accepted (200, per the spec: a token this app cannot act on is
|
|
496
|
+
* not a token it may reject) but ends nothing. Every failure path answers
|
|
497
|
+
* with no cookie and nothing about which case it was, since the caller is
|
|
498
|
+
* the issuer, not the person the token names.
|
|
499
|
+
*/
|
|
500
|
+
const backchannelLogout = async (request) => {
|
|
501
|
+
if (!options().namespace)
|
|
502
|
+
return new Response('Not found', { status: 404, headers: NO_STORE });
|
|
503
|
+
if (request.method !== 'POST')
|
|
504
|
+
return new Response('Method not allowed', { status: 405, headers: NO_STORE });
|
|
505
|
+
let token;
|
|
506
|
+
try {
|
|
507
|
+
const body = await readBounded(request, BACKCHANNEL_LOGOUT_MAX_BODY_BYTES);
|
|
508
|
+
if (body === null) {
|
|
509
|
+
return backchannelLogoutError('invalid_request', 'the request body is too large', 413);
|
|
510
|
+
}
|
|
511
|
+
token = new URLSearchParams(body).get('logout_token');
|
|
512
|
+
}
|
|
513
|
+
catch {
|
|
514
|
+
return backchannelLogoutError('invalid_request', 'the request body could not be read');
|
|
515
|
+
}
|
|
516
|
+
if (!token)
|
|
517
|
+
return backchannelLogoutError('invalid_request', 'missing logout_token');
|
|
518
|
+
let claims;
|
|
519
|
+
try {
|
|
520
|
+
claims = await oidc().verifyLogoutToken(token);
|
|
521
|
+
}
|
|
522
|
+
catch (error) {
|
|
523
|
+
if (rejected(error))
|
|
524
|
+
return backchannelLogoutError('invalid_token', 'the logout token was refused');
|
|
525
|
+
throw error;
|
|
526
|
+
}
|
|
527
|
+
if (claims.sub)
|
|
528
|
+
await sessions.revokeSubject(claims.sub);
|
|
529
|
+
return new Response(null, { status: 200, headers: NO_STORE });
|
|
530
|
+
};
|
|
464
531
|
const listSessions = async (request) => {
|
|
465
532
|
if (!options().namespace)
|
|
466
533
|
throw new Error('@wtfalch/auth: sessions needs namespace sessions');
|
|
@@ -564,6 +631,30 @@ export function createAuth(input) {
|
|
|
564
631
|
emit({ type: 'createTestUser', email: input.email, userId: result.userId });
|
|
565
632
|
return result;
|
|
566
633
|
};
|
|
634
|
+
/**
|
|
635
|
+
* Runs its own transaction, as `resetPassword` does. No hosted-login
|
|
636
|
+
* handoff: the broker's `/act-as` either finishes the auth request
|
|
637
|
+
* directly or refuses outright, so there is nothing for `finishOrHandoff`
|
|
638
|
+
* to catch here.
|
|
639
|
+
*/
|
|
640
|
+
const actAs = async ({ actorId, userId, next }, requestHeaders) => {
|
|
641
|
+
const onActAs = options().onActAs;
|
|
642
|
+
if (!onActAs)
|
|
643
|
+
throw new Error('@wtfalch/auth: onActAs is required to use actAs');
|
|
644
|
+
const flow = await serverFlow();
|
|
645
|
+
const callbackUrl = await broker().actAs({ authRequestId: flow.id, actorId, userId });
|
|
646
|
+
// Audited only once `finish` has made the session: a failed `finish` leaves no
|
|
647
|
+
// `act_as.started` for an attempt the caller sees as failed, and a retry logs once.
|
|
648
|
+
const completed = await finish(callbackUrl, flow, safeNextPath(next, options().afterLogin), requestHeaders);
|
|
649
|
+
await onActAs({
|
|
650
|
+
type: 'act_as.started',
|
|
651
|
+
actor: actorId,
|
|
652
|
+
target: userId,
|
|
653
|
+
client: options().clientId,
|
|
654
|
+
time: Date.now(),
|
|
655
|
+
});
|
|
656
|
+
return completed;
|
|
657
|
+
};
|
|
567
658
|
/**
|
|
568
659
|
* An admin's reset for somebody in this app's own organisation whom
|
|
569
660
|
* `requestPasswordReset` cannot otherwise get back in -- nobody at the
|
|
@@ -945,6 +1036,7 @@ export function createAuth(input) {
|
|
|
945
1036
|
callback,
|
|
946
1037
|
complete,
|
|
947
1038
|
logout,
|
|
1039
|
+
backchannelLogout,
|
|
948
1040
|
verify,
|
|
949
1041
|
link,
|
|
950
1042
|
followLink,
|
|
@@ -954,6 +1046,7 @@ export function createAuth(input) {
|
|
|
954
1046
|
sendLink,
|
|
955
1047
|
invite,
|
|
956
1048
|
createTestUser,
|
|
1049
|
+
actAs,
|
|
957
1050
|
handle,
|
|
958
1051
|
read,
|
|
959
1052
|
readCookie,
|
|
@@ -994,6 +1087,45 @@ function redirect(location, cookies, status = 302) {
|
|
|
994
1087
|
headers.append('Set-Cookie', cookie.header);
|
|
995
1088
|
return new Response(null, { status, headers });
|
|
996
1089
|
}
|
|
1090
|
+
const NO_STORE = { 'Cache-Control': 'no-store' };
|
|
1091
|
+
/** A logout token is a few hundred bytes; this is generous and still small. */
|
|
1092
|
+
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
|
+
/** A back-channel logout endpoint's 400 error body, per OpenID Connect Back-Channel Logout 1.0 §2.6. */
|
|
1123
|
+
function backchannelLogoutError(error, description, status = 400) {
|
|
1124
|
+
return new Response(JSON.stringify({ error, error_description: description }), {
|
|
1125
|
+
status,
|
|
1126
|
+
headers: { 'content-type': 'application/json', ...NO_STORE },
|
|
1127
|
+
});
|
|
1128
|
+
}
|
|
997
1129
|
function matches(rule, pathname) {
|
|
998
1130
|
if (!rule)
|
|
999
1131
|
return false;
|
|
@@ -1070,18 +1202,14 @@ function userFrom(claims) {
|
|
|
1070
1202
|
};
|
|
1071
1203
|
}
|
|
1072
1204
|
// The token itself is bad, as opposed to the keys being unreachable or rotating.
|
|
1205
|
+
/** The most a `/start` form body may hold: a path, a hint and three short fields. */
|
|
1206
|
+
const START_BODY_MAX = 4096;
|
|
1073
1207
|
function isNavigation(request) {
|
|
1074
1208
|
const mode = request.headers.get('sec-fetch-mode');
|
|
1075
1209
|
if (mode && mode !== 'navigate')
|
|
1076
1210
|
return false;
|
|
1077
1211
|
return !request.headers.has('rsc') && !request.headers.has('next-action');
|
|
1078
1212
|
}
|
|
1079
|
-
function isSameOrigin(request, origin) {
|
|
1080
|
-
const from = request.headers.get('origin');
|
|
1081
|
-
if (from)
|
|
1082
|
-
return from === origin;
|
|
1083
|
-
return request.headers.get('sec-fetch-site') === 'same-origin';
|
|
1084
|
-
}
|
|
1085
1213
|
// Never says whether the address exists, so neither can the app.
|
|
1086
1214
|
async function quietly(act) {
|
|
1087
1215
|
try {
|
package/dist/broker.d.ts
CHANGED
|
@@ -146,6 +146,12 @@ export declare class Broker {
|
|
|
146
146
|
}): Promise<{
|
|
147
147
|
userId: string;
|
|
148
148
|
}>;
|
|
149
|
+
/** The service half of `Auth['actAs']`: a session for the target, finishing `authRequestId`. */
|
|
150
|
+
actAs(input: {
|
|
151
|
+
authRequestId: string;
|
|
152
|
+
actorId: string;
|
|
153
|
+
userId: string;
|
|
154
|
+
}): Promise<string>;
|
|
149
155
|
sendReset(email: string, next: string): Promise<unknown>;
|
|
150
156
|
sendVerification(email: string, next: string): Promise<unknown>;
|
|
151
157
|
/**
|
package/dist/broker.js
CHANGED
|
@@ -137,6 +137,11 @@ export class Broker {
|
|
|
137
137
|
createTestUser(input) {
|
|
138
138
|
return this.call('POST', '/test-users', input);
|
|
139
139
|
}
|
|
140
|
+
/** The service half of `Auth['actAs']`: a session for the target, finishing `authRequestId`. */
|
|
141
|
+
async actAs(input) {
|
|
142
|
+
const { callbackUrl } = await this.call('POST', '/act-as', input);
|
|
143
|
+
return callbackUrl;
|
|
144
|
+
}
|
|
140
145
|
sendReset(email, next) {
|
|
141
146
|
return this.call('POST', '/reset/send', { email, next });
|
|
142
147
|
}
|
package/dist/browser.d.ts
CHANGED
|
@@ -72,7 +72,7 @@ export interface BrowserAuthOptions {
|
|
|
72
72
|
* origin `/auth` belongs to Stalwart and never reaches the app.
|
|
73
73
|
*/
|
|
74
74
|
callbackPath?: string;
|
|
75
|
-
/** Scopes to ask for. `openid` is required; the rest is the app's business. */
|
|
75
|
+
/** Scopes to ask for. `openid` is required (`createBrowserAuth` throws without it); the rest is the app's business. */
|
|
76
76
|
scope?: string;
|
|
77
77
|
/**
|
|
78
78
|
* Extra scopes appended to `scope` at `/authorize`, de-duplicated with it —
|
|
@@ -176,6 +176,16 @@ export interface BrowserAuth {
|
|
|
176
176
|
* to the other's now-spent refresh token.
|
|
177
177
|
*/
|
|
178
178
|
refresh(): Promise<RefreshOutcome>;
|
|
179
|
+
/**
|
|
180
|
+
* The issuer's sign-out URL carrying `id_token_hint` and
|
|
181
|
+
* `post_logout_redirect_uri`, built from the ID token this tab received at
|
|
182
|
+
* sign-in, or null when none is held (a session from before this existed, a
|
|
183
|
+
* namespace client, or after `forget()`). The token itself never leaves the
|
|
184
|
+
* SDK. Call it before `forget()`, which drops it; then send the browser there.
|
|
185
|
+
* The URL is the issuer's fixed `/oidc/v1/end_session`; `postLogoutRedirectUri`
|
|
186
|
+
* must be registered for the client at the issuer.
|
|
187
|
+
*/
|
|
188
|
+
endSessionUrl(postLogoutRedirectUri: string): string | null;
|
|
179
189
|
/** Drop the session, and do not silently take another in this tab. Awaits
|
|
180
190
|
* the store's writes and invalidates pending sign-in/refresh completions.
|
|
181
191
|
* After it resolves, an older write cannot restore the forgotten session. */
|
package/dist/browser.js
CHANGED
|
@@ -49,13 +49,17 @@ function verifier() {
|
|
|
49
49
|
return base64url(crypto.getRandomValues(new Uint8Array(32)).buffer);
|
|
50
50
|
}
|
|
51
51
|
function publicSession(session) {
|
|
52
|
-
const { refreshToken, ...rest } = session;
|
|
52
|
+
const { refreshToken, idToken, ...rest } = session;
|
|
53
53
|
return rest;
|
|
54
54
|
}
|
|
55
55
|
// Instances using the same store and context share cancellation and refreshes.
|
|
56
56
|
// The network is outside this queue: forget must not wait for a token response.
|
|
57
57
|
const coordinators = new WeakMap();
|
|
58
58
|
export function createBrowserAuth(options) {
|
|
59
|
+
// The nonce check reads the ID token, and the issuer sends one only for `openid`.
|
|
60
|
+
// Without it every sign-in would be refused after the code exchange, so refuse here.
|
|
61
|
+
if (!(options.scope ?? 'openid').split(/\s+/).includes('openid'))
|
|
62
|
+
throw new Error('createBrowserAuth: scope must include openid');
|
|
59
63
|
if (options.namespace) {
|
|
60
64
|
// The namespace path coordinates a shared session across tabs and
|
|
61
65
|
// clients through fixed `sessionStorage`/`localStorage` keys (see
|
|
@@ -232,6 +236,8 @@ export function createBrowserAuth(options) {
|
|
|
232
236
|
accessToken: token.access_token,
|
|
233
237
|
expiresAt: Date.now() + (token.expires_in ?? 3600) * 1000,
|
|
234
238
|
refreshToken: token.refresh_token,
|
|
239
|
+
// A refresh response may carry a new ID token; otherwise the one from sign-in stays.
|
|
240
|
+
idToken: token.id_token ?? session.idToken,
|
|
235
241
|
};
|
|
236
242
|
await store.set(tokenKey, JSON.stringify(next));
|
|
237
243
|
if (!current(generation))
|
|
@@ -242,6 +248,15 @@ export function createBrowserAuth(options) {
|
|
|
242
248
|
return {
|
|
243
249
|
accountChanged: () => false,
|
|
244
250
|
currentSession,
|
|
251
|
+
endSessionUrl(postLogoutRedirectUri) {
|
|
252
|
+
const idToken = readStored()?.idToken;
|
|
253
|
+
if (!idToken)
|
|
254
|
+
return null;
|
|
255
|
+
const url = new URL(`${options.issuer.replace(/\/$/, '')}/oidc/v1/end_session`);
|
|
256
|
+
url.searchParams.set('id_token_hint', idToken);
|
|
257
|
+
url.searchParams.set('post_logout_redirect_uri', postLogoutRedirectUri);
|
|
258
|
+
return url.toString();
|
|
259
|
+
},
|
|
245
260
|
silentSignInAvailable,
|
|
246
261
|
async trySilentSignIn(returnTo = window.location.pathname) {
|
|
247
262
|
if (!silentSignInAvailable())
|
|
@@ -344,6 +359,7 @@ export function createBrowserAuth(options) {
|
|
|
344
359
|
accessToken: token.access_token,
|
|
345
360
|
expiresAt: Date.now() + (token.expires_in ?? 3600) * 1000,
|
|
346
361
|
refreshToken: token.refresh_token,
|
|
362
|
+
idToken: token.id_token,
|
|
347
363
|
};
|
|
348
364
|
await store.set(tokenKey, JSON.stringify(session));
|
|
349
365
|
if (!current(generation))
|
package/dist/config.d.ts
CHANGED
|
@@ -32,6 +32,20 @@ export type AuthEvent =
|
|
|
32
32
|
email: string;
|
|
33
33
|
userId: string;
|
|
34
34
|
};
|
|
35
|
+
/**
|
|
36
|
+
* `actAs`'s own audit record: who asked (`actor`), who they viewed the app
|
|
37
|
+
* as (`target`), which client the session was opened for, and when. Unlike
|
|
38
|
+
* `AuthEvent`, this has exactly one variant and one writer (`onActAs`),
|
|
39
|
+
* because act-as is the one call here that must never go unrecorded -- see
|
|
40
|
+
* `onActAs`'s own doc comment for what that changes.
|
|
41
|
+
*/
|
|
42
|
+
export interface ActAsEvent {
|
|
43
|
+
type: 'act_as.started';
|
|
44
|
+
actor: string;
|
|
45
|
+
target: string;
|
|
46
|
+
client: string;
|
|
47
|
+
time: number;
|
|
48
|
+
}
|
|
35
49
|
/**
|
|
36
50
|
* Why `accessToken` refused: no session (no cookie in cookie mode, no live
|
|
37
51
|
* record behind the namespace handle in namespace mode), a session with no
|
|
@@ -70,11 +84,11 @@ export interface AuthOptions {
|
|
|
70
84
|
* `appKey`; unused without it. Every replica of the app must share one.
|
|
71
85
|
*/
|
|
72
86
|
sessionStore?: SessionStore;
|
|
73
|
-
/** This app's origin, e.g. https://portal.
|
|
87
|
+
/** This app's origin, e.g. https://portal.example.test. Every URL the SDK builds starts here, never from the request's Host. */
|
|
74
88
|
appUrl: string | undefined;
|
|
75
|
-
/** From
|
|
89
|
+
/** From the stack's provisioned.json. */
|
|
76
90
|
clientId: string | undefined;
|
|
77
|
-
/** From
|
|
91
|
+
/** From the stack's provisioned.json. Sent as a scope and checked on every token. */
|
|
78
92
|
organizationId: string | undefined;
|
|
79
93
|
/**
|
|
80
94
|
* Extra scopes appended to `scopeFor`'s fixed set, on both the
|
|
@@ -111,7 +125,12 @@ export interface AuthOptions {
|
|
|
111
125
|
afterLogin?: string;
|
|
112
126
|
/** Default /. Must be a registered post-logout URI. */
|
|
113
127
|
afterLogout?: string;
|
|
114
|
-
/**
|
|
128
|
+
/**
|
|
129
|
+
* Where a failed callback lands, with `?auth_error=<reason>`, and `&next=<path>`
|
|
130
|
+
* when the failed sign-in had a transaction. `next` is a same-origin path, but anyone
|
|
131
|
+
* can open this page with a `next` of their own: run it through `safeNextPath` before
|
|
132
|
+
* building a link. Default /.
|
|
133
|
+
*/
|
|
115
134
|
onError?: string;
|
|
116
135
|
/** Where a verified email link lands. Default /. */
|
|
117
136
|
afterVerify?: string;
|
|
@@ -137,6 +156,16 @@ export interface AuthOptions {
|
|
|
137
156
|
* the call it rode in on.
|
|
138
157
|
*/
|
|
139
158
|
onAuthEvent?: (event: AuthEvent) => void;
|
|
159
|
+
/**
|
|
160
|
+
* Required to call `actAs`. Unlike `onAuthEvent`, this IS awaited, and a
|
|
161
|
+
* throw or a rejection fails the `actAs` call -- the whole point of
|
|
162
|
+
* act-as is that it never happens off the record, so a session nobody's
|
|
163
|
+
* audit trail caught must not go live silently. The broker has already
|
|
164
|
+
* made the session and finished the auth request by the time this runs;
|
|
165
|
+
* a failure here cannot undo that, only stop the SDK from handing the
|
|
166
|
+
* browser the result.
|
|
167
|
+
*/
|
|
168
|
+
onActAs?: (event: ActAsEvent) => Promise<void>;
|
|
140
169
|
}
|
|
141
170
|
export interface ResolvedOptions {
|
|
142
171
|
namespace: ReturnType<typeof resolveNamespaceContext> | null;
|
|
@@ -174,6 +203,7 @@ export interface ResolvedOptions {
|
|
|
174
203
|
secure: boolean;
|
|
175
204
|
fetch: typeof fetch;
|
|
176
205
|
onAuthEvent: ((event: AuthEvent) => void) | null;
|
|
206
|
+
onActAs: ((event: ActAsEvent) => Promise<void>) | null;
|
|
177
207
|
}
|
|
178
208
|
export declare function resolveOptions(options: AuthOptions): ResolvedOptions;
|
|
179
209
|
/**
|
package/dist/config.js
CHANGED
package/dist/cookies.d.ts
CHANGED
|
@@ -78,6 +78,7 @@ export declare function linkCookieName(options: ResolvedOptions): string;
|
|
|
78
78
|
export declare function sealLink(options: ResolvedOptions, payload: Omit<LinkPayload, keyof JWTPayload>): Promise<SetCookie>;
|
|
79
79
|
export declare function openLink(options: ResolvedOptions, value: string | undefined): Promise<LinkPayload | null>;
|
|
80
80
|
export declare function clearLink(options: ResolvedOptions): SetCookie;
|
|
81
|
+
export declare function setCookie(name: string, value: string, maxAge: number, secure: boolean): SetCookie;
|
|
81
82
|
export declare function cookieFrom(header: string | null, name: string): string | undefined;
|
|
82
83
|
/** The Cookie header with these cookies replaced or removed, for a proxy to forward. */
|
|
83
84
|
export declare function withCookies(header: string | null, updates: SetCookie[]): string;
|
package/dist/cookies.js
CHANGED
|
@@ -186,7 +186,7 @@ async function open(options, kind, value) {
|
|
|
186
186
|
return null;
|
|
187
187
|
}
|
|
188
188
|
}
|
|
189
|
-
function setCookie(name, value, maxAge, secure) {
|
|
189
|
+
export function setCookie(name, value, maxAge, secure) {
|
|
190
190
|
const parts = [`${name}=${value}`, 'Path=/', 'HttpOnly', 'SameSite=Lax'];
|
|
191
191
|
if (maxAge > 0) {
|
|
192
192
|
parts.push(`Max-Age=${maxAge}`);
|