@wtfalch/auth 0.25.0 → 0.26.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/dist/mcp.d.ts ADDED
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Resource side of MCP authorization for an org app (ADR 0002, section 4).
3
+ * Only `jose` and `fetch`: this entry runs on workerd, so nothing here may
4
+ * reach a `node:` module.
5
+ */
6
+ export interface McpOptions {
7
+ /** The bound login host, e.g. `https://login.candorfx.com`. */
8
+ issuer: string;
9
+ /** The ZITADEL project id of the MCP project (env `MCP_AUDIENCE`). */
10
+ audience: string;
11
+ /** The MCP URL clients send as `resource` (env `MCP_RESOURCE`). */
12
+ resource: string;
13
+ /** Test seam. */
14
+ fetch?: typeof fetch;
15
+ }
16
+ export interface McpTokenClaims {
17
+ userId: string;
18
+ }
19
+ /** Claims for a valid bearer token for this resource, otherwise null. Never
20
+ * throws on a bad token; says nothing about why. */
21
+ export declare function verifyMcpToken(req: Request, opts: McpOptions): Promise<McpTokenClaims | null>;
22
+ /** The 401 or 403 the app returns when `verifyMcpToken` gives null (401) or
23
+ * the user may not do the thing (403). */
24
+ export declare function mcpChallenge(opts: McpOptions, status: 401 | 403): Response;
25
+ /** The document the app serves at `/.well-known/oauth-protected-resource` (and
26
+ * the path form). */
27
+ export declare function protectedResourceMetadata(opts: McpOptions): {
28
+ resource: string;
29
+ authorization_servers: string[];
30
+ };
package/dist/mcp.js ADDED
@@ -0,0 +1,55 @@
1
+ import { createPersonTokenCore } from './person-token.js';
2
+ /** One person-token core per (fetch, issuer, audience), so discovery and the
3
+ * JWKS are fetched once and cached, not once per request. Weakly keyed by the
4
+ * fetch function so a test seam never leaks across tests. */
5
+ const cores = new WeakMap();
6
+ function coreFor(opts) {
7
+ const fetchImpl = opts.fetch ?? fetch;
8
+ let byKey = cores.get(fetchImpl);
9
+ if (!byKey) {
10
+ byKey = new Map();
11
+ cores.set(fetchImpl, byKey);
12
+ }
13
+ const key = JSON.stringify([opts.issuer, opts.audience]);
14
+ let core = byKey.get(key);
15
+ if (!core) {
16
+ core = createPersonTokenCore({
17
+ issuer: opts.issuer,
18
+ audience: opts.audience,
19
+ fetch: opts.fetch,
20
+ });
21
+ byKey.set(key, core);
22
+ }
23
+ return core;
24
+ }
25
+ const BEARER = /^Bearer ([^\s]+)$/i;
26
+ /** Claims for a valid bearer token for this resource, otherwise null. Never
27
+ * throws on a bad token; says nothing about why. */
28
+ export async function verifyMcpToken(req, opts) {
29
+ const match = BEARER.exec(req.headers.get('authorization') ?? '');
30
+ if (!match?.[1])
31
+ return null;
32
+ const payload = await coreFor(opts).verifyJwtClaims(match[1]);
33
+ if (!payload || typeof payload.sub !== 'string' || !payload.sub)
34
+ return null;
35
+ return { userId: payload.sub };
36
+ }
37
+ function metadataUrl(opts) {
38
+ const url = new URL(opts.resource);
39
+ const path = url.pathname === '/' ? '' : url.pathname;
40
+ return `${url.origin}/.well-known/oauth-protected-resource${path}`;
41
+ }
42
+ /** The 401 or 403 the app returns when `verifyMcpToken` gives null (401) or
43
+ * the user may not do the thing (403). */
44
+ export function mcpChallenge(opts, status) {
45
+ const resourceMetadata = `resource_metadata="${metadataUrl(opts)}"`;
46
+ const challenge = status === 403
47
+ ? `Bearer error="insufficient_scope", ${resourceMetadata}`
48
+ : `Bearer ${resourceMetadata}`;
49
+ return new Response(null, { status, headers: { 'WWW-Authenticate': challenge } });
50
+ }
51
+ /** The document the app serves at `/.well-known/oauth-protected-resource` (and
52
+ * the path form). */
53
+ export function protectedResourceMetadata(opts) {
54
+ return { resource: opts.resource, authorization_servers: [opts.issuer] };
55
+ }
@@ -1,3 +1,4 @@
1
+ import { type JWTPayload } from 'jose';
1
2
  /**
2
3
  * Verifies a signed-in person's own `@wtfalch/auth`/ZITADEL access token,
3
4
  * server-side, for a service that wants to accept one directly rather than
@@ -58,4 +59,12 @@ export interface PersonTokenConfig {
58
59
  * real cooldown. */
59
60
  jwksCooldownMs?: number;
60
61
  }
62
+ /** @internal Shared by `createPersonTokenVerifier` and `./mcp`: one discovery
63
+ * document and one JWKS cache per core. Not part of the public contract. */
64
+ export interface PersonTokenCore extends PersonTokenVerifier {
65
+ /** JWT path only, no introspection: the verified claims, or null. */
66
+ verifyJwtClaims(accessToken: string): Promise<JWTPayload | null>;
67
+ }
68
+ /** @internal */
69
+ export declare function createPersonTokenCore(config: PersonTokenConfig): PersonTokenCore;
61
70
  export declare function createPersonTokenVerifier(config: PersonTokenConfig): PersonTokenVerifier;
@@ -77,7 +77,8 @@ class PositiveIntrospectionCache {
77
77
  this.entries.set(token, { payload, expiresAt });
78
78
  }
79
79
  }
80
- export function createPersonTokenVerifier(config) {
80
+ /** @internal */
81
+ export function createPersonTokenCore(config) {
81
82
  const fetchImpl = config.fetch ?? fetch;
82
83
  let discovery = null;
83
84
  let jwks = null;
@@ -182,6 +183,9 @@ export function createPersonTokenVerifier(config) {
182
183
  return payload;
183
184
  }
184
185
  return {
186
+ async verifyJwtClaims(accessToken) {
187
+ return JWT_SHAPE.test(accessToken) ? verifyJwt(accessToken) : null;
188
+ },
185
189
  async verify(accessToken) {
186
190
  const payload = JWT_SHAPE.test(accessToken)
187
191
  ? await verifyJwt(accessToken)
@@ -192,3 +196,7 @@ export function createPersonTokenVerifier(config) {
192
196
  },
193
197
  };
194
198
  }
199
+ export function createPersonTokenVerifier(config) {
200
+ const { verify } = createPersonTokenCore(config);
201
+ return { verify };
202
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * The same OIDC sign-in the Next apps use, for a Cloudflare Worker: Request in,
3
+ * Response out, no `next`, no `node:`. It is `createAuth` with the app's origin
4
+ * taken from each request, so the session cookie (id token + refresh token,
5
+ * never an access token), the PKCE and state transaction, the organisation
6
+ * check and the refresh rules are the Next path's own code, not a copy.
7
+ */
8
+ export interface WorkerSessionOptions {
9
+ /** The org's login host, e.g. `https://login.candorfx.com`. */
10
+ issuer: string;
11
+ /** The entry's client id. */
12
+ clientId: string;
13
+ /** The org's ZITADEL organisation id. A person from any other organisation is refused. */
14
+ orgId: string;
15
+ /** Where the issuer sends the browser back, e.g. `/auth/callback`. Must end in `/callback` and have a prefix; the app routes it to `callback`. */
16
+ redirectPath: string;
17
+ /** 32 bytes, base64 or hex. Never logged. */
18
+ cookieSecret: string;
19
+ /** Test seam. */
20
+ fetch?: typeof fetch;
21
+ }
22
+ export interface WorkerSignedIn {
23
+ userId: string;
24
+ orgId: string;
25
+ /**
26
+ * `Set-Cookie` header values to add to the app's response. Empty unless the
27
+ * session was refreshed on this request. The refresh token is single use:
28
+ * an app that drops these signs the person out at the next refresh.
29
+ */
30
+ setCookies: string[];
31
+ }
32
+ export interface WorkerSession {
33
+ /** Redirects to the issuer. `register` opens registration, `returnTo` is a path on this app. */
34
+ signIn(req: Request, o?: {
35
+ register?: boolean;
36
+ returnTo?: string;
37
+ }): Promise<Response>;
38
+ /** Route `redirectPath` here. Always a redirect: to `returnTo` on success, to `/?auth_error=<reason>` otherwise. */
39
+ callback(req: Request): Promise<Response>;
40
+ currentUser(req: Request): Promise<WorkerSignedIn | null>;
41
+ /** POST from the app's own origin only. */
42
+ signOut(req: Request): Promise<Response>;
43
+ }
44
+ export declare function createWorkerSession(opts: WorkerSessionOptions): WorkerSession;
@@ -0,0 +1,106 @@
1
+ import { createAuth } from './auth.js';
2
+ import { resolveOptions } from './config.js';
3
+ import { FETCH_TIMEOUT_MS } from './util.js';
4
+ const CALLBACK = /^(\/[A-Za-z0-9._~-]+)+\/callback$/;
5
+ /** The fetch the SDK is given: every outside call (discovery, token, JWKS, revoke) is abandoned after FETCH_TIMEOUT_MS unless the caller set its own signal. */
6
+ function withTimeout(fetchImpl) {
7
+ return (input, init) => fetchImpl(input, { ...init, signal: init?.signal ?? AbortSignal.timeout(FETCH_TIMEOUT_MS) });
8
+ }
9
+ export function createWorkerSession(opts) {
10
+ for (const name of ['issuer', 'clientId', 'orgId', 'redirectPath', 'cookieSecret']) {
11
+ if (typeof opts[name] !== 'string' || !opts[name]) {
12
+ throw new Error(`@wtfalch/auth: ${name} is required`);
13
+ }
14
+ }
15
+ if (!CALLBACK.test(opts.redirectPath)) {
16
+ throw new Error('@wtfalch/auth: redirectPath must be a path ending in /callback, like /auth/callback');
17
+ }
18
+ let issuerUrl;
19
+ try {
20
+ issuerUrl = new URL(opts.issuer);
21
+ }
22
+ catch {
23
+ throw new Error('@wtfalch/auth: issuer must be an absolute URL');
24
+ }
25
+ if (issuerUrl.protocol !== 'https:' && issuerUrl.hostname !== 'localhost') {
26
+ throw new Error('@wtfalch/auth: issuer must be https');
27
+ }
28
+ const basePath = opts.redirectPath.slice(0, -'/callback'.length);
29
+ const fetchImpl = withTimeout(opts.fetch ?? globalThis.fetch);
30
+ const config = (appUrl) => ({
31
+ appUrl,
32
+ clientId: opts.clientId,
33
+ organizationId: opts.orgId,
34
+ issuer: opts.issuer,
35
+ cookieSecret: opts.cookieSecret,
36
+ basePath,
37
+ fetch: fetchImpl,
38
+ });
39
+ // A bad secret or client is a startup error, not the first visitor's.
40
+ resolveOptions(config('https://placeholder.invalid'));
41
+ // One Auth per origin, so the discovery document, JWKS and refresh dedupe are shared across requests in an isolate.
42
+ const byOrigin = new Map();
43
+ const authFor = (req) => {
44
+ const origin = new URL(req.url).origin;
45
+ let auth = byOrigin.get(origin);
46
+ if (!auth) {
47
+ auth = createAuth(config(origin));
48
+ byOrigin.set(origin, auth);
49
+ }
50
+ return auth;
51
+ };
52
+ // An outside failure is logged by its class only: messages can carry URLs and token fragments.
53
+ const unavailable = (what, error) => {
54
+ console.error(`@wtfalch/auth: worker session ${what} failed`, error instanceof Error ? error.name : 'unknown');
55
+ return new Response('Sign-in is unavailable', { status: 503 });
56
+ };
57
+ return {
58
+ async signIn(req, o = {}) {
59
+ const url = new URL(`${basePath}/start`, req.url);
60
+ if (o.register)
61
+ url.searchParams.set('intent', 'register');
62
+ if (o.returnTo !== undefined)
63
+ url.searchParams.set('next', o.returnTo);
64
+ try {
65
+ return await authFor(req).start(new Request(url, { headers: req.headers }));
66
+ }
67
+ catch (error) {
68
+ return unavailable('start', error);
69
+ }
70
+ },
71
+ async callback(req) {
72
+ try {
73
+ return await authFor(req).callback(req);
74
+ }
75
+ catch (error) {
76
+ return unavailable('callback', error);
77
+ }
78
+ },
79
+ async currentUser(req) {
80
+ try {
81
+ const { user, cookies } = await authFor(req).read(req);
82
+ if (!user)
83
+ return null;
84
+ return {
85
+ userId: user.id,
86
+ orgId: user.organizationId,
87
+ setCookies: cookies.map((c) => c.header),
88
+ };
89
+ }
90
+ catch (error) {
91
+ unavailable('session read', error);
92
+ return null;
93
+ }
94
+ },
95
+ async signOut(req) {
96
+ if (req.method !== 'POST')
97
+ return new Response('Method not allowed', { status: 405 });
98
+ try {
99
+ return await authFor(req).logout(req);
100
+ }
101
+ catch (error) {
102
+ return unavailable('sign-out', error);
103
+ }
104
+ },
105
+ };
106
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wtfalch/auth",
3
- "version": "0.25.0",
3
+ "version": "0.26.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",
@@ -46,6 +46,14 @@
46
46
  "types": "./dist/person-token.d.ts",
47
47
  "default": "./dist/person-token.js"
48
48
  },
49
+ "./mcp": {
50
+ "types": "./dist/mcp.d.ts",
51
+ "default": "./dist/mcp.js"
52
+ },
53
+ "./worker-session": {
54
+ "types": "./dist/worker-session.d.ts",
55
+ "default": "./dist/worker-session.js"
56
+ },
49
57
  "./qr-door": {
50
58
  "types": "./dist/qr-door.d.ts",
51
59
  "default": "./dist/qr-door.js"
@@ -64,6 +72,7 @@
64
72
  "prepack": "pnpm build",
65
73
  "typecheck": "tsc --noEmit",
66
74
  "test": "vitest run",
75
+ "test:workerd": "vitest run --config vitest.workerd.config.ts",
67
76
  "test:cookie-browser": "vitest run --config vitest.cookie-browser.config.ts"
68
77
  },
69
78
  "dependencies": {
@@ -87,6 +96,7 @@
87
96
  }
88
97
  },
89
98
  "devDependencies": {
99
+ "@cloudflare/vitest-pool-workers": "0.23.0",
90
100
  "@electric-sql/pglite": "^0.5.8",
91
101
  "@types/node": "^22",
92
102
  "@types/qrcode": "^1.5.6",