mcp-server-bitbucket 0.11.0 → 0.19.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/Dockerfile +9 -1
- package/dist/index.d.ts +16 -0
- package/dist/index.js +906 -139
- package/package.json +14 -12
- package/scripts/check-ca-bundles.cjs +27 -0
- package/smithery.yaml +10 -4
- package/src/auth.ts +330 -0
- package/src/client.ts +253 -123
- package/src/http-input.ts +36 -0
- package/src/index.ts +274 -13
- package/src/oauth-proxy.ts +298 -0
- package/src/prompts.ts +1 -1
- package/src/settings.ts +51 -10
- package/src/tools/commits.ts +65 -33
- package/src/tools/index.ts +0 -3
- package/src/tools/pipelines.ts +41 -0
- package/src/tools/source.ts +23 -0
- package/src/tools/tags.ts +21 -6
- package/src/types.ts +9 -0
- package/tests/auth.test.ts +277 -0
- package/tests/history.test.ts +284 -0
- package/tests/http-handler.test.ts +67 -0
- package/tests/http-input.test.ts +40 -0
- package/tests/network-resilience.test.ts +113 -0
- package/tests/oauth-proxy.test.ts +286 -0
- package/tests/settings.test.ts +110 -0
- package/tests/stdio.test.ts +20 -0
- package/tests/test-remote.mjs +22 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mcp-server-bitbucket",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.19.0",
|
|
4
4
|
"description": "MCP server for Bitbucket API operations",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -37,21 +37,23 @@
|
|
|
37
37
|
"url": "https://github.com/JaviMaligno/mcp-server-bitbucket/issues"
|
|
38
38
|
},
|
|
39
39
|
"engines": {
|
|
40
|
-
"node": "
|
|
40
|
+
"node": "^22.23.4 || ^24.22.1"
|
|
41
41
|
},
|
|
42
42
|
"dependencies": {
|
|
43
|
-
"@modelcontextprotocol/sdk": "^1.
|
|
44
|
-
"axios": "^1.
|
|
45
|
-
"
|
|
43
|
+
"@modelcontextprotocol/sdk": "^1.32.1",
|
|
44
|
+
"axios": "^1.20.0",
|
|
45
|
+
"jose": "^6.2.9",
|
|
46
|
+
"zod": "^3.25.76"
|
|
46
47
|
},
|
|
47
48
|
"devDependencies": {
|
|
48
|
-
"@types/node": "^
|
|
49
|
-
"
|
|
50
|
-
"typescript": "^
|
|
51
|
-
"vitest": "^2.0.0",
|
|
49
|
+
"@types/node": "^24.0.0",
|
|
50
|
+
"@typescript-eslint/eslint-plugin": "^8.72.0",
|
|
51
|
+
"@typescript-eslint/parser": "^8.72.0",
|
|
52
52
|
"eslint": "^8.57.0",
|
|
53
|
-
"
|
|
54
|
-
"
|
|
53
|
+
"tsup": "8.5.0",
|
|
54
|
+
"typescript": "^5.4.0",
|
|
55
|
+
"vitest": "^4.1.12",
|
|
56
|
+
"vite": "^8.3.5",
|
|
57
|
+
"esbuild": "^0.28.1"
|
|
55
58
|
}
|
|
56
59
|
}
|
|
57
|
-
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Image-build gate: verify the final Node binary and its public trust stores.
|
|
2
|
+
const assert = require('node:assert/strict');
|
|
3
|
+
const { X509Certificate } = require('node:crypto');
|
|
4
|
+
const { readFileSync } = require('node:fs');
|
|
5
|
+
const { rootCertificates } = require('node:tls');
|
|
6
|
+
|
|
7
|
+
const compromised = '86a1ecba089c4a8d3bbe2734c612ba341d813e043cf9e8a862cd5c57a36bbe6b';
|
|
8
|
+
const floors = { 22: [23, 4], 24: [22, 1], 26: [12, 1] };
|
|
9
|
+
const [major, minor, patch] = process.versions.node.split('.').map(Number);
|
|
10
|
+
const floor = floors[major];
|
|
11
|
+
assert(floor && (minor > floor[0] || (minor === floor[0] && patch >= floor[1])),
|
|
12
|
+
`Unpatched or unsupported Node runtime: ${process.versions.node}`);
|
|
13
|
+
const pem = readFileSync('/etc/ssl/certs/ca-certificates.crt', 'utf8');
|
|
14
|
+
const bundles = {
|
|
15
|
+
node: rootCertificates,
|
|
16
|
+
os: pem.match(/-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g) || [],
|
|
17
|
+
};
|
|
18
|
+
const stores = {};
|
|
19
|
+
for (const [name, certificates] of Object.entries(bundles)) {
|
|
20
|
+
assert(certificates.length > 0, `Empty ${name} trust store`);
|
|
21
|
+
for (const certificate of certificates) {
|
|
22
|
+
const fingerprint = new X509Certificate(certificate).fingerprint256.replaceAll(':', '').toLowerCase();
|
|
23
|
+
assert.notEqual(fingerprint, compromised, `Compromised CA present in ${name}`);
|
|
24
|
+
}
|
|
25
|
+
stores[name] = { certificates: certificates.length, compromisedRootPresent: false };
|
|
26
|
+
}
|
|
27
|
+
console.log(JSON.stringify({ node: process.versions.node, stores }));
|
package/smithery.yaml
CHANGED
|
@@ -12,10 +12,16 @@ startCommand:
|
|
|
12
12
|
description: Bitbucket workspace slug (e.g., 'my-team')
|
|
13
13
|
BITBUCKET_EMAIL:
|
|
14
14
|
type: string
|
|
15
|
-
description: Account email for Bitbucket Basic Auth
|
|
15
|
+
description: Account email for Bitbucket Basic Auth (required for basic auth)
|
|
16
16
|
BITBUCKET_API_TOKEN:
|
|
17
17
|
type: string
|
|
18
|
-
description:
|
|
18
|
+
description: Atlassian API token used with Basic auth
|
|
19
|
+
BITBUCKET_OAUTH_TOKEN:
|
|
20
|
+
type: string
|
|
21
|
+
description: Workspace/project/repository access token sent as `Authorization: Bearer`
|
|
22
|
+
BITBUCKET_AUTH_TYPE:
|
|
23
|
+
type: string
|
|
24
|
+
description: Force auth mode - `basic` or `bearer` (auto-detected when unset)
|
|
19
25
|
API_TIMEOUT:
|
|
20
26
|
type: number
|
|
21
27
|
default: 30
|
|
@@ -26,8 +32,6 @@ startCommand:
|
|
|
26
32
|
description: Max retry attempts for rate limiting (max 10)
|
|
27
33
|
required:
|
|
28
34
|
- BITBUCKET_WORKSPACE
|
|
29
|
-
- BITBUCKET_EMAIL
|
|
30
|
-
- BITBUCKET_API_TOKEN
|
|
31
35
|
commandFunction: |
|
|
32
36
|
(config) => ({
|
|
33
37
|
command: 'node',
|
|
@@ -36,6 +40,8 @@ startCommand:
|
|
|
36
40
|
BITBUCKET_WORKSPACE: config.BITBUCKET_WORKSPACE,
|
|
37
41
|
BITBUCKET_EMAIL: config.BITBUCKET_EMAIL,
|
|
38
42
|
BITBUCKET_API_TOKEN: config.BITBUCKET_API_TOKEN,
|
|
43
|
+
BITBUCKET_OAUTH_TOKEN: config.BITBUCKET_OAUTH_TOKEN,
|
|
44
|
+
BITBUCKET_AUTH_TYPE: config.BITBUCKET_AUTH_TYPE,
|
|
39
45
|
API_TIMEOUT: config.API_TIMEOUT ? String(config.API_TIMEOUT) : "30",
|
|
40
46
|
MAX_RETRIES: config.MAX_RETRIES ? String(config.MAX_RETRIES) : "3"
|
|
41
47
|
}
|
package/src/auth.ts
ADDED
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OAuth 2.1 bearer-token protection for the HTTP transport.
|
|
3
|
+
*
|
|
4
|
+
* The remote server holds a single company-owned Bitbucket credential, so
|
|
5
|
+
* anyone who can reach /mcp can use it. This module gates that endpoint on a
|
|
6
|
+
* bearer token issued by an external authorization server (Microsoft Entra ID
|
|
7
|
+
* in our deployment), following the MCP authorization spec: the server acts as
|
|
8
|
+
* an OAuth *resource server*, advertises where to get a token via
|
|
9
|
+
* /.well-known/oauth-protected-resource (RFC 9728), and answers unauthenticated
|
|
10
|
+
* requests with 401 + WWW-Authenticate so the client can start the flow.
|
|
11
|
+
*
|
|
12
|
+
* Note this authenticates *who may use the server*, not who acts on Bitbucket:
|
|
13
|
+
* calls still reach Bitbucket as the service identity.
|
|
14
|
+
*
|
|
15
|
+
* Disabled unless both MCP_OAUTH_ISSUER and MCP_OAUTH_AUDIENCE are set, so an
|
|
16
|
+
* unconfigured deployment keeps behaving exactly as before.
|
|
17
|
+
*
|
|
18
|
+
* Environment variables:
|
|
19
|
+
* - MCP_OAUTH_ISSUER: token issuer, e.g. https://login.microsoftonline.com/<tenant>/v2.0
|
|
20
|
+
* - MCP_OAUTH_AUDIENCE: expected `aud`, e.g. api://<app-id>
|
|
21
|
+
* - MCP_OAUTH_JWKS_URI: override for the signing keys (derived from the issuer otherwise)
|
|
22
|
+
* - MCP_OAUTH_REQUIRED_SCOPE: scope(s) the token must carry, comma-separated when
|
|
23
|
+
* more than one is acceptable, e.g. "mcp.access,mcp.invoke" (Entra names the
|
|
24
|
+
* delegated scope and the application role differently, and a token only ever
|
|
25
|
+
* carries one of the two)
|
|
26
|
+
* - MCP_OAUTH_RESOURCE: identifier advertised as the protected resource, when it
|
|
27
|
+
* cannot be the server's own URL. The spec wants the canonical MCP endpoint,
|
|
28
|
+
* and clients send it as the RFC 8707 `resource` parameter — but Entra rejects
|
|
29
|
+
* that with AADSTS9010010 ("resource parameter doesn't match the requested
|
|
30
|
+
* scopes") unless it is the API's Application ID URI, and it only accepts
|
|
31
|
+
* https identifiers on domains verified in the tenant. So against Entra this
|
|
32
|
+
* is set to api://<app-id>.
|
|
33
|
+
* - MCP_OAUTH_AS_METADATA: 'proxy' (default) to publish the authorization server
|
|
34
|
+
* metadata ourselves, or 'issuer' to point clients straight at the issuer.
|
|
35
|
+
* Entra ID does not serve RFC 8414 metadata at all — /.well-known/oauth-authorization-server
|
|
36
|
+
* is a 404 on every variant — so a client that follows the MCP spec stops there
|
|
37
|
+
* and never reaches the login page. Proxying republishes the issuer's OpenID
|
|
38
|
+
* configuration at our own well-known path, which is what unblocks the flow.
|
|
39
|
+
* - MCP_OAUTH_SCOPES_SUPPORTED: scope(s) advertised to clients, when they differ
|
|
40
|
+
* from the ones validated. Entra hands out `scp: mcp.access` but expects the
|
|
41
|
+
* client to *request* `api://<app-id>/mcp.access`, so the full URI goes here
|
|
42
|
+
* - MCP_PUBLIC_URL: public URL of this server. The resource identifier is the MCP
|
|
43
|
+
* endpoint itself (<public-url>/mcp), which is the most specific canonical URI
|
|
44
|
+
* per RFC 8707, and the metadata is served at the path RFC 9728 derives from
|
|
45
|
+
* it: /.well-known/oauth-protected-resource/mcp
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
import { createRemoteJWKSet, jwtVerify, type JWTPayload, type JWTVerifyGetKey } from 'jose';
|
|
49
|
+
|
|
50
|
+
export interface AuthConfig {
|
|
51
|
+
/** Token issuer that must match the `iss` claim. */
|
|
52
|
+
issuer: string;
|
|
53
|
+
/** Expected `aud` claim — the identifier of this API. */
|
|
54
|
+
audience: string;
|
|
55
|
+
/** Scopes the token must carry at least one of, if any. */
|
|
56
|
+
requiredScopes?: string[];
|
|
57
|
+
/** Scopes advertised in the metadata document; defaults to requiredScopes. */
|
|
58
|
+
advertisedScopes?: string[];
|
|
59
|
+
/** Public URL of this server; anchors the well-known paths. */
|
|
60
|
+
resourceUrl: string;
|
|
61
|
+
/** What we advertise as the resource — the URL itself unless overridden. */
|
|
62
|
+
resourceIdentifier: string;
|
|
63
|
+
/** When true, we publish the authorization server metadata ourselves. */
|
|
64
|
+
proxyAuthorizationServerMetadata: boolean;
|
|
65
|
+
/** Key source used to verify token signatures. */
|
|
66
|
+
jwks: JWTVerifyGetKey;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface AuthFailure {
|
|
70
|
+
ok: false;
|
|
71
|
+
/** HTTP status to answer with: 401 for a missing/invalid token, 403 for a valid one lacking the scope. */
|
|
72
|
+
status: 401 | 403;
|
|
73
|
+
/** OAuth error code for the WWW-Authenticate header. */
|
|
74
|
+
error: 'invalid_token' | 'insufficient_scope';
|
|
75
|
+
description: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface AuthSuccess {
|
|
79
|
+
ok: true;
|
|
80
|
+
/** `sub` claim, useful for attributing calls in logs. */
|
|
81
|
+
subject?: string;
|
|
82
|
+
/** Human-readable caller, when the token carries one. */
|
|
83
|
+
caller?: string;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export type AuthResult = AuthSuccess | AuthFailure;
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Entra ID publishes its signing keys next to the issuer; the v2.0 suffix in
|
|
90
|
+
* the issuer is not part of the discovery path.
|
|
91
|
+
*/
|
|
92
|
+
export function defaultJwksUri(issuer: string): string {
|
|
93
|
+
const base = issuer.replace(/\/+$/, '').replace(/\/v2\.0$/, '');
|
|
94
|
+
return `${base}/discovery/v2.0/keys`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Build the auth configuration from the environment, or null when OAuth is not
|
|
99
|
+
* configured (the server then serves /mcp unauthenticated, as it did before).
|
|
100
|
+
*
|
|
101
|
+
* `jwksOverride` exists for tests, which cannot fetch a remote key set.
|
|
102
|
+
*/
|
|
103
|
+
export function getAuthConfig(
|
|
104
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
105
|
+
jwksOverride?: JWTVerifyGetKey
|
|
106
|
+
): AuthConfig | null {
|
|
107
|
+
const issuer = (env.MCP_OAUTH_ISSUER || '').trim();
|
|
108
|
+
const audience = (env.MCP_OAUTH_AUDIENCE || '').trim();
|
|
109
|
+
|
|
110
|
+
if (!issuer || !audience) {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const jwksUri = (env.MCP_OAUTH_JWKS_URI || '').trim() || defaultJwksUri(issuer);
|
|
115
|
+
const requiredScopes = parseScopes(env.MCP_OAUTH_REQUIRED_SCOPE);
|
|
116
|
+
const advertisedScopes = parseScopes(env.MCP_OAUTH_SCOPES_SUPPORTED) ?? requiredScopes;
|
|
117
|
+
const publicUrl = (env.MCP_PUBLIC_URL || '').trim().replace(/\/+$/, '');
|
|
118
|
+
// The resource is the MCP endpoint, not the host: clients ask for the most
|
|
119
|
+
// specific URI they can, and the audience check depends on this matching.
|
|
120
|
+
const resourceUrl = publicUrl ? `${publicUrl}/mcp` : audience;
|
|
121
|
+
const resourceIdentifier = (env.MCP_OAUTH_RESOURCE || '').trim() || resourceUrl;
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
issuer,
|
|
125
|
+
audience,
|
|
126
|
+
requiredScopes,
|
|
127
|
+
advertisedScopes,
|
|
128
|
+
resourceUrl,
|
|
129
|
+
resourceIdentifier,
|
|
130
|
+
proxyAuthorizationServerMetadata: (env.MCP_OAUTH_AS_METADATA || 'proxy').trim() !== 'issuer',
|
|
131
|
+
jwks: jwksOverride ?? createRemoteJWKSet(new URL(jwksUri)),
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Split a comma- or space-separated scope list; undefined when empty. */
|
|
136
|
+
function parseScopes(raw: string | undefined): string[] | undefined {
|
|
137
|
+
const scopes = (raw || '').split(/[\s,]+/).filter(Boolean);
|
|
138
|
+
return scopes.length > 0 ? scopes : undefined;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* RFC 9728 metadata telling clients which authorization server issues tokens
|
|
143
|
+
* for this resource. Claude fetches this after a 401 to start the OAuth flow.
|
|
144
|
+
*/
|
|
145
|
+
export function protectedResourceMetadata(config: AuthConfig): Record<string, unknown> {
|
|
146
|
+
// Advertise what clients must request; when that is not spelled out, the
|
|
147
|
+
// scopes we validate are the best available answer.
|
|
148
|
+
const scopes = config.advertisedScopes ?? config.requiredScopes;
|
|
149
|
+
|
|
150
|
+
// Point clients at whoever actually serves RFC 8414 metadata: ourselves when
|
|
151
|
+
// proxying (because the issuer does not), otherwise the issuer.
|
|
152
|
+
const authorizationServer = config.proxyAuthorizationServerMetadata
|
|
153
|
+
? serverOrigin(config)
|
|
154
|
+
: config.issuer;
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
resource: config.resourceIdentifier,
|
|
158
|
+
authorization_servers: [authorizationServer],
|
|
159
|
+
bearer_methods_supported: ['header'],
|
|
160
|
+
...(scopes ? { scopes_supported: scopes } : {}),
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Paths the metadata document is served from. RFC 9728 derives the path from the
|
|
166
|
+
* resource identifier, so a resource at /mcp is described at
|
|
167
|
+
* /.well-known/oauth-protected-resource/mcp; the bare path is kept too because
|
|
168
|
+
* some clients only look there.
|
|
169
|
+
*/
|
|
170
|
+
export const METADATA_PATHS = [
|
|
171
|
+
'/.well-known/oauth-protected-resource/mcp',
|
|
172
|
+
'/.well-known/oauth-protected-resource',
|
|
173
|
+
];
|
|
174
|
+
|
|
175
|
+
/** Origin this server is reachable at, derived from the resource identifier. */
|
|
176
|
+
export function serverOrigin(config: AuthConfig): string {
|
|
177
|
+
return config.resourceUrl.replace(/\/mcp$/, '');
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Absolute URL of the metadata document, as advertised to clients. */
|
|
181
|
+
export function metadataUrl(config: AuthConfig): string {
|
|
182
|
+
return `${serverOrigin(config)}${METADATA_PATHS[0]}`;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Where RFC 8414 metadata is served from when we publish it ourselves. */
|
|
186
|
+
export const AS_METADATA_PATH = '/.well-known/oauth-authorization-server';
|
|
187
|
+
|
|
188
|
+
interface CachedMetadata {
|
|
189
|
+
document: Record<string, unknown>;
|
|
190
|
+
fetchedAt: number;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
let asMetadataCache: CachedMetadata | null = null;
|
|
194
|
+
const AS_METADATA_TTL_MS = 60 * 60 * 1000;
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Authorization server metadata to publish on our own well-known path.
|
|
198
|
+
*
|
|
199
|
+
* Built from the issuer's OpenID configuration — the one document Entra does
|
|
200
|
+
* serve — so the endpoints are always whatever the issuer currently advertises,
|
|
201
|
+
* rather than a copy that silently goes stale. PKCE is asserted because the MCP
|
|
202
|
+
* spec requires it of clients and Entra supports it without listing it.
|
|
203
|
+
*/
|
|
204
|
+
export async function authorizationServerMetadata(
|
|
205
|
+
config: AuthConfig,
|
|
206
|
+
fetchImpl: typeof fetch = fetch,
|
|
207
|
+
now: number = Date.now()
|
|
208
|
+
): Promise<Record<string, unknown>> {
|
|
209
|
+
if (asMetadataCache && now - asMetadataCache.fetchedAt < AS_METADATA_TTL_MS) {
|
|
210
|
+
return asMetadataCache.document;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const url = `${config.issuer.replace(/\/+$/, '')}/.well-known/openid-configuration`;
|
|
214
|
+
const response = await fetchImpl(url);
|
|
215
|
+
if (!response.ok) {
|
|
216
|
+
throw new Error(`Could not read OpenID configuration from ${url}: HTTP ${response.status}`);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const upstream = (await response.json()) as Record<string, unknown>;
|
|
220
|
+
const document = {
|
|
221
|
+
...upstream,
|
|
222
|
+
code_challenge_methods_supported: upstream.code_challenge_methods_supported ?? ['S256'],
|
|
223
|
+
grant_types_supported: upstream.grant_types_supported ?? ['authorization_code', 'refresh_token'],
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
asMetadataCache = { document, fetchedAt: now };
|
|
227
|
+
return document;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Drop the cached metadata (tests, and after a configuration change). */
|
|
231
|
+
export function resetAuthorizationServerMetadataCache(): void {
|
|
232
|
+
asMetadataCache = null;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Value for the WWW-Authenticate header, pointing at the metadata document so
|
|
237
|
+
* the client can discover where to authenticate.
|
|
238
|
+
*/
|
|
239
|
+
export function wwwAuthenticate(config: AuthConfig, failure?: AuthFailure): string {
|
|
240
|
+
const parts = [
|
|
241
|
+
`Bearer realm="mcp"`,
|
|
242
|
+
`resource_metadata="${metadataUrl(config)}"`,
|
|
243
|
+
];
|
|
244
|
+
if (failure) {
|
|
245
|
+
parts.push(`error="${failure.error}"`, `error_description="${failure.description}"`);
|
|
246
|
+
}
|
|
247
|
+
return parts.join(', ');
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Scopes arrive as a space-separated `scp` string (delegated tokens) or a
|
|
252
|
+
* `roles` array (application tokens); accept either.
|
|
253
|
+
*/
|
|
254
|
+
function tokenScopes(payload: JWTPayload): string[] {
|
|
255
|
+
const scp = payload.scp;
|
|
256
|
+
if (typeof scp === 'string') {
|
|
257
|
+
return scp.split(' ').filter(Boolean);
|
|
258
|
+
}
|
|
259
|
+
if (Array.isArray(scp)) {
|
|
260
|
+
return scp.filter((s): s is string => typeof s === 'string');
|
|
261
|
+
}
|
|
262
|
+
const roles = payload.roles;
|
|
263
|
+
if (Array.isArray(roles)) {
|
|
264
|
+
return roles.filter((r): r is string => typeof r === 'string');
|
|
265
|
+
}
|
|
266
|
+
return [];
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Verify the Authorization header of an incoming request.
|
|
271
|
+
*
|
|
272
|
+
* Returns a typed result rather than throwing so the caller can map it onto a
|
|
273
|
+
* 401 or 403 with the right WWW-Authenticate header.
|
|
274
|
+
*/
|
|
275
|
+
export async function verifyBearer(
|
|
276
|
+
authorization: string | undefined,
|
|
277
|
+
config: AuthConfig
|
|
278
|
+
): Promise<AuthResult> {
|
|
279
|
+
if (!authorization) {
|
|
280
|
+
return {
|
|
281
|
+
ok: false,
|
|
282
|
+
status: 401,
|
|
283
|
+
error: 'invalid_token',
|
|
284
|
+
description: 'Authorization header is required',
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const match = /^Bearer[ ]+(.+)$/i.exec(authorization.trim());
|
|
289
|
+
if (!match) {
|
|
290
|
+
return {
|
|
291
|
+
ok: false,
|
|
292
|
+
status: 401,
|
|
293
|
+
error: 'invalid_token',
|
|
294
|
+
description: 'Authorization header must use the Bearer scheme',
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
let payload: JWTPayload;
|
|
299
|
+
try {
|
|
300
|
+
const verified = await jwtVerify(match[1], config.jwks, {
|
|
301
|
+
issuer: config.issuer,
|
|
302
|
+
audience: config.audience,
|
|
303
|
+
});
|
|
304
|
+
payload = verified.payload;
|
|
305
|
+
} catch (error) {
|
|
306
|
+
return {
|
|
307
|
+
ok: false,
|
|
308
|
+
status: 401,
|
|
309
|
+
error: 'invalid_token',
|
|
310
|
+
description: error instanceof Error ? error.message : 'Token verification failed',
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
if (config.requiredScopes) {
|
|
315
|
+
const granted = tokenScopes(payload);
|
|
316
|
+
if (!config.requiredScopes.some((scope) => granted.includes(scope))) {
|
|
317
|
+
return {
|
|
318
|
+
ok: false,
|
|
319
|
+
status: 403,
|
|
320
|
+
error: 'insufficient_scope',
|
|
321
|
+
description: `Token carries none of the required scopes: ${config.requiredScopes.join(', ')}`,
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const caller = [payload.preferred_username, payload.upn, payload.appid]
|
|
327
|
+
.find((value): value is string => typeof value === 'string' && value.length > 0);
|
|
328
|
+
|
|
329
|
+
return { ok: true, subject: typeof payload.sub === 'string' ? payload.sub : undefined, caller };
|
|
330
|
+
}
|