@oxy.so/core 1.3.1 → 1.5.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/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/mixins/OxyServices.agency.js +41 -0
- package/dist/cjs/mixins/OxyServices.utility.js +173 -6
- package/dist/cjs/server/auth.js +29 -0
- package/dist/cjs/server/index.js +17 -1
- package/dist/cjs/server/requesterAssertion.js +362 -0
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/mixins/OxyServices.agency.js +41 -0
- package/dist/esm/mixins/OxyServices.utility.js +173 -6
- package/dist/esm/server/auth.js +28 -0
- package/dist/esm/server/index.js +4 -1
- package/dist/esm/server/requesterAssertion.js +353 -0
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/index.d.ts +2 -2
- package/dist/types/mixins/OxyServices.agency.d.ts +41 -0
- package/dist/types/mixins/OxyServices.utility.d.ts +49 -0
- package/dist/types/server/auth.d.ts +19 -1
- package/dist/types/server/index.d.ts +4 -2
- package/dist/types/server/requesterAssertion.d.ts +151 -0
- package/package.json +1 -1
- package/src/index.ts +3 -1
- package/src/mixins/OxyServices.agency.ts +76 -0
- package/src/mixins/OxyServices.utility.ts +226 -5
- package/src/mixins/__tests__/authRefusalObservability.test.ts +396 -0
- package/src/server/__tests__/requesterAssertion.test.ts +359 -0
- package/src/server/auth.ts +37 -1
- package/src/server/index.ts +29 -0
- package/src/server/requesterAssertion.ts +465 -0
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Present-requester assertions (ADR 0025).
|
|
3
|
+
*
|
|
4
|
+
* A first-party product backend holding a signed-in person's live Oxy session
|
|
5
|
+
* trades it with Oxy (`OxyServices.mintRequesterAssertion`) for a one-use,
|
|
6
|
+
* short-lived `OXY-REQUESTER+JWT`, and sends that — never the person's bearer —
|
|
7
|
+
* to the audience service (Alia) beside its own service token. The audience
|
|
8
|
+
* mounts {@link createOxyRequesterAssertionAuth} after `createOxyAuthMiddleware`
|
|
9
|
+
* and gets `req.userId` from the verified, introspected and consumed assertion.
|
|
10
|
+
*
|
|
11
|
+
* Signing and stateless verification live here so Oxy (the signer) and every
|
|
12
|
+
* verifier share one implementation of the format.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
createPublicKey,
|
|
17
|
+
sign as signBytes,
|
|
18
|
+
verify as verifyBytes,
|
|
19
|
+
type KeyObject,
|
|
20
|
+
} from 'node:crypto';
|
|
21
|
+
import type { NextFunction, Request, RequestHandler, Response } from 'express';
|
|
22
|
+
import { z } from 'zod';
|
|
23
|
+
import type { OxyAuthRequest, OxyServiceAppContext } from './auth';
|
|
24
|
+
|
|
25
|
+
export const OXY_REQUESTER_ASSERTION_TYPE = 'OXY-REQUESTER+JWT';
|
|
26
|
+
/** The request header an audience reads the assertion from. */
|
|
27
|
+
export const OXY_REQUESTER_ASSERTION_HEADER = 'x-oxy-requester-assertion';
|
|
28
|
+
/** Oxy signs for 120 s; no verifier accepts a lifetime above this ceiling. */
|
|
29
|
+
export const OXY_REQUESTER_ASSERTION_MAX_TTL_SECONDS = 300;
|
|
30
|
+
export const OXY_REQUESTER_ASSERTION_DEFAULT_ISSUER = 'https://api.oxy.so';
|
|
31
|
+
|
|
32
|
+
const ALGORITHM = 'EdDSA';
|
|
33
|
+
const CLOCK_SKEW_SECONDS = 5;
|
|
34
|
+
const KEY_ID_PATTERN = /^[A-Za-z0-9._-]{1,128}$/;
|
|
35
|
+
const BASE64URL_PATTERN = /^[A-Za-z0-9_-]+$/;
|
|
36
|
+
const MAX_ASSERTION_BYTES = 4096;
|
|
37
|
+
|
|
38
|
+
const identifier = z.string().min(1).max(128);
|
|
39
|
+
|
|
40
|
+
export const oxyRequesterAssertionClaimsSchema = z.object({
|
|
41
|
+
iss: z.string().min(1).max(256),
|
|
42
|
+
aud: identifier,
|
|
43
|
+
/** The requester account. */
|
|
44
|
+
sub: identifier,
|
|
45
|
+
jti: z.string().uuid(),
|
|
46
|
+
iat: z.number().int().nonnegative(),
|
|
47
|
+
exp: z.number().int().positive(),
|
|
48
|
+
/** The application the assertion was minted for, and the only valid presenter. */
|
|
49
|
+
azp: identifier,
|
|
50
|
+
/** The exact credential of that application. */
|
|
51
|
+
cid: identifier,
|
|
52
|
+
/** The one native agent this requester may reach with it. */
|
|
53
|
+
agentId: identifier,
|
|
54
|
+
}).strict();
|
|
55
|
+
|
|
56
|
+
export type OxyRequesterAssertionClaims = z.infer<typeof oxyRequesterAssertionClaimsSchema>;
|
|
57
|
+
|
|
58
|
+
export type OxyRequesterAssertionErrorCode =
|
|
59
|
+
| 'malformed'
|
|
60
|
+
| 'unknown_key'
|
|
61
|
+
| 'invalid_signature'
|
|
62
|
+
| 'invalid_claims'
|
|
63
|
+
| 'wrong_issuer'
|
|
64
|
+
| 'wrong_audience'
|
|
65
|
+
| 'not_yet_valid'
|
|
66
|
+
| 'expired'
|
|
67
|
+
| 'ttl_exceeded';
|
|
68
|
+
|
|
69
|
+
export class OxyRequesterAssertionError extends Error {
|
|
70
|
+
constructor(public readonly code: OxyRequesterAssertionErrorCode) {
|
|
71
|
+
super(`Requester assertion rejected: ${code}`);
|
|
72
|
+
this.name = 'OxyRequesterAssertionError';
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function base64Url(value: string | Uint8Array): string {
|
|
77
|
+
return Buffer.from(value).toString('base64url');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function decodeObject(segment: string): Record<string, unknown> {
|
|
81
|
+
if (!BASE64URL_PATTERN.test(segment)) throw new OxyRequesterAssertionError('malformed');
|
|
82
|
+
const bytes = Buffer.from(segment, 'base64url');
|
|
83
|
+
if (bytes.toString('base64url') !== segment) throw new OxyRequesterAssertionError('malformed');
|
|
84
|
+
let value: unknown;
|
|
85
|
+
try {
|
|
86
|
+
value = JSON.parse(bytes.toString('utf8')) as unknown;
|
|
87
|
+
} catch {
|
|
88
|
+
throw new OxyRequesterAssertionError('malformed');
|
|
89
|
+
}
|
|
90
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
91
|
+
throw new OxyRequesterAssertionError('malformed');
|
|
92
|
+
}
|
|
93
|
+
return value as Record<string, unknown>;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Signs an assertion. Oxy is the only intended caller. */
|
|
97
|
+
export function signOxyRequesterAssertion(
|
|
98
|
+
claims: OxyRequesterAssertionClaims,
|
|
99
|
+
signing: { readonly keyId: string; readonly privateKey: KeyObject },
|
|
100
|
+
): string {
|
|
101
|
+
const parsed = oxyRequesterAssertionClaimsSchema.parse(claims);
|
|
102
|
+
if (parsed.exp <= parsed.iat || parsed.exp - parsed.iat > OXY_REQUESTER_ASSERTION_MAX_TTL_SECONDS) {
|
|
103
|
+
throw new OxyRequesterAssertionError('ttl_exceeded');
|
|
104
|
+
}
|
|
105
|
+
if (!KEY_ID_PATTERN.test(signing.keyId)) throw new OxyRequesterAssertionError('malformed');
|
|
106
|
+
if (signing.privateKey.asymmetricKeyType !== 'ed25519') {
|
|
107
|
+
throw new OxyRequesterAssertionError('unknown_key');
|
|
108
|
+
}
|
|
109
|
+
const header = { alg: ALGORITHM, typ: OXY_REQUESTER_ASSERTION_TYPE, kid: signing.keyId };
|
|
110
|
+
const input = `${base64Url(JSON.stringify(header))}.${base64Url(JSON.stringify(parsed))}`;
|
|
111
|
+
return `${input}.${base64Url(signBytes(null, Buffer.from(input), signing.privateKey))}`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Reads the `kid` without trusting anything else in the token. */
|
|
115
|
+
export function readOxyRequesterAssertionKeyId(token: string): string {
|
|
116
|
+
if (token.length > MAX_ASSERTION_BYTES) throw new OxyRequesterAssertionError('malformed');
|
|
117
|
+
const segments = token.split('.');
|
|
118
|
+
if (segments.length !== 3 || segments.some((segment) => segment.length === 0)) {
|
|
119
|
+
throw new OxyRequesterAssertionError('malformed');
|
|
120
|
+
}
|
|
121
|
+
const header = decodeObject(segments[0] as string);
|
|
122
|
+
if (
|
|
123
|
+
Object.keys(header).length !== 3
|
|
124
|
+
|| header.alg !== ALGORITHM
|
|
125
|
+
|| header.typ !== OXY_REQUESTER_ASSERTION_TYPE
|
|
126
|
+
|| typeof header.kid !== 'string'
|
|
127
|
+
|| !KEY_ID_PATTERN.test(header.kid)
|
|
128
|
+
) {
|
|
129
|
+
throw new OxyRequesterAssertionError('malformed');
|
|
130
|
+
}
|
|
131
|
+
return header.kid;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export interface OxyRequesterAssertionVerificationOptions {
|
|
135
|
+
readonly publicKey: KeyObject | undefined;
|
|
136
|
+
readonly issuer: string;
|
|
137
|
+
readonly audience: string;
|
|
138
|
+
readonly now?: Date;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Stateless verification: header, signature, claim shape, issuer, audience and
|
|
143
|
+
* lifetime. It does NOT prove the assertion is unused or that the requester's
|
|
144
|
+
* session is still live — only introspection does.
|
|
145
|
+
*/
|
|
146
|
+
export function verifyOxyRequesterAssertion(
|
|
147
|
+
token: string,
|
|
148
|
+
options: OxyRequesterAssertionVerificationOptions,
|
|
149
|
+
): OxyRequesterAssertionClaims {
|
|
150
|
+
readOxyRequesterAssertionKeyId(token);
|
|
151
|
+
const [encodedHeader, encodedPayload, encodedSignature] = token.split('.') as [string, string, string];
|
|
152
|
+
const publicKey = options.publicKey;
|
|
153
|
+
if (!publicKey || publicKey.asymmetricKeyType !== 'ed25519') {
|
|
154
|
+
throw new OxyRequesterAssertionError('unknown_key');
|
|
155
|
+
}
|
|
156
|
+
if (!BASE64URL_PATTERN.test(encodedSignature)) throw new OxyRequesterAssertionError('malformed');
|
|
157
|
+
const signature = Buffer.from(encodedSignature, 'base64url');
|
|
158
|
+
if (signature.length !== 64 || signature.toString('base64url') !== encodedSignature) {
|
|
159
|
+
throw new OxyRequesterAssertionError('malformed');
|
|
160
|
+
}
|
|
161
|
+
if (!verifyBytes(null, Buffer.from(`${encodedHeader}.${encodedPayload}`), publicKey, signature)) {
|
|
162
|
+
throw new OxyRequesterAssertionError('invalid_signature');
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const parsed = oxyRequesterAssertionClaimsSchema.safeParse(decodeObject(encodedPayload));
|
|
166
|
+
if (!parsed.success) throw new OxyRequesterAssertionError('invalid_claims');
|
|
167
|
+
const claims = parsed.data;
|
|
168
|
+
const now = Math.floor((options.now ?? new Date()).getTime() / 1000);
|
|
169
|
+
if (claims.iss !== options.issuer) throw new OxyRequesterAssertionError('wrong_issuer');
|
|
170
|
+
if (claims.aud !== options.audience) throw new OxyRequesterAssertionError('wrong_audience');
|
|
171
|
+
if (claims.iat > now + CLOCK_SKEW_SECONDS) throw new OxyRequesterAssertionError('not_yet_valid');
|
|
172
|
+
if (claims.exp <= now) throw new OxyRequesterAssertionError('expired');
|
|
173
|
+
if (claims.exp <= claims.iat || claims.exp - claims.iat > OXY_REQUESTER_ASSERTION_MAX_TTL_SECONDS) {
|
|
174
|
+
throw new OxyRequesterAssertionError('ttl_exceeded');
|
|
175
|
+
}
|
|
176
|
+
return claims;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ---------------------------------------------------------------------------
|
|
180
|
+
// JWKS
|
|
181
|
+
// ---------------------------------------------------------------------------
|
|
182
|
+
|
|
183
|
+
const JWKS_CACHE_MS = 5 * 60 * 1000;
|
|
184
|
+
const JWKS_REFRESH_FLOOR_MS = 60 * 1000;
|
|
185
|
+
const JWKS_FETCH_TIMEOUT_MS = 5_000;
|
|
186
|
+
const JWKS_MAX_BYTES = 64 * 1024;
|
|
187
|
+
const JWKS_MAX_KEYS = 20;
|
|
188
|
+
|
|
189
|
+
function parseJwks(value: unknown): Map<string, KeyObject> {
|
|
190
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) throw new Error('JWKS is malformed');
|
|
191
|
+
const keys = (value as { keys?: unknown }).keys;
|
|
192
|
+
if (!Array.isArray(keys) || keys.length === 0 || keys.length > JWKS_MAX_KEYS) throw new Error('JWKS has an invalid key set');
|
|
193
|
+
const result = new Map<string, KeyObject>();
|
|
194
|
+
for (const entry of keys) {
|
|
195
|
+
if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) throw new Error('JWKS key is malformed');
|
|
196
|
+
const key = entry as Record<string, unknown>;
|
|
197
|
+
if (
|
|
198
|
+
key.kty !== 'OKP'
|
|
199
|
+
|| key.crv !== 'Ed25519'
|
|
200
|
+
|| key.alg !== 'EdDSA'
|
|
201
|
+
|| key.use !== 'sig'
|
|
202
|
+
|| typeof key.x !== 'string'
|
|
203
|
+
|| typeof key.kid !== 'string'
|
|
204
|
+
|| !KEY_ID_PATTERN.test(key.kid)
|
|
205
|
+
|| Object.prototype.hasOwnProperty.call(key, 'd')
|
|
206
|
+
|| result.has(key.kid)
|
|
207
|
+
) throw new Error('JWKS key is unsupported');
|
|
208
|
+
const x = Buffer.from(key.x, 'base64url');
|
|
209
|
+
if (x.length !== 32 || x.toString('base64url') !== key.x) throw new Error('JWKS key is not Ed25519');
|
|
210
|
+
result.set(key.kid, createPublicKey({ key: { kty: 'OKP', crv: 'Ed25519', x: key.x }, format: 'jwk' }));
|
|
211
|
+
}
|
|
212
|
+
return result;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export interface OxyJwksKeyResolverOptions {
|
|
216
|
+
readonly jwksUrl: string;
|
|
217
|
+
readonly fetch?: typeof fetch;
|
|
218
|
+
readonly clock?: () => number;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Cached Ed25519 JWKS resolver. An unknown `kid` refreshes at most once a
|
|
223
|
+
* minute, so a flood of forged key ids cannot turn into a flood of fetches. A
|
|
224
|
+
* failed refresh keeps serving keys that are still inside their cache window.
|
|
225
|
+
*/
|
|
226
|
+
export function createOxyJwksKeyResolver(
|
|
227
|
+
options: OxyJwksKeyResolverOptions,
|
|
228
|
+
): (keyId: string) => Promise<KeyObject | undefined> {
|
|
229
|
+
const fetchImpl = options.fetch ?? fetch;
|
|
230
|
+
const clock = options.clock ?? Date.now;
|
|
231
|
+
let keys = new Map<string, KeyObject>();
|
|
232
|
+
let expiresAt = 0;
|
|
233
|
+
let lastAttemptAt = Number.NEGATIVE_INFINITY;
|
|
234
|
+
let pending: Promise<void> | undefined;
|
|
235
|
+
|
|
236
|
+
const refresh = async (): Promise<void> => {
|
|
237
|
+
if (pending) return pending;
|
|
238
|
+
lastAttemptAt = clock();
|
|
239
|
+
pending = (async () => {
|
|
240
|
+
const controller = new AbortController();
|
|
241
|
+
const timer = setTimeout(() => controller.abort(), JWKS_FETCH_TIMEOUT_MS);
|
|
242
|
+
try {
|
|
243
|
+
const response = await fetchImpl(options.jwksUrl, {
|
|
244
|
+
method: 'GET',
|
|
245
|
+
headers: { accept: 'application/json' },
|
|
246
|
+
redirect: 'error',
|
|
247
|
+
signal: controller.signal,
|
|
248
|
+
});
|
|
249
|
+
if (!response.ok) throw new Error(`JWKS returned HTTP ${response.status}`);
|
|
250
|
+
const body = await response.text();
|
|
251
|
+
if (Buffer.byteLength(body, 'utf8') > JWKS_MAX_BYTES) throw new Error('JWKS exceeds the size limit');
|
|
252
|
+
keys = parseJwks(JSON.parse(body) as unknown);
|
|
253
|
+
expiresAt = clock() + JWKS_CACHE_MS;
|
|
254
|
+
} finally {
|
|
255
|
+
clearTimeout(timer);
|
|
256
|
+
}
|
|
257
|
+
})();
|
|
258
|
+
try {
|
|
259
|
+
await pending;
|
|
260
|
+
} finally {
|
|
261
|
+
pending = undefined;
|
|
262
|
+
}
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
return async (keyId: string) => {
|
|
266
|
+
const now = clock();
|
|
267
|
+
const cached = keys.get(keyId);
|
|
268
|
+
if (cached && expiresAt > now) return cached;
|
|
269
|
+
if (expiresAt <= now || now - lastAttemptAt >= JWKS_REFRESH_FLOOR_MS) {
|
|
270
|
+
try {
|
|
271
|
+
await refresh();
|
|
272
|
+
} catch {
|
|
273
|
+
// Fall through: a still-valid cached key is served, anything else is unknown.
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
return expiresAt > clock() ? keys.get(keyId) : undefined;
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// ---------------------------------------------------------------------------
|
|
281
|
+
// Audience middleware
|
|
282
|
+
// ---------------------------------------------------------------------------
|
|
283
|
+
|
|
284
|
+
export interface OxyRequesterAssertionIntrospection {
|
|
285
|
+
readonly active: boolean;
|
|
286
|
+
readonly requesterAccountId?: string;
|
|
287
|
+
readonly agentId?: string;
|
|
288
|
+
readonly applicationId?: string;
|
|
289
|
+
readonly credentialId?: string;
|
|
290
|
+
readonly jti?: string;
|
|
291
|
+
readonly expiresAt?: string;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
export interface OxyRequesterAssertionIntrospector {
|
|
295
|
+
introspectRequesterAssertion(input: {
|
|
296
|
+
assertion: string;
|
|
297
|
+
presenter: { applicationId: string; credentialId: string };
|
|
298
|
+
}): Promise<OxyRequesterAssertionIntrospection>;
|
|
299
|
+
getBaseURL(): string;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** What the audience learns about the present requester. */
|
|
303
|
+
export interface OxyRequesterContext {
|
|
304
|
+
readonly userId: string;
|
|
305
|
+
readonly agentId: string;
|
|
306
|
+
readonly applicationId: string;
|
|
307
|
+
readonly credentialId: string;
|
|
308
|
+
readonly jti: string;
|
|
309
|
+
readonly expiresAt: string;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
export interface OxyRequesterAssertionRequest extends OxyAuthRequest {
|
|
313
|
+
oxyRequester?: OxyRequesterContext;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
export interface OxyRequesterAssertionAuthOptions {
|
|
317
|
+
/** This service's audience name, e.g. `alia`. */
|
|
318
|
+
readonly audience: string;
|
|
319
|
+
/** Defaults to `https://api.oxy.so`. */
|
|
320
|
+
readonly issuer?: string;
|
|
321
|
+
/** Defaults to `/capabilities/.well-known/jwks.json` on the introspector's origin. */
|
|
322
|
+
readonly jwksUrl?: string;
|
|
323
|
+
readonly resolvePublicKey?: (keyId: string) => Promise<KeyObject | undefined>;
|
|
324
|
+
readonly now?: () => Date;
|
|
325
|
+
/** Observability hook. Receives ids only, never the assertion. */
|
|
326
|
+
readonly onRejected?: (event: {
|
|
327
|
+
readonly code: string;
|
|
328
|
+
readonly status: number;
|
|
329
|
+
readonly applicationId: string | null;
|
|
330
|
+
}) => void;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function reject(
|
|
334
|
+
req: Request,
|
|
335
|
+
res: Response,
|
|
336
|
+
options: OxyRequesterAssertionAuthOptions,
|
|
337
|
+
status: 400 | 401 | 503,
|
|
338
|
+
error: string,
|
|
339
|
+
code: string,
|
|
340
|
+
): void {
|
|
341
|
+
options.onRejected?.({
|
|
342
|
+
code,
|
|
343
|
+
status,
|
|
344
|
+
applicationId: (req as OxyAuthRequest).serviceApp?.appId ?? null,
|
|
345
|
+
});
|
|
346
|
+
res.status(status).json({ error, code, message: 'The requester assertion was not accepted', status });
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function singleHeader(req: Request, name: string): string | null | 'invalid' {
|
|
350
|
+
const value = req.headers[name];
|
|
351
|
+
if (value === undefined) return null;
|
|
352
|
+
if (typeof value !== 'string' || value.length === 0 || value.length > MAX_ASSERTION_BYTES) return 'invalid';
|
|
353
|
+
return value;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Accepts an `X-Oxy-Requester-Assertion` presented beside a VERIFIED service
|
|
358
|
+
* token. Mount it after `createOxyAuthMiddleware`. Without the header it does
|
|
359
|
+
* nothing, so it composes with every other lane.
|
|
360
|
+
*
|
|
361
|
+
* `introspector` must be an `OxyServices` configured with THIS service's own
|
|
362
|
+
* credential (`configureServiceAuth`): Oxy only lets the audience's application
|
|
363
|
+
* consume an assertion.
|
|
364
|
+
*/
|
|
365
|
+
export function createOxyRequesterAssertionAuth(
|
|
366
|
+
introspector: OxyRequesterAssertionIntrospector,
|
|
367
|
+
options: OxyRequesterAssertionAuthOptions,
|
|
368
|
+
): RequestHandler {
|
|
369
|
+
const issuer = options.issuer ?? OXY_REQUESTER_ASSERTION_DEFAULT_ISSUER;
|
|
370
|
+
let resolvePublicKey = options.resolvePublicKey;
|
|
371
|
+
const resolver = (): ((keyId: string) => Promise<KeyObject | undefined>) => {
|
|
372
|
+
resolvePublicKey ??= createOxyJwksKeyResolver({
|
|
373
|
+
jwksUrl: options.jwksUrl
|
|
374
|
+
?? new URL('/capabilities/.well-known/jwks.json', introspector.getBaseURL()).toString(),
|
|
375
|
+
});
|
|
376
|
+
return resolvePublicKey;
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
return async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
|
380
|
+
const assertion = singleHeader(req, OXY_REQUESTER_ASSERTION_HEADER);
|
|
381
|
+
if (assertion === null) {
|
|
382
|
+
next();
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
if (assertion === 'invalid') {
|
|
386
|
+
reject(req, res, options, 401, 'REQUESTER_ASSERTION_INVALID', 'malformed');
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const request = req as OxyRequesterAssertionRequest;
|
|
391
|
+
const serviceApp: OxyServiceAppContext | undefined = request.serviceApp;
|
|
392
|
+
if (!serviceApp) {
|
|
393
|
+
reject(req, res, options, 401, 'REQUESTER_ASSERTION_REQUIRES_SERVICE_TOKEN', 'service_token_required');
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
// One identity channel per request. An offline delegation header or an
|
|
397
|
+
// identity some earlier middleware attached must never be combined with, or
|
|
398
|
+
// silently replaced by, a present-requester assertion.
|
|
399
|
+
if (
|
|
400
|
+
req.headers['x-oxy-user-id'] !== undefined
|
|
401
|
+
|| request.serviceActingAs !== undefined
|
|
402
|
+
|| (request.userId !== undefined && request.userId !== null)
|
|
403
|
+
) {
|
|
404
|
+
reject(req, res, options, 400, 'REQUESTER_ASSERTION_CONFLICT', 'identity_conflict');
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
let claims: OxyRequesterAssertionClaims;
|
|
409
|
+
try {
|
|
410
|
+
const keyId = readOxyRequesterAssertionKeyId(assertion);
|
|
411
|
+
claims = verifyOxyRequesterAssertion(assertion, {
|
|
412
|
+
publicKey: await resolver()(keyId),
|
|
413
|
+
issuer,
|
|
414
|
+
audience: options.audience,
|
|
415
|
+
...(options.now ? { now: options.now() } : {}),
|
|
416
|
+
});
|
|
417
|
+
} catch (error) {
|
|
418
|
+
const code = error instanceof OxyRequesterAssertionError ? error.code : 'invalid_claims';
|
|
419
|
+
reject(req, res, options, 401, 'REQUESTER_ASSERTION_INVALID', code);
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// Bound to its presenter: a copy in anyone else's hands proves nothing,
|
|
424
|
+
// and checking before introspection means it cannot even be spent.
|
|
425
|
+
if (claims.azp !== serviceApp.appId || claims.cid !== serviceApp.credentialId) {
|
|
426
|
+
reject(req, res, options, 401, 'REQUESTER_ASSERTION_INVALID', 'presenter_mismatch');
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
let introspection: OxyRequesterAssertionIntrospection;
|
|
431
|
+
try {
|
|
432
|
+
introspection = await introspector.introspectRequesterAssertion({
|
|
433
|
+
assertion,
|
|
434
|
+
presenter: { applicationId: serviceApp.appId, credentialId: serviceApp.credentialId },
|
|
435
|
+
});
|
|
436
|
+
} catch {
|
|
437
|
+
reject(req, res, options, 503, 'REQUESTER_ASSERTION_UNAVAILABLE', 'introspection_unavailable');
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
if (
|
|
442
|
+
introspection.active !== true
|
|
443
|
+
|| introspection.requesterAccountId !== claims.sub
|
|
444
|
+
|| introspection.agentId !== claims.agentId
|
|
445
|
+
|| introspection.applicationId !== claims.azp
|
|
446
|
+
|| introspection.credentialId !== claims.cid
|
|
447
|
+
|| introspection.jti !== claims.jti
|
|
448
|
+
) {
|
|
449
|
+
reject(req, res, options, 401, 'REQUESTER_ASSERTION_INACTIVE', 'inactive');
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
request.userId = claims.sub;
|
|
454
|
+
request.user = { id: claims.sub };
|
|
455
|
+
request.oxyRequester = {
|
|
456
|
+
userId: claims.sub,
|
|
457
|
+
agentId: claims.agentId,
|
|
458
|
+
applicationId: claims.azp,
|
|
459
|
+
credentialId: claims.cid,
|
|
460
|
+
jti: claims.jti,
|
|
461
|
+
expiresAt: new Date(claims.exp * 1000).toISOString(),
|
|
462
|
+
};
|
|
463
|
+
next();
|
|
464
|
+
};
|
|
465
|
+
}
|