@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/package.json +9 -4
- package/src/authorize.ts +295 -83
- package/src/constants.ts +2 -2
- package/src/credentials.ts +25 -0
- package/src/headers.ts +210 -0
- package/src/iam.ts +36 -4
- package/src/index.ts +6 -4
- package/src/manifest.ts +320 -31
- package/src/oauth-resource.ts +87 -0
- package/src/oauth.ts +30 -0
- package/src/service.ts +139 -47
- package/src/verifier.ts +3 -3
- package/src/gates.ts +0 -65
package/src/manifest.ts
CHANGED
|
@@ -1,21 +1,101 @@
|
|
|
1
|
-
import
|
|
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
|
-
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
z.object({
|
|
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
|
-
|
|
44
|
-
export const
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
+
}
|