@wtfalch/auth 0.28.0 → 0.29.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 CHANGED
@@ -13,12 +13,57 @@ export interface McpOptions {
13
13
  /** Test seam. */
14
14
  fetch?: typeof fetch;
15
15
  }
16
- export interface McpTokenClaims {
16
+ /** What `verifyMcpToken` needs besides the issuer, audience and resource. */
17
+ export interface McpVerifyOptions extends McpOptions {
18
+ /** The ZITADEL organisation id of this app's customers. A token from any other organisation is refused. */
19
+ expectedOrgId: string;
20
+ /** The broker's `POST /mcp/token-info` (ADR 0002), asked about every token that passes the signature check. */
21
+ tokenInfo: {
22
+ /** The full URL, e.g. `https://auth.example.com/mcp/token-info`. */
23
+ url: string;
24
+ /** This app's broker key (`AUTH_APP_KEY`). */
25
+ credential: string;
26
+ /** How long a live answer is trusted, in milliseconds. Default and maximum 60 000. */
27
+ cacheMs?: number;
28
+ /** How long to wait for the broker, in milliseconds. Default and maximum 5 000; past it the answer is `unavailable`. */
29
+ timeoutMs?: number;
30
+ };
31
+ }
32
+ /** Who is behind a verified MCP token and which client holds it. */
33
+ export interface McpIdentity {
17
34
  userId: string;
35
+ /** The token's `jti`. */
36
+ tokenId: string;
37
+ orgId: string;
38
+ scope: string;
39
+ /** The MCP client's id (a metadata URL or a registered id), as the broker records it. */
40
+ clientId: string;
41
+ clientName: string;
42
+ /** The refresh family (the person's grant for this client), which Disconnect ends. */
43
+ familyId: string;
44
+ /** Epoch milliseconds. */
45
+ expiresAt: number;
18
46
  }
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>;
47
+ export type McpDenyReason = 'invalid' | 'expired' | 'revoked' | 'wrong_org' | 'wrong_scope' | 'unavailable';
48
+ /** An identity, or null with the reason. The reason is for the app's own audit log; clients get a 401 either way. */
49
+ export type McpVerification = {
50
+ identity: McpIdentity;
51
+ reason: null;
52
+ } | {
53
+ identity: null;
54
+ reason: McpDenyReason;
55
+ };
56
+ /** For tests: forgets every cached core and token-info answer. */
57
+ export declare function resetMcpCaches(): void;
58
+ /**
59
+ * Who is behind a bearer token for this resource, or why not. Checks the
60
+ * signature, issuer, audience and expiry locally, then asks the broker whether
61
+ * the grant behind the token is still live (so Disconnect works within
62
+ * `cacheMs`, not at the token's expiry), then that the grant belongs to this
63
+ * app's organisation and carries `mcp:read`. When the broker cannot be asked
64
+ * the answer is `unavailable`, never an identity. Never throws.
65
+ */
66
+ export declare function verifyMcpToken(req: Request, opts: McpVerifyOptions): Promise<McpVerification>;
22
67
  /** The 401 or 403 the app returns when `verifyMcpToken` gives null (401) or
23
68
  * the user may not do the thing (403). */
24
69
  export declare function mcpChallenge(opts: McpOptions, status: 401 | 403): Response;
package/dist/mcp.js CHANGED
@@ -1,38 +1,147 @@
1
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();
2
+ /** One person-token core per (issuer, audience), so discovery and the JWKS are
3
+ * fetched once and cached, not once per request. The fetch function is not
4
+ * part of the key: an app that builds its options (and a timeout fetch) per
5
+ * request still shares the cache. The first fetch seen is the one kept. */
6
+ const cores = new Map();
7
+ const MAX_CORES = 32;
6
8
  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
9
  const key = JSON.stringify([opts.issuer, opts.audience]);
14
- let core = byKey.get(key);
10
+ let core = cores.get(key);
15
11
  if (!core) {
12
+ // Issuer and audience come from the app's config; a cap keeps a misconfigured per-request value from growing this.
13
+ if (cores.size >= MAX_CORES)
14
+ cores.clear();
16
15
  core = createPersonTokenCore({
17
16
  issuer: opts.issuer,
18
17
  audience: opts.audience,
19
18
  fetch: opts.fetch,
20
19
  });
21
- byKey.set(key, core);
20
+ cores.set(key, core);
22
21
  }
23
22
  return core;
24
23
  }
24
+ /** Live token-info answers, by token. In memory only; bounded; positive answers only. */
25
+ const live = new Map();
26
+ const MAX_LIVE = 1000;
27
+ const CACHE_MS_MAX = 60_000;
28
+ const TOKEN_INFO_TIMEOUT_MS = 5_000;
29
+ const REQUIRED_SCOPE = 'mcp:read';
30
+ const RESOURCE_OWNER = 'urn:zitadel:iam:user:resourceowner:id';
31
+ /** For tests: forgets every cached core and token-info answer. */
32
+ export function resetMcpCaches() {
33
+ cores.clear();
34
+ live.clear();
35
+ }
25
36
  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. */
37
+ const deny = (reason) => ({ identity: null, reason });
38
+ const str = (value) => (typeof value === 'string' && value ? value : null);
39
+ /** The broker's answer, checked field by field; null for anything that is not a well-formed active answer. */
40
+ function parseInfo(body) {
41
+ if (!body || typeof body !== 'object')
42
+ return null;
43
+ const b = body;
44
+ const userId = str(b.sub);
45
+ const tokenId = str(b.jti);
46
+ const orgId = str(b.orgId);
47
+ const scope = str(b.scope);
48
+ const clientId = str(b.clientId);
49
+ const clientName = str(b.clientName);
50
+ const familyId = str(b.familyId);
51
+ if (!userId ||
52
+ !tokenId ||
53
+ !orgId ||
54
+ !scope ||
55
+ !clientId ||
56
+ !clientName ||
57
+ !familyId ||
58
+ typeof b.exp !== 'number' ||
59
+ !Number.isFinite(b.exp)) {
60
+ return null;
61
+ }
62
+ return { userId, tokenId, orgId, scope, clientId, clientName, familyId, expiresAt: b.exp * 1000 };
63
+ }
64
+ async function askBroker(token, opts) {
65
+ const fetchImpl = opts.fetch ?? fetch;
66
+ try {
67
+ const response = await fetchImpl(opts.tokenInfo.url, {
68
+ method: 'POST',
69
+ headers: {
70
+ 'content-type': 'application/json',
71
+ authorization: `Bearer ${opts.tokenInfo.credential}`,
72
+ },
73
+ body: JSON.stringify({ token }),
74
+ signal: AbortSignal.timeout(Math.min(Math.max(opts.tokenInfo.timeoutMs ?? TOKEN_INFO_TIMEOUT_MS, 1), TOKEN_INFO_TIMEOUT_MS)),
75
+ });
76
+ // A refused key, a 5xx and a malformed body are all "could not check".
77
+ if (!response.ok)
78
+ return { kind: 'down' };
79
+ const body = (await response.json());
80
+ if (body.active === false)
81
+ return { kind: 'inactive' };
82
+ if (body.active !== true)
83
+ return { kind: 'down' };
84
+ const identity = parseInfo(body);
85
+ return identity ? { kind: 'active', identity } : { kind: 'down' };
86
+ }
87
+ catch {
88
+ return { kind: 'down' };
89
+ }
90
+ }
91
+ /**
92
+ * Who is behind a bearer token for this resource, or why not. Checks the
93
+ * signature, issuer, audience and expiry locally, then asks the broker whether
94
+ * the grant behind the token is still live (so Disconnect works within
95
+ * `cacheMs`, not at the token's expiry), then that the grant belongs to this
96
+ * app's organisation and carries `mcp:read`. When the broker cannot be asked
97
+ * the answer is `unavailable`, never an identity. Never throws.
98
+ */
28
99
  export async function verifyMcpToken(req, opts) {
29
100
  const match = BEARER.exec(req.headers.get('authorization') ?? '');
30
101
  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 };
102
+ return deny('invalid');
103
+ const token = match[1];
104
+ const outcome = await coreFor(opts).verifyJwtOutcome(token);
105
+ if (!outcome.ok)
106
+ return deny(outcome.reason);
107
+ const { payload } = outcome;
108
+ if (typeof payload.sub !== 'string' || !payload.sub)
109
+ return deny('invalid');
110
+ // ZITADEL puts the owner in the token when it was asked to; when it did, it must be ours.
111
+ const owner = payload[RESOURCE_OWNER];
112
+ if (owner !== undefined && owner !== opts.expectedOrgId)
113
+ return deny('wrong_org');
114
+ const key = `${opts.tokenInfo.url}\n${token}`;
115
+ const now = Date.now();
116
+ let identity = null;
117
+ const cached = live.get(key);
118
+ if (cached && cached.until > now && cached.identity.expiresAt > now) {
119
+ identity = cached.identity;
120
+ }
121
+ else {
122
+ live.delete(key);
123
+ const asked = await askBroker(token, opts);
124
+ if (asked.kind === 'inactive')
125
+ return deny('revoked');
126
+ if (asked.kind === 'down')
127
+ return deny('unavailable');
128
+ identity = asked.identity;
129
+ const cacheMs = Math.min(Math.max(opts.tokenInfo.cacheMs ?? CACHE_MS_MAX, 0), CACHE_MS_MAX);
130
+ if (cacheMs > 0) {
131
+ if (live.size >= MAX_LIVE)
132
+ live.delete(live.keys().next().value);
133
+ live.set(key, { identity, until: now + cacheMs });
134
+ }
135
+ }
136
+ if (identity.userId !== payload.sub)
137
+ return deny('invalid');
138
+ if (identity.expiresAt <= now)
139
+ return deny('expired');
140
+ if (identity.orgId !== opts.expectedOrgId)
141
+ return deny('wrong_org');
142
+ if (!identity.scope.split(' ').includes(REQUIRED_SCOPE))
143
+ return deny('wrong_scope');
144
+ return { identity, reason: null };
36
145
  }
37
146
  function metadataUrl(opts) {
38
147
  const url = new URL(opts.resource);
@@ -64,7 +64,21 @@ export interface PersonTokenConfig {
64
64
  export interface PersonTokenCore extends PersonTokenVerifier {
65
65
  /** JWT path only, no introspection: the verified claims, or null. */
66
66
  verifyJwtClaims(accessToken: string): Promise<JWTPayload | null>;
67
+ /**
68
+ * Like `verifyJwtClaims`, but says why a token failed: `expired` for a
69
+ * well-signed token past its `exp`, `unavailable` when the issuer's keys
70
+ * could not be fetched, `invalid` for everything else.
71
+ */
72
+ verifyJwtOutcome(accessToken: string): Promise<JwtOutcome>;
67
73
  }
68
74
  /** @internal */
75
+ export type JwtOutcome = {
76
+ ok: true;
77
+ payload: JWTPayload;
78
+ } | {
79
+ ok: false;
80
+ reason: 'invalid' | 'expired' | 'unavailable';
81
+ };
82
+ /** @internal */
69
83
  export declare function createPersonTokenCore(config: PersonTokenConfig): PersonTokenCore;
70
84
  export declare function createPersonTokenVerifier(config: PersonTokenConfig): PersonTokenVerifier;
@@ -102,9 +102,16 @@ export function createPersonTokenCore(config) {
102
102
  });
103
103
  return jwks;
104
104
  };
105
- async function verifyJwt(accessToken) {
105
+ async function verifyJwtOutcome(accessToken) {
106
+ let keyset;
107
+ try {
108
+ keyset = await keys();
109
+ }
110
+ catch {
111
+ // Discovery could not be fetched or is wrong: no key can be trusted, so no token is.
112
+ return { ok: false, reason: 'unavailable' };
113
+ }
106
114
  try {
107
- const keyset = await keys();
108
115
  const { payload } = await jwtVerify(accessToken, keyset, {
109
116
  issuer: config.issuer,
110
117
  audience: config.audience,
@@ -112,12 +119,23 @@ export function createPersonTokenCore(config) {
112
119
  clockTolerance: 60,
113
120
  requiredClaims: ['exp', 'sub'],
114
121
  });
115
- return payload;
122
+ return { ok: true, payload };
116
123
  }
117
- catch {
118
- return null;
124
+ catch (error) {
125
+ const code = error?.code;
126
+ if (code === 'ERR_JWT_EXPIRED')
127
+ return { ok: false, reason: 'expired' };
128
+ // A key fetch that failed or timed out is the issuer being down, not the token being bad.
129
+ if (code === 'ERR_JWKS_TIMEOUT' || typeof code !== 'string') {
130
+ return { ok: false, reason: 'unavailable' };
131
+ }
132
+ return { ok: false, reason: 'invalid' };
119
133
  }
120
134
  }
135
+ async function verifyJwt(accessToken) {
136
+ const outcome = await verifyJwtOutcome(accessToken);
137
+ return outcome.ok ? outcome.payload : null;
138
+ }
121
139
  /**
122
140
  * RFC 7662 token introspection for an opaque access token: POSTs
123
141
  * `token=<accessToken>` to the issuer's `introspection_endpoint`,
@@ -186,6 +204,11 @@ export function createPersonTokenCore(config) {
186
204
  async verifyJwtClaims(accessToken) {
187
205
  return JWT_SHAPE.test(accessToken) ? verifyJwt(accessToken) : null;
188
206
  },
207
+ async verifyJwtOutcome(accessToken) {
208
+ return JWT_SHAPE.test(accessToken)
209
+ ? verifyJwtOutcome(accessToken)
210
+ : { ok: false, reason: 'invalid' };
211
+ },
189
212
  async verify(accessToken) {
190
213
  const payload = JWT_SHAPE.test(accessToken)
191
214
  ? await verifyJwt(accessToken)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wtfalch/auth",
3
- "version": "0.28.0",
3
+ "version": "0.29.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",