@fabrika/proxy-core 0.3.3 → 0.4.1

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/src/manifest.ts CHANGED
@@ -1,21 +1,101 @@
1
- import type { AppGates, CredentialLocation, GateRule } from '@fabrika/auth-core'
1
+ import {
2
+ APP_ENVIRONMENT_SEPARATOR,
3
+ type Caller,
4
+ compileEndpointTable,
5
+ type ContractArtifact,
6
+ contractDigest,
7
+ type CredentialedEndpoint,
8
+ type EndpointDelegation,
9
+ endpointDelegation,
10
+ type EndpointRow,
11
+ EndpointTableError,
12
+ type InstallationEndpointRow,
13
+ type InstallationEndpointTable,
14
+ isDigest,
15
+ parseAppEnvironmentId,
16
+ requirementDigest,
17
+ } from '@fabrika/auth-core'
2
18
  import { z } from 'zod'
19
+ import { type CspOrigins, cspOrigins, isPolicyOrigin, MAX_CSP_ORIGINS } from './headers'
20
+ import { type OAuthProtectedResource, oauthProtectedResourceSchema, oauthScopesSchema } from './oauth-resource'
21
+
22
+ export * from './oauth-resource'
3
23
 
4
24
  export const FABRIKA_PROXY_MANIFEST_JSON = 'FABRIKA_PROXY_MANIFEST_JSON'
5
25
 
6
- export interface ProxyApp {
7
- /** The IAM app id, used as the minted token audience. */
26
+ interface ProxyAppBase {
27
+ /** The IAM app id, used as the minted token audience: `<app>@<env>` for an application, a plain id for a platform service. */
8
28
  id: string
9
29
  /** Literal hostnames, lower-cased on parse; never patterns or host:port pairs. */
10
30
  hosts: string[]
11
31
  /** Private upstream dial address, for example `app:3000`. */
12
32
  upstream: string
13
- /** Ordered rules; evaluated only by auth-core in the proxy. */
14
- gates: AppGates
15
33
  /** Browser-facing scheme used for login and handoff URLs, defaulting to https. */
16
34
  scheme: 'http' | 'https'
17
35
  }
18
36
 
37
+ /** A contract application: its endpoint table, from which the proxy selects one endpoint per request (ADR-0022 rule 3). */
38
+ export interface ContractProxyApp extends ProxyAppBase {
39
+ /** Array order carries no meaning; `planEndpoint` selects the most specific row. */
40
+ endpoints: ProxyEndpointRow[]
41
+ /** Literal prefixes reserved for procedures; a path under one reaches only an endpoint declared under it. */
42
+ reservedPrefixes: string[]
43
+ /** The digest of the contract the table was compiled from (`contractDigest`). */
44
+ snapshot: string
45
+ /** The contract's named CSP origins, which the proxy's header policy adds to their directives. */
46
+ csp: CspOrigins
47
+ /** The installation's Operations ingest origin, admitted by `connect-src`; absent when the producer knows none. */
48
+ operationsOrigin?: string
49
+ }
50
+
51
+ /**
52
+ * An installation service protected by an endpoint table (ADR-0022 "Installation entries"). Only trusted platform
53
+ * composition writes `installation: true`; its rows may use the forms no application may declare, and its hop keeps
54
+ * `Authorization`, sets no proxy secret and leaves the service's answers untouched.
55
+ */
56
+ export interface InstallationProxyApp extends ProxyAppBase {
57
+ installation: true
58
+ /** Array order carries no meaning; `planEndpoint` selects the most specific row. */
59
+ endpoints: InstallationProxyEndpointRow[]
60
+ reservedPrefixes: string[]
61
+ /** The resources a row that admits `oauth` is bound to, by the resource URI's path. */
62
+ oauthResources?: OAuthProtectedResource[]
63
+ }
64
+
65
+ /** What a producer adds to a contract app's placement: the output of `contractProxyTable`. */
66
+ export type ContractProxyTable = Pick<ContractProxyApp, 'endpoints' | 'reservedPrefixes' | 'snapshot' | 'csp'>
67
+
68
+ /** Every entry is protected by an endpoint table: an application's, or an installation service's. */
69
+ export type ProxyApp = ContractProxyApp | InstallationProxyApp
70
+
71
+ /**
72
+ * Whether an entry is an application: its hop carries the proxy secret, its answers get the header policy, and an
73
+ * anonymous endpoint's request loses `Authorization`. Every other entry is an installation service.
74
+ */
75
+ export function isApplicationEntry(app: ProxyApp): app is ContractProxyApp {
76
+ return 'endpoints' in app && !('installation' in app)
77
+ }
78
+
79
+ interface ProxyEndpointExtras {
80
+ /** `requirementDigest` of the row's rules and delegation; the proxy sends it as `X-Fabrika-Endpoint: <id>@<digest>`. */
81
+ digest: string
82
+ delegation: EndpointDelegation | null
83
+ }
84
+
85
+ /** An endpoint row as the proxy applies it: the rules without the schemas, plus the digest and the delegation. */
86
+ export type ProxyEndpointRow =
87
+ | (Pick<CredentialedEndpoint, 'id' | 'kind' | 'method' | 'path' | 'callers' | 'requirement' | 'admission'> & ProxyEndpointExtras)
88
+ | (Pick<Extract<EndpointRow, { requirement: null }>, 'id' | 'kind' | 'method' | 'path' | 'callers' | 'requirement' | 'admission'> & {
89
+ digest: string
90
+ delegation: null
91
+ })
92
+
93
+ /** An installation row as the proxy applies it: the row with its digest and no delegation. */
94
+ export type InstallationProxyEndpointRow = InstallationEndpointRow & { digest: string; delegation: null }
95
+
96
+ /** Any row an endpoint table of the manifest holds. */
97
+ export type ManifestEndpointRow = ProxyEndpointRow | InstallationProxyEndpointRow
98
+
19
99
  export interface ProxyManifest {
20
100
  apps: ProxyApp[]
21
101
  }
@@ -27,38 +107,247 @@ export function isProxyHost(host: string): boolean {
27
107
  return host.length > 0 && host.length <= 253 && host.split('.').every((label) => label.length <= 63 && HOST_LABEL.test(label))
28
108
  }
29
109
 
30
- export const proxyCredentialLocationSchema = z.object({
31
- in: z.enum(['header', 'query', 'cookie']),
32
- name: z.string().min(1),
33
- }) satisfies z.ZodType<CredentialLocation, z.ZodTypeDef, unknown>
110
+ /** An id that holds `@` must be a whole `<app>@<env>`; a malformed one would mint for an app IAM never registered. */
111
+ const proxyAppIdSchema = z.string().min(1).refine((id) => !id.includes(APP_ENVIRONMENT_SEPARATOR) || parseAppEnvironmentId(id) !== null)
112
+
113
+ const proxyAppScheme = z.preprocess((value) => typeof value === 'string' ? value : undefined, z.enum(['http', 'https']).default('https'))
114
+ const proxyAppFields = {
115
+ id: proxyAppIdSchema,
116
+ hosts: z.array(z.string().toLowerCase().refine(isProxyHost)).min(1),
117
+ upstream: z.string().min(1),
118
+ }
119
+
120
+ /** Each resource once, on one of the entry's own hosts and its scheme. */
121
+ function checkOAuthResources(app: Pick<InstallationProxyApp, 'hosts' | 'scheme' | 'oauthResources'>, ctx: z.RefinementCtx): void {
122
+ const resources = new Set<string>()
123
+ const metadata = new Set<string>()
124
+ for (const resource of app.oauthResources ?? []) {
125
+ const url = new URL(resource.resource)
126
+ if (
127
+ resources.has(resource.resource) || metadata.has(resource.resourceMetadata) || !app.hosts.includes(url.hostname)
128
+ || url.protocol !== `${app.scheme}:`
129
+ ) {
130
+ ctx.addIssue({ code: 'custom', message: 'invalid OAuth resource binding' })
131
+ }
132
+ resources.add(resource.resource)
133
+ metadata.add(resource.resourceMetadata)
134
+ }
135
+ }
34
136
 
35
- const gatePathSchema = z.string().startsWith('/')
36
- /** A gate rule's fields before normalization; a producer adds its own refinements on top. */
37
- export const proxyGateRuleFieldsSchema = z.discriminatedUnion('kind', [
38
- z.object({ path: gatePathSchema, kind: z.literal('public') }),
39
- z.object({ path: gatePathSchema, kind: z.literal('human') }),
40
- z.object({ path: gatePathSchema, kind: z.literal('service'), credential: proxyCredentialLocationSchema.nullish() }),
137
+ const nameSchema = z.string().min(1)
138
+ const callerSchema = z.custom<Caller>((value) =>
139
+ value === 'human' || value === 'machine' || value === 'link' || (typeof value === 'string' && /^app:[^\s@]+$/.test(value))
140
+ )
141
+ const accessFormSchema = z.discriminatedUnion('form', [
142
+ z.object({ form: z.literal('over'), dimension: nameSchema }).strict(),
143
+ z.object({ form: z.literal('in'), dimension: nameSchema, from: nameSchema }).strict(),
144
+ z.object({ form: z.literal('on'), dimension: nameSchema, from: nameSchema.nullable() }).strict(),
145
+ z.object({ form: z.literal('creates'), dimension: nameSchema }).strict(),
146
+ z.object({ form: z.literal('app') }).strict(),
41
147
  ])
148
+ const requirementSchema = z.discriminatedUnion('kind', [
149
+ z.object({ kind: z.literal('member') }).strict(),
150
+ z.object({ kind: z.literal('action'), action: nameSchema, access: accessFormSchema }).strict(),
151
+ ])
152
+ const admissionSchema = z.object({
153
+ contentTypes: z.array(nameSchema).nonempty(),
154
+ maxBytes: z.number().int().positive().nullable(),
155
+ }).strict()
156
+ const endpointRuleFields = {
157
+ id: nameSchema,
158
+ method: z.enum(['GET', 'POST']),
159
+ path: z.string().startsWith('/'),
160
+ admission: admissionSchema.nullable(),
161
+ digest: z.string().refine(isDigest),
162
+ }
163
+ /** One row of a contract app's endpoint table; it refuses a digest that does not describe the row's rules. */
164
+ export const proxyEndpointRowSchema = z.union([
165
+ z.object({
166
+ ...endpointRuleFields,
167
+ kind: z.enum(['query', 'mutation', 'spa', 'upload', 'download']),
168
+ callers: z.array(callerSchema).nonempty(),
169
+ requirement: requirementSchema,
170
+ delegation: z.enum(['members', 'creates']).nullable(),
171
+ }).strict(),
172
+ z.object({
173
+ ...endpointRuleFields,
174
+ kind: z.enum(['health', 'form', 'webhook', 'link-spa']),
175
+ callers: z.tuple([]),
176
+ requirement: z.null(),
177
+ delegation: z.null(),
178
+ }).strict(),
179
+ // A public SPA is a fixed static row: it serves GET only and admits no body.
180
+ z.object({
181
+ ...endpointRuleFields,
182
+ kind: z.literal('public-spa'),
183
+ method: z.literal('GET'),
184
+ admission: z.null(),
185
+ callers: z.tuple([]),
186
+ requirement: z.null(),
187
+ delegation: z.null(),
188
+ }).strict(),
189
+ ]).refine((row) => row.digest === requirementDigest(row), 'endpoint digest does not match its rules') satisfies z.ZodType<
190
+ ProxyEndpointRow,
191
+ z.ZodTypeDef,
192
+ unknown
193
+ >
194
+
195
+ const cspOriginSchema = z.string().refine((value) => isPolicyOrigin(value))
196
+ const cspDirectiveSchema = z.array(cspOriginSchema).max(MAX_CSP_ORIGINS)
197
+ const cspOriginsSchema = z.object({
198
+ connectSrc: cspDirectiveSchema,
199
+ imgSrc: cspDirectiveSchema,
200
+ frameAncestors: cspDirectiveSchema,
201
+ // A manifest written before these four directives existed names none of them.
202
+ scriptSrc: cspDirectiveSchema.default([]),
203
+ frameSrc: cspDirectiveSchema.default([]),
204
+ styleSrc: cspDirectiveSchema.default([]),
205
+ fontSrc: cspDirectiveSchema.default([]),
206
+ }).strict() satisfies z.ZodType<CspOrigins, z.ZodTypeDef, unknown>
42
207
 
43
- /** Drop an absent service credential, so a rule without one carries no `credential` key at all. */
44
- export const normalizeGateRule = (rule: z.infer<typeof proxyGateRuleFieldsSchema>): GateRule => {
45
- if (rule.kind !== 'service') return rule
46
- return { path: rule.path, kind: rule.kind, ...(rule.credential == null ? {} : { credential: rule.credential }) }
208
+ // Strict: a key the proxy does not read (`gates`, or an installation entry's field) is refused, never ignored.
209
+ export const contractProxyAppSchema = z.object({
210
+ ...proxyAppFields,
211
+ scheme: proxyAppScheme,
212
+ endpoints: z.array(proxyEndpointRowSchema),
213
+ reservedPrefixes: z.array(z.string()),
214
+ snapshot: z.string().refine(isDigest),
215
+ csp: cspOriginsSchema,
216
+ operationsOrigin: z.string().refine((value) => isPolicyOrigin(value, ['https:', 'http:'])).optional(),
217
+ }).strict().superRefine((app, ctx) => {
218
+ checkEndpointTable(app, ctx)
219
+ }) satisfies z.ZodType<ContractProxyApp, z.ZodTypeDef, unknown>
220
+
221
+ function checkEndpointTable(app: Pick<ContractProxyApp | InstallationProxyApp, 'endpoints' | 'reservedPrefixes'>, ctx: z.RefinementCtx): void {
222
+ try {
223
+ compileEndpointTable<ManifestEndpointRow>(app)
224
+ } catch (error) {
225
+ if (!(error instanceof EndpointTableError)) throw error
226
+ ctx.addIssue({ code: 'custom', message: 'ambiguous or invalid endpoint table' })
227
+ }
47
228
  }
48
229
 
49
- export const proxyGateRuleSchema = proxyGateRuleFieldsSchema.transform(normalizeGateRule) satisfies z.ZodType<GateRule, z.ZodTypeDef, unknown>
230
+ const installationRuleFields = {
231
+ id: nameSchema,
232
+ method: z.enum(['GET', 'POST', '*']),
233
+ path: z.string().startsWith('/'),
234
+ admission: admissionSchema.nullable(),
235
+ delegation: z.null(),
236
+ digest: z.string().refine(isDigest),
237
+ }
238
+ /** One row of an installation entry: the installation forms, and no application form but `health`. */
239
+ export const installationProxyEndpointRowSchema = z.union([
240
+ z.object({
241
+ ...installationRuleFields,
242
+ kind: z.literal('service'),
243
+ callers: z.array(z.enum(['human', 'machine', 'oauth'])).nonempty(),
244
+ requirement: z.object({ kind: z.literal('authenticated') }).strict(),
245
+ oauth: z.object({ scopes: oauthScopesSchema }).strict().nullable(),
246
+ }).strict().refine((row) => row.callers.includes('oauth') === (row.oauth !== null), 'oauth scopes belong to a row that admits oauth'),
247
+ z.object({
248
+ ...installationRuleFields,
249
+ kind: z.enum(['protocol', 'health']),
250
+ callers: z.tuple([]),
251
+ requirement: z.null(),
252
+ oauth: z.null(),
253
+ }).strict(),
254
+ ]).refine((row) => row.digest === requirementDigest(row), 'endpoint digest does not match its rules') satisfies z.ZodType<
255
+ InstallationProxyEndpointRow,
256
+ z.ZodTypeDef,
257
+ unknown
258
+ >
50
259
 
51
- export const proxyAppGatesSchema = z.object({
52
- rules: z.array(proxyGateRuleSchema),
53
- }) satisfies z.ZodType<AppGates, z.ZodTypeDef, unknown>
260
+ /** The registered resource a row that admits `oauth` is bound to: the one whose URI path is the row's path. */
261
+ export function oauthResourceOfRow(
262
+ app: Pick<InstallationProxyApp, 'oauthResources'>,
263
+ row: Pick<InstallationProxyEndpointRow, 'path'>,
264
+ ): OAuthProtectedResource | null {
265
+ return app.oauthResources?.find((resource) => new URL(resource.resource).pathname === row.path) ?? null
266
+ }
54
267
 
55
- export const proxyAppSchema = z.object({
56
- id: z.string().min(1),
57
- hosts: z.array(z.string().toLowerCase().refine(isProxyHost)).min(1),
58
- upstream: z.string().min(1),
59
- gates: proxyAppGatesSchema,
60
- scheme: z.preprocess((value) => typeof value === 'string' ? value : undefined, z.enum(['http', 'https']).default('https')),
61
- }) satisfies z.ZodType<ProxyApp, z.ZodTypeDef, unknown>
268
+ /**
269
+ * An installation entry. Only platform composition writes the marker; the parser refuses one whose id holds `@`,
270
+ * that carries what only an application has, or whose `oauth` row is bound to no registered resource.
271
+ */
272
+ export const installationProxyAppSchema = z.object({
273
+ ...proxyAppFields,
274
+ scheme: proxyAppScheme,
275
+ installation: z.literal(true),
276
+ endpoints: z.array(installationProxyEndpointRowSchema),
277
+ reservedPrefixes: z.array(z.string()),
278
+ oauthResources: z.array(oauthProtectedResourceSchema).min(1).optional(),
279
+ }).strict().superRefine((app, ctx) => {
280
+ if (app.id.includes(APP_ENVIRONMENT_SEPARATOR)) {
281
+ ctx.addIssue({ code: 'custom', message: 'an installation entry is no application environment' })
282
+ }
283
+ checkOAuthResources(app, ctx)
284
+ const paths = (app.oauthResources ?? []).map((resource) => new URL(resource.resource).pathname)
285
+ if (new Set(paths).size !== paths.length) {
286
+ ctx.addIssue({ code: 'custom', message: 'two OAuth resources on one path' })
287
+ }
288
+ for (const row of app.endpoints) {
289
+ if (row.oauth === null) continue
290
+ const resource = oauthResourceOfRow(app, row)
291
+ if (resource === null || !row.oauth.scopes.every((scope) => resource.scopesSupported.includes(scope))) {
292
+ ctx.addIssue({ code: 'custom', message: 'unregistered OAuth row' })
293
+ }
294
+ }
295
+ checkEndpointTable(app, ctx)
296
+ }) satisfies z.ZodType<InstallationProxyApp, z.ZodTypeDef, unknown>
297
+
298
+ export const proxyAppSchema = z.union([contractProxyAppSchema, installationProxyAppSchema]) satisfies z.ZodType<
299
+ ProxyApp,
300
+ z.ZodTypeDef,
301
+ unknown
302
+ >
303
+
304
+ /**
305
+ * The manifest form of a compiled contract: every endpoint's rules with their digest and delegation, the reserved
306
+ * prefixes, the contract digest and the named CSP origins. Producers call this; they never assemble rows by hand.
307
+ */
308
+ export function contractProxyTable(artifact: ContractArtifact): ContractProxyTable {
309
+ return {
310
+ endpoints: artifact.endpoints.map((row): ProxyEndpointRow => {
311
+ const rules = { id: row.id, method: row.method, path: row.path, admission: row.admission }
312
+ if (row.requirement === null) {
313
+ const anonymous = { ...rules, kind: row.kind, callers: row.callers, requirement: null, delegation: null }
314
+ return { ...anonymous, digest: requirementDigest(anonymous) }
315
+ }
316
+ const credentialed = {
317
+ ...rules,
318
+ kind: row.kind,
319
+ callers: row.callers,
320
+ requirement: row.requirement,
321
+ delegation: endpointDelegation(artifact, row),
322
+ }
323
+ return { ...credentialed, digest: requirementDigest(credentialed) }
324
+ }),
325
+ reservedPrefixes: [...artifact.reservedPrefixes],
326
+ snapshot: contractDigest(artifact),
327
+ csp: cspOrigins(artifact.csp),
328
+ }
329
+ }
330
+
331
+ /** What a producer adds to an installation service's placement: the output of `serviceProxyTable`. */
332
+ export type InstallationProxyTable = Pick<InstallationProxyApp, 'installation' | 'endpoints' | 'reservedPrefixes'>
333
+
334
+ /**
335
+ * The manifest form of a service contract: every row with its digest. Without an authorization server (`oauth:
336
+ * false`) every row that admits `oauth` is dropped, because the parser refuses such a row without its registered
337
+ * resource; the service must then serve the same table, or the dropped path answers 503 there.
338
+ */
339
+ export function serviceProxyTable(service: InstallationEndpointTable, options: { readonly oauth: boolean }): InstallationProxyTable {
340
+ return {
341
+ installation: true,
342
+ endpoints: service.endpoints
343
+ .filter((row) => options.oauth || row.oauth === null)
344
+ .map((row): InstallationProxyEndpointRow => {
345
+ const rules = { ...row, delegation: null }
346
+ return { ...rules, digest: requirementDigest(rules) }
347
+ }),
348
+ reservedPrefixes: [...service.reservedPrefixes],
349
+ }
350
+ }
62
351
 
63
352
  /** The first host two apps (or one app twice) claim, or null. A host belongs to exactly one app. */
64
353
  export function findDuplicateProxyHost(apps: readonly Pick<ProxyApp, 'hosts'>[]): string | null {
@@ -72,7 +361,7 @@ export function findDuplicateProxyHost(apps: readonly Pick<ProxyApp, 'hosts'>[])
72
361
 
73
362
  export const proxyManifestSchema = z.object({
74
363
  apps: z.array(proxyAppSchema),
75
- }).superRefine(({ apps }, ctx) => {
364
+ }).strict().superRefine(({ apps }, ctx) => {
76
365
  if (new Set(apps.map((app) => app.id)).size !== apps.length) ctx.addIssue({ code: 'custom', message: 'duplicate proxy app id' })
77
366
  if (findDuplicateProxyHost(apps) !== null) ctx.addIssue({ code: 'custom', message: 'duplicate proxy host' })
78
367
  }) satisfies z.ZodType<ProxyManifest, z.ZodTypeDef, unknown>
@@ -0,0 +1,87 @@
1
+ import { isOAuthResourceUri, parseOAuthScopes } from '@fabrika/auth-core'
2
+ import { z } from 'zod'
3
+
4
+ /** Configuration, not caller-supplied URLs. Resource and metadata stay on the protected app origin. */
5
+ export interface OAuthProtectedResource {
6
+ resource: string
7
+ resourceMetadata: string
8
+ authorizationServers: string[]
9
+ scopesSupported: string[]
10
+ }
11
+
12
+ /** RFC 9728 metadata response; metadata publication does not admit requests to the resource. */
13
+ export interface OAuthProtectedResourceMetadata {
14
+ resource: string
15
+ authorization_servers: string[]
16
+ scopes_supported: string[]
17
+ bearer_methods_supported: ['header']
18
+ }
19
+
20
+ /** RFC 6750 challenge parameters with RFC 9728 resource metadata discovery. */
21
+ export interface OAuthBearerChallenge {
22
+ resource_metadata: string
23
+ error?: 'invalid_token' | 'insufficient_scope'
24
+ scope?: string
25
+ }
26
+
27
+ const METADATA_PATH = '/.well-known/oauth-protected-resource'
28
+
29
+ export const oauthScopesSchema = z.unknown().transform((value, ctx) => {
30
+ const scopes = parseOAuthScopes(value)
31
+ if (scopes !== null) return scopes
32
+ ctx.addIssue({ code: 'custom', message: 'invalid OAuth scopes', fatal: true })
33
+ return z.NEVER
34
+ })
35
+
36
+ const resourceUriSchema = z.string().refine(isOAuthResourceUri)
37
+ const authorizationServerSchema = z.string().refine((server) => isOAuthResourceUri(`${server}/`) && new URL(server).origin === server)
38
+
39
+ export const oauthProtectedResourceSchema = z.object({
40
+ resource: resourceUriSchema,
41
+ resourceMetadata: resourceUriSchema,
42
+ authorizationServers: z.array(authorizationServerSchema).length(1),
43
+ scopesSupported: oauthScopesSchema,
44
+ }).refine(({ resource, resourceMetadata }) => {
45
+ const metadata = new URL(resourceMetadata)
46
+ return new URL(resource).origin === metadata.origin
47
+ && (metadata.pathname === METADATA_PATH || metadata.pathname.startsWith(`${METADATA_PATH}/`))
48
+ }, 'OAuth resource metadata must live on the resource origin') satisfies z.ZodType<OAuthProtectedResource, z.ZodTypeDef, unknown>
49
+
50
+ export function parseOAuthProtectedResource(value: unknown): OAuthProtectedResource | null {
51
+ const result = oauthProtectedResourceSchema.safeParse(value)
52
+ return result.success ? result.data : null
53
+ }
54
+
55
+ export function oauthProtectedResourceMetadata(config: OAuthProtectedResource): OAuthProtectedResourceMetadata {
56
+ if (parseOAuthProtectedResource(config) === null) throw new Error('Invalid OAuth protected resource config')
57
+ return {
58
+ resource: config.resource,
59
+ authorization_servers: [...config.authorizationServers],
60
+ scopes_supported: [...config.scopesSupported],
61
+ bearer_methods_supported: ['header'],
62
+ }
63
+ }
64
+
65
+ const oauthBearerChallengeSchema = z.object({
66
+ resource_metadata: resourceUriSchema,
67
+ error: z.enum(['invalid_token', 'insufficient_scope']).optional(),
68
+ scope: z.string().refine((scope) => parseOAuthScopes(scope.split(' ')) !== null).optional(),
69
+ }).refine((challenge) => challenge.error !== 'insufficient_scope' || challenge.scope !== undefined, 'insufficient_scope needs a scope')
70
+
71
+ export function parseOAuthBearerChallenge(value: unknown): OAuthBearerChallenge | null {
72
+ const result = oauthBearerChallengeSchema.safeParse(value)
73
+ if (!result.success) return null
74
+ const challenge: OAuthBearerChallenge = { resource_metadata: result.data.resource_metadata }
75
+ if (result.data.error !== undefined) challenge.error = result.data.error
76
+ if (result.data.scope !== undefined) challenge.scope = result.data.scope
77
+ return challenge
78
+ }
79
+
80
+ export function formatOAuthBearerChallenge(value: OAuthBearerChallenge): string {
81
+ const challenge = parseOAuthBearerChallenge(value)
82
+ if (challenge === null) throw new Error('Invalid OAuth bearer challenge')
83
+ const parameters = [`resource_metadata=${JSON.stringify(challenge.resource_metadata)}`]
84
+ if (challenge.error !== undefined) parameters.push(`error=${JSON.stringify(challenge.error)}`)
85
+ if (challenge.scope !== undefined) parameters.push(`scope=${JSON.stringify(challenge.scope)}`)
86
+ return `Bearer ${parameters.join(', ')}`
87
+ }
package/src/oauth.ts ADDED
@@ -0,0 +1,30 @@
1
+ import { isApplicationEntry, type OAuthProtectedResource, oauthProtectedResourceMetadata, type ProxyApp } from './manifest'
2
+
3
+ /**
4
+ * The auth service's RFC 9728 metadata endpoint. Separate from `/verify` because a 2xx from `/verify`
5
+ * admits the request to the app, while metadata is answered by the proxy itself and never forwarded.
6
+ */
7
+ export const OAUTH_METADATA_VERIFY_PATH = '/oauth-metadata'
8
+
9
+ /**
10
+ * The entry's protected resource whose metadata URL is this request's host and exact raw pathname. Only an
11
+ * installation entry has one.
12
+ */
13
+ export function findOAuthMetadata(app: ProxyApp, url: URL): OAuthProtectedResource | null {
14
+ if (isApplicationEntry(app)) return null
15
+ const hostname = url.hostname.toLowerCase()
16
+ return app.oauthResources?.find((resource) => {
17
+ const metadata = new URL(resource.resourceMetadata)
18
+ return metadata.hostname === hostname && metadata.pathname === url.pathname
19
+ }) ?? null
20
+ }
21
+
22
+ export function oauthMetadataResponse(resource: OAuthProtectedResource, method: string): Response {
23
+ if (method !== 'GET' && method !== 'HEAD') {
24
+ return new Response('method not allowed', { status: 405, headers: { allow: 'GET, HEAD', 'cache-control': 'no-store' } })
25
+ }
26
+ return new Response(method === 'HEAD' ? null : JSON.stringify(oauthProtectedResourceMetadata(resource)), {
27
+ status: 200,
28
+ headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' },
29
+ })
30
+ }