@wtfalch/auth 0.31.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 +19 -0
- package/dist/auth.js +1 -7
- package/dist/cookies.d.ts +1 -0
- package/dist/cookies.js +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/preview.d.ts +24 -0
- package/dist/preview.js +60 -0
- package/dist/redirect.d.ts +1 -0
- package/dist/redirect.js +6 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -433,6 +433,25 @@ A phone that is already signed in can also approve a new device from a QR
|
|
|
433
433
|
code, the issuer's OAuth device grant opted into per namespace binding with
|
|
434
434
|
`qrSignIn: true`, through `startQrSignIn` and `pollQrSignIn`.
|
|
435
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
|
+
|
|
436
455
|
## Documentation
|
|
437
456
|
|
|
438
457
|
`docs/adopting.md` in [wtfalch/auth](https://github.com/wtfalch/auth) covers a
|
package/dist/auth.js
CHANGED
|
@@ -5,7 +5,7 @@ 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';
|
|
8
|
+
import { isSameOrigin, safeLoginHint, safeNextPath } from './redirect.js';
|
|
9
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.
|
|
@@ -1210,12 +1210,6 @@ function isNavigation(request) {
|
|
|
1210
1210
|
return false;
|
|
1211
1211
|
return !request.headers.has('rsc') && !request.headers.has('next-action');
|
|
1212
1212
|
}
|
|
1213
|
-
function isSameOrigin(request, origin) {
|
|
1214
|
-
const from = request.headers.get('origin');
|
|
1215
|
-
if (from)
|
|
1216
|
-
return from === origin;
|
|
1217
|
-
return request.headers.get('sec-fetch-site') === 'same-origin';
|
|
1218
|
-
}
|
|
1219
1213
|
// Never says whether the address exists, so neither can the app.
|
|
1220
1214
|
async function quietly(act) {
|
|
1221
1215
|
try {
|
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}`);
|
package/dist/index.d.ts
CHANGED
|
@@ -7,3 +7,4 @@ export type { PersonIdentity, PersonTokenConfig, PersonTokenOutcome, PersonToken
|
|
|
7
7
|
export { BrokerError, type PeoplePage, type Person } from './broker.js';
|
|
8
8
|
export { AuthError, type AuthErrorReason, ORG_CLAIM } from './oidc.js';
|
|
9
9
|
export { safeLoginHint, safeNextPath } from './redirect.js';
|
|
10
|
+
export { PREVIEW_CHECK_PATH, PREVIEW_COOKIE, PREVIEW_PATH, type PreviewRoutes, type PreviewRoutesOptions, createPreviewRoutes, } from './preview.js';
|
package/dist/index.js
CHANGED
|
@@ -3,3 +3,4 @@ export { DesktopHandoffError, } from './desktop.js';
|
|
|
3
3
|
export { BrokerError } from './broker.js';
|
|
4
4
|
export { AuthError, ORG_CLAIM } from './oidc.js';
|
|
5
5
|
export { safeLoginHint, safeNextPath } from './redirect.js';
|
|
6
|
+
export { PREVIEW_CHECK_PATH, PREVIEW_COOKIE, PREVIEW_PATH, createPreviewRoutes, } from './preview.js';
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/** The cookie that selects the idle slot at the balancer. */
|
|
2
|
+
export declare const PREVIEW_COOKIE = "bg_preview";
|
|
3
|
+
/** Where the balancer's forwardAuth asks whether the request may see the idle slot. */
|
|
4
|
+
export declare const PREVIEW_CHECK_PATH = "/api/foundry/preview-check";
|
|
5
|
+
/** Where the app sets (POST) and clears (DELETE) the preview cookie. */
|
|
6
|
+
export declare const PREVIEW_PATH = "/api/foundry/preview";
|
|
7
|
+
export interface PreviewRoutesOptions<S> {
|
|
8
|
+
/** The app's own session reader (for example `auth.read`). Null means signed out. */
|
|
9
|
+
readSession: (request: Request) => Promise<S | null>;
|
|
10
|
+
/** Whether this session holds `releases:preview` in the app's production authz. */
|
|
11
|
+
mayPreview: (session: S) => Promise<boolean>;
|
|
12
|
+
/** The app's own origin, the one `appUrl` names. Required: the trusted origin never comes from the request. */
|
|
13
|
+
appOrigin: string;
|
|
14
|
+
}
|
|
15
|
+
export interface PreviewRoutes {
|
|
16
|
+
/** GET `PREVIEW_CHECK_PATH`: 200 allowed, 401 no session, 403 otherwise. */
|
|
17
|
+
check: (request: Request) => Promise<Response>;
|
|
18
|
+
/** POST `PREVIEW_PATH`: same-origin, same check, then sets the cookie (204). */
|
|
19
|
+
set: (request: Request) => Promise<Response>;
|
|
20
|
+
/** DELETE `PREVIEW_PATH`: same-origin, clears the cookie (204). */
|
|
21
|
+
clear: (request: Request) => Promise<Response>;
|
|
22
|
+
}
|
|
23
|
+
/** Route handlers for the idle preview: the check the balancer calls, and the cookie the person sets and clears. */
|
|
24
|
+
export declare function createPreviewRoutes<S>(options: PreviewRoutesOptions<S>): PreviewRoutes;
|
package/dist/preview.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { setCookie } from './cookies.js';
|
|
2
|
+
import { isSameOrigin } from './redirect.js';
|
|
3
|
+
/** The cookie that selects the idle slot at the balancer. */
|
|
4
|
+
export const PREVIEW_COOKIE = 'bg_preview';
|
|
5
|
+
/** Where the balancer's forwardAuth asks whether the request may see the idle slot. */
|
|
6
|
+
export const PREVIEW_CHECK_PATH = '/api/foundry/preview-check';
|
|
7
|
+
/** Where the app sets (POST) and clears (DELETE) the preview cookie. */
|
|
8
|
+
export const PREVIEW_PATH = '/api/foundry/preview';
|
|
9
|
+
/** Eight hours. */
|
|
10
|
+
const PREVIEW_MAX_AGE = 28_800;
|
|
11
|
+
const NO_STORE = { 'cache-control': 'no-store' };
|
|
12
|
+
function reply(status, extra = {}) {
|
|
13
|
+
return new Response(null, { status, headers: { ...NO_STORE, ...extra } });
|
|
14
|
+
}
|
|
15
|
+
/** Route handlers for the idle preview: the check the balancer calls, and the cookie the person sets and clears. */
|
|
16
|
+
export function createPreviewRoutes(options) {
|
|
17
|
+
// 200 when allowed; otherwise the refusing status. A thrown reader or
|
|
18
|
+
// permission check is a refusal, logged by error name only so no session
|
|
19
|
+
// data or message reaches the log.
|
|
20
|
+
const decide = async (request) => {
|
|
21
|
+
try {
|
|
22
|
+
const session = await options.readSession(request);
|
|
23
|
+
if (session === null)
|
|
24
|
+
return 401;
|
|
25
|
+
return (await options.mayPreview(session)) ? 200 : 403;
|
|
26
|
+
}
|
|
27
|
+
catch (error) {
|
|
28
|
+
console.error(`@wtfalch/auth: preview check failed (${error instanceof Error ? error.name : 'Error'})`);
|
|
29
|
+
return 403;
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
// Normalised once so a malformed value throws at setup, and case or a
|
|
33
|
+
// trailing slash cannot change `secure` or the same-origin compare.
|
|
34
|
+
const appOrigin = new URL(options.appOrigin).origin;
|
|
35
|
+
const secure = appOrigin.startsWith('https:');
|
|
36
|
+
const sameOrigin = (request) => {
|
|
37
|
+
if (isSameOrigin(request, appOrigin))
|
|
38
|
+
return true;
|
|
39
|
+
console.error('@wtfalch/auth: preview request refused (cross-origin)');
|
|
40
|
+
return false;
|
|
41
|
+
};
|
|
42
|
+
return {
|
|
43
|
+
check: async (request) => reply(await decide(request)),
|
|
44
|
+
set: async (request) => {
|
|
45
|
+
if (!sameOrigin(request))
|
|
46
|
+
return reply(403);
|
|
47
|
+
const status = await decide(request);
|
|
48
|
+
if (status !== 200)
|
|
49
|
+
return reply(status);
|
|
50
|
+
const cookie = setCookie(PREVIEW_COOKIE, '1', PREVIEW_MAX_AGE, secure);
|
|
51
|
+
return reply(204, { 'set-cookie': cookie.header });
|
|
52
|
+
},
|
|
53
|
+
clear: async (request) => {
|
|
54
|
+
if (!sameOrigin(request))
|
|
55
|
+
return reply(403);
|
|
56
|
+
const cookie = setCookie(PREVIEW_COOKIE, '', 0, secure);
|
|
57
|
+
return reply(204, { 'set-cookie': cookie.header });
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
}
|
package/dist/redirect.d.ts
CHANGED
|
@@ -7,3 +7,4 @@ export declare function safeNextPath(next: string | null | undefined, fallback:
|
|
|
7
7
|
* because the person can still type their address at the issuer.
|
|
8
8
|
*/
|
|
9
9
|
export declare function safeLoginHint(raw: string | null | undefined): string | undefined;
|
|
10
|
+
export declare function isSameOrigin(request: Request, origin: string): boolean;
|
package/dist/redirect.js
CHANGED
|
@@ -33,3 +33,9 @@ export function safeLoginHint(raw) {
|
|
|
33
33
|
return undefined;
|
|
34
34
|
return /^[^\s@\p{Cc}]+@[^\s@\p{Cc}]+\.[^\s@\p{Cc}]+$/u.test(raw) ? raw : undefined;
|
|
35
35
|
}
|
|
36
|
+
export function isSameOrigin(request, origin) {
|
|
37
|
+
const from = request.headers.get('origin');
|
|
38
|
+
if (from)
|
|
39
|
+
return from === origin;
|
|
40
|
+
return request.headers.get('sec-fetch-site') === 'same-origin';
|
|
41
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wtfalch/auth",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.32.0",
|
|
4
4
|
"description": "Sign in against a self-hosted ZITADEL issuer: a server session for a Next.js app, and a browser client with silent single sign-on across subdomains.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|