@jeffjassky/oauth-host 0.1.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/LICENSE +21 -0
- package/README.md +126 -0
- package/dist/index.cjs +2787 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.js +2773 -0
- package/dist/index.js.map +1 -0
- package/package.json +79 -0
- package/types/index.d.ts +781 -0
- package/types/test-d.ts +320 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,2773 @@
|
|
|
1
|
+
import mongoose from 'mongoose';
|
|
2
|
+
import express2 from 'express';
|
|
3
|
+
import { createHash, randomBytes, createPrivateKey, createPublicKey, generateKeyPairSync, timingSafeEqual, sign, createHmac } from 'crypto';
|
|
4
|
+
|
|
5
|
+
// src/server/models.ts
|
|
6
|
+
var DEFAULT_NAMES = {
|
|
7
|
+
client: "OAuthClient",
|
|
8
|
+
grant: "OAuthGrant",
|
|
9
|
+
code: "OAuthCode",
|
|
10
|
+
token: "OAuthToken",
|
|
11
|
+
request: "OAuthRequest",
|
|
12
|
+
key: "OAuthKey",
|
|
13
|
+
audit: "OAuthAudit"
|
|
14
|
+
};
|
|
15
|
+
function compile(connection, name, build, collection) {
|
|
16
|
+
const existing = connection.models?.[name];
|
|
17
|
+
if (existing) return existing;
|
|
18
|
+
return connection.model(name, build(), collection);
|
|
19
|
+
}
|
|
20
|
+
function clientSchema() {
|
|
21
|
+
const schema = new mongoose.Schema(
|
|
22
|
+
{
|
|
23
|
+
clientId: { type: String, required: true, unique: true },
|
|
24
|
+
name: { type: String, required: true },
|
|
25
|
+
// `public` exists for CIMD clients, which hold no secret and are bound by
|
|
26
|
+
// PKCE instead. The enum was written with this widening in mind, so it
|
|
27
|
+
// was one member rather than a migration.
|
|
28
|
+
type: { type: String, enum: ["confidential", "public"], default: "confidential" },
|
|
29
|
+
// How the row got here. A `cimd` row is re-derived from the client's own
|
|
30
|
+
// metadata document, so this is what tells the re-fetch path which fields
|
|
31
|
+
// it owns — and `status` is deliberately not one of them.
|
|
32
|
+
registration: { type: String, enum: ["manual", "cimd"], default: "manual" },
|
|
33
|
+
metadataUrl: String,
|
|
34
|
+
metadataFetchedAt: Date,
|
|
35
|
+
metadataEtag: String,
|
|
36
|
+
trusted: { type: Boolean, default: false },
|
|
37
|
+
// An array, not a string: rotation needs two live secrets at once or it
|
|
38
|
+
// cannot be deployed without downtime.
|
|
39
|
+
secrets: [
|
|
40
|
+
{
|
|
41
|
+
_id: false,
|
|
42
|
+
hash: { type: String, required: true },
|
|
43
|
+
label: String,
|
|
44
|
+
createdAt: { type: Date, default: Date.now },
|
|
45
|
+
lastUsedAt: Date,
|
|
46
|
+
retiresAt: Date
|
|
47
|
+
}
|
|
48
|
+
],
|
|
49
|
+
redirectUris: { type: [String], default: [] },
|
|
50
|
+
allowedScopes: { type: [String], default: [] },
|
|
51
|
+
allowedResources: { type: [String], default: [] },
|
|
52
|
+
branding: {
|
|
53
|
+
_id: false,
|
|
54
|
+
logoUrl: String,
|
|
55
|
+
publisher: String,
|
|
56
|
+
homepageUrl: String,
|
|
57
|
+
tosUrl: String,
|
|
58
|
+
privacyUrl: String
|
|
59
|
+
},
|
|
60
|
+
status: { type: String, enum: ["active", "disabled"], default: "active" },
|
|
61
|
+
// Pairwise subjects live on the client, not in a collection of their own:
|
|
62
|
+
// they are per-(client, user) and are read on every token issuance for
|
|
63
|
+
// that client. A Map keyed by user id keeps that a single document read.
|
|
64
|
+
pairwiseSubjects: { type: Map, of: String }
|
|
65
|
+
},
|
|
66
|
+
{ timestamps: true }
|
|
67
|
+
);
|
|
68
|
+
return schema;
|
|
69
|
+
}
|
|
70
|
+
function grantSchema() {
|
|
71
|
+
const schema = new mongoose.Schema(
|
|
72
|
+
{
|
|
73
|
+
userId: { type: mongoose.Schema.Types.Mixed, required: true },
|
|
74
|
+
clientId: { type: String, required: true },
|
|
75
|
+
// `null`, never absent. A missing field and a null field index
|
|
76
|
+
// differently, and the unique index below has to see one consistent
|
|
77
|
+
// value for single-subject mode.
|
|
78
|
+
contextId: { type: String, default: null },
|
|
79
|
+
scopes: { type: [String], default: [] },
|
|
80
|
+
resources: { type: [String], default: [] },
|
|
81
|
+
version: { type: Number, default: 1 },
|
|
82
|
+
lastUsedAt: Date,
|
|
83
|
+
revokedAt: { type: Date, default: null },
|
|
84
|
+
revokedBy: { type: String, enum: ["user", "admin", "system", "client"] }
|
|
85
|
+
},
|
|
86
|
+
{ timestamps: true }
|
|
87
|
+
);
|
|
88
|
+
schema.index(
|
|
89
|
+
{ clientId: 1, userId: 1, contextId: 1 },
|
|
90
|
+
{ unique: true, partialFilterExpression: { revokedAt: null } }
|
|
91
|
+
);
|
|
92
|
+
schema.index({ userId: 1 });
|
|
93
|
+
schema.index({ clientId: 1 });
|
|
94
|
+
return schema;
|
|
95
|
+
}
|
|
96
|
+
function codeSchema() {
|
|
97
|
+
const schema = new mongoose.Schema(
|
|
98
|
+
{
|
|
99
|
+
codeHash: { type: String, required: true, unique: true },
|
|
100
|
+
clientId: { type: String, required: true },
|
|
101
|
+
userId: { type: mongoose.Schema.Types.Mixed, required: true },
|
|
102
|
+
grantId: { type: mongoose.Schema.Types.ObjectId, required: true },
|
|
103
|
+
contextId: { type: String, default: null },
|
|
104
|
+
scopes: { type: [String], default: [] },
|
|
105
|
+
resources: { type: [String], default: [] },
|
|
106
|
+
redirectUri: { type: String, required: true },
|
|
107
|
+
codeChallenge: { type: String, required: true },
|
|
108
|
+
codeChallengeMethod: { type: String, enum: ["S256"], default: "S256" },
|
|
109
|
+
nonce: String,
|
|
110
|
+
authTime: Date,
|
|
111
|
+
// Set, not deleted, on redemption. A consumed code has to stay readable
|
|
112
|
+
// long enough to detect the replay — see `tokens.ts`.
|
|
113
|
+
consumedAt: { type: Date, default: null },
|
|
114
|
+
expiresAt: { type: Date, required: true }
|
|
115
|
+
},
|
|
116
|
+
{ timestamps: { createdAt: true, updatedAt: false } }
|
|
117
|
+
);
|
|
118
|
+
schema.index({ expiresAt: 1 }, { expireAfterSeconds: 0 });
|
|
119
|
+
return schema;
|
|
120
|
+
}
|
|
121
|
+
function tokenSchema() {
|
|
122
|
+
const schema = new mongoose.Schema(
|
|
123
|
+
{
|
|
124
|
+
kind: { type: String, enum: ["access", "refresh"], required: true },
|
|
125
|
+
tokenHash: { type: String, required: true, unique: true },
|
|
126
|
+
clientId: { type: String, required: true },
|
|
127
|
+
userId: { type: mongoose.Schema.Types.Mixed, required: true },
|
|
128
|
+
grantId: { type: mongoose.Schema.Types.ObjectId, required: true },
|
|
129
|
+
contextId: { type: String, default: null },
|
|
130
|
+
scopes: { type: [String], default: [] },
|
|
131
|
+
audience: { type: [String], default: [] },
|
|
132
|
+
familyId: { type: String, required: true },
|
|
133
|
+
parentId: { type: mongoose.Schema.Types.ObjectId, default: null },
|
|
134
|
+
consumedAt: { type: Date, default: null },
|
|
135
|
+
revokedAt: { type: Date, default: null },
|
|
136
|
+
familyExpiresAt: Date,
|
|
137
|
+
expiresAt: { type: Date, required: true }
|
|
138
|
+
},
|
|
139
|
+
{ timestamps: { createdAt: true, updatedAt: false } }
|
|
140
|
+
);
|
|
141
|
+
schema.index({ expiresAt: 1 }, { expireAfterSeconds: 0 });
|
|
142
|
+
schema.index({ familyId: 1 });
|
|
143
|
+
schema.index({ grantId: 1 });
|
|
144
|
+
schema.index({ userId: 1, clientId: 1 });
|
|
145
|
+
return schema;
|
|
146
|
+
}
|
|
147
|
+
function requestSchema() {
|
|
148
|
+
const schema = new mongoose.Schema(
|
|
149
|
+
{
|
|
150
|
+
requestId: { type: String, required: true, unique: true },
|
|
151
|
+
clientId: { type: String, required: true },
|
|
152
|
+
userId: { type: mongoose.Schema.Types.Mixed, required: true },
|
|
153
|
+
redirectUri: { type: String, required: true },
|
|
154
|
+
scopes: { type: [String], default: [] },
|
|
155
|
+
resources: { type: [String], default: [] },
|
|
156
|
+
state: String,
|
|
157
|
+
nonce: String,
|
|
158
|
+
codeChallenge: { type: String, required: true },
|
|
159
|
+
codeChallengeMethod: { type: String, enum: ["S256"], default: "S256" },
|
|
160
|
+
prompt: String,
|
|
161
|
+
maxAge: Number,
|
|
162
|
+
decision: { type: String, enum: ["approved", "denied"] },
|
|
163
|
+
decidedAt: Date,
|
|
164
|
+
expiresAt: { type: Date, required: true }
|
|
165
|
+
},
|
|
166
|
+
{ timestamps: { createdAt: true, updatedAt: false } }
|
|
167
|
+
);
|
|
168
|
+
schema.index({ expiresAt: 1 }, { expireAfterSeconds: 0 });
|
|
169
|
+
return schema;
|
|
170
|
+
}
|
|
171
|
+
function keySchema() {
|
|
172
|
+
const schema = new mongoose.Schema(
|
|
173
|
+
{
|
|
174
|
+
kid: { type: String, required: true, unique: true },
|
|
175
|
+
alg: { type: String, enum: ["ES256"], default: "ES256" },
|
|
176
|
+
publicJwk: { type: mongoose.Schema.Types.Mixed, required: true },
|
|
177
|
+
// Retiring keys stay published in JWKS so tokens signed by them still
|
|
178
|
+
// validate; only `active` is used to sign.
|
|
179
|
+
privateJwk: { type: mongoose.Schema.Types.Mixed, required: true },
|
|
180
|
+
status: { type: String, enum: ["active", "retiring"], default: "active" }
|
|
181
|
+
},
|
|
182
|
+
{ timestamps: { createdAt: true, updatedAt: false } }
|
|
183
|
+
);
|
|
184
|
+
return schema;
|
|
185
|
+
}
|
|
186
|
+
function auditSchema(retentionDays) {
|
|
187
|
+
const schema = new mongoose.Schema(
|
|
188
|
+
{
|
|
189
|
+
type: { type: String, required: true },
|
|
190
|
+
actor: { type: String, enum: ["user", "client", "admin", "system"] },
|
|
191
|
+
clientId: String,
|
|
192
|
+
userId: mongoose.Schema.Types.Mixed,
|
|
193
|
+
grantId: mongoose.Schema.Types.ObjectId,
|
|
194
|
+
ip: String,
|
|
195
|
+
meta: mongoose.Schema.Types.Mixed
|
|
196
|
+
},
|
|
197
|
+
{ timestamps: { createdAt: true, updatedAt: false } }
|
|
198
|
+
);
|
|
199
|
+
schema.index({ createdAt: 1 }, { expireAfterSeconds: retentionDays * 86400 });
|
|
200
|
+
schema.index({ clientId: 1, createdAt: -1 });
|
|
201
|
+
schema.index({ userId: 1, createdAt: -1 });
|
|
202
|
+
return schema;
|
|
203
|
+
}
|
|
204
|
+
function createModels({
|
|
205
|
+
connection = mongoose,
|
|
206
|
+
modelNames = {},
|
|
207
|
+
collectionPrefix = "oauth_",
|
|
208
|
+
auditRetentionDays = 400
|
|
209
|
+
} = {}) {
|
|
210
|
+
const names = { ...DEFAULT_NAMES, ...modelNames };
|
|
211
|
+
const c = (suffix) => `${collectionPrefix}${suffix}`;
|
|
212
|
+
return {
|
|
213
|
+
Client: compile(connection, names.client, clientSchema, c("clients")),
|
|
214
|
+
Grant: compile(connection, names.grant, grantSchema, c("grants")),
|
|
215
|
+
Code: compile(connection, names.code, codeSchema, c("codes")),
|
|
216
|
+
Token: compile(connection, names.token, tokenSchema, c("tokens")),
|
|
217
|
+
Request: compile(connection, names.request, requestSchema, c("requests")),
|
|
218
|
+
Key: compile(connection, names.key, keySchema, c("keys")),
|
|
219
|
+
Audit: compile(
|
|
220
|
+
connection,
|
|
221
|
+
names.audit,
|
|
222
|
+
() => auditSchema(auditRetentionDays),
|
|
223
|
+
c("audit")
|
|
224
|
+
)
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
async function syncModelIndexes(models) {
|
|
228
|
+
await Promise.all(Object.values(models).map((m) => m.init()));
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// src/server/config.ts
|
|
232
|
+
var NOOP_LOGGER = {
|
|
233
|
+
debug() {
|
|
234
|
+
},
|
|
235
|
+
info() {
|
|
236
|
+
},
|
|
237
|
+
warn() {
|
|
238
|
+
},
|
|
239
|
+
error() {
|
|
240
|
+
}
|
|
241
|
+
};
|
|
242
|
+
var DEFAULT_TTL = {
|
|
243
|
+
code: 60,
|
|
244
|
+
accessToken: 3600,
|
|
245
|
+
refreshToken: 60 * 86400,
|
|
246
|
+
refreshAbsolute: 180 * 86400,
|
|
247
|
+
authorizationRequest: 600
|
|
248
|
+
};
|
|
249
|
+
var OIDC_SCOPES = /* @__PURE__ */ new Set(["openid", "profile", "email"]);
|
|
250
|
+
function defaultResolveUser(req) {
|
|
251
|
+
const { authUserId, user } = req;
|
|
252
|
+
const id = user?._id ?? user?.id ?? authUserId;
|
|
253
|
+
if (!id) return null;
|
|
254
|
+
return {
|
|
255
|
+
id,
|
|
256
|
+
email: user?.email,
|
|
257
|
+
displayName: user?.displayName ?? null,
|
|
258
|
+
avatarUrl: user?.avatarUrl ?? null,
|
|
259
|
+
authTime: user?.authTime,
|
|
260
|
+
isAdmin: Boolean(user?.isAdmin)
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
function createUserAdapter({ resolveUser = defaultResolveUser, loadUser } = {}) {
|
|
264
|
+
if (typeof resolveUser !== "function") {
|
|
265
|
+
throw new TypeError("oauth-host: user adapter `resolveUser` must be a function");
|
|
266
|
+
}
|
|
267
|
+
if (loadUser !== void 0 && typeof loadUser !== "function") {
|
|
268
|
+
throw new TypeError("oauth-host: user adapter `loadUser` must be a function");
|
|
269
|
+
}
|
|
270
|
+
return { resolveUser, ...loadUser ? { loadUser } : {} };
|
|
271
|
+
}
|
|
272
|
+
function normalizeScopes(input) {
|
|
273
|
+
if (!Array.isArray(input) || input.length === 0) {
|
|
274
|
+
throw new TypeError("oauth-host: `scopes` must be a non-empty array");
|
|
275
|
+
}
|
|
276
|
+
return input.map((s) => {
|
|
277
|
+
const spec = typeof s === "string" ? { id: s } : { ...s };
|
|
278
|
+
if (!spec.id || typeof spec.id !== "string") {
|
|
279
|
+
throw new TypeError(`oauth-host: every scope needs a string \`id\` (got ${JSON.stringify(s)})`);
|
|
280
|
+
}
|
|
281
|
+
if (/[\s"\\]/.test(spec.id)) {
|
|
282
|
+
throw new TypeError(`oauth-host: scope id must not contain whitespace or quotes: '${spec.id}'`);
|
|
283
|
+
}
|
|
284
|
+
return {
|
|
285
|
+
...spec,
|
|
286
|
+
label: spec.label ?? spec.id,
|
|
287
|
+
oidc: spec.oidc ?? OIDC_SCOPES.has(spec.id)
|
|
288
|
+
};
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
function normalizeDefaultScopes(input, scopeIndex) {
|
|
292
|
+
if (input === void 0) return void 0;
|
|
293
|
+
if (!Array.isArray(input) || input.length === 0) {
|
|
294
|
+
throw new TypeError(
|
|
295
|
+
"oauth-host: `defaultScopes`, when given, must be a non-empty array of catalog scope ids. Omit the key entirely to require every client to send `scope`."
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
for (const id of input) {
|
|
299
|
+
if (typeof id !== "string" || !scopeIndex.has(id)) {
|
|
300
|
+
throw new TypeError(
|
|
301
|
+
`oauth-host: defaultScopes contains '${String(id)}', which is not in the configured scope catalog`
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
return [...input];
|
|
306
|
+
}
|
|
307
|
+
function normalizeResources(input) {
|
|
308
|
+
if (!Array.isArray(input) || input.length === 0) {
|
|
309
|
+
throw new TypeError(
|
|
310
|
+
"oauth-host: `resources` must list at least one API. MCP clients send `resource` on every request (RFC 8707) and tokens are audience-bound to it."
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
return input.map((r) => {
|
|
314
|
+
const spec = typeof r === "string" ? { id: r } : { ...r };
|
|
315
|
+
let url;
|
|
316
|
+
try {
|
|
317
|
+
url = new URL(spec.id);
|
|
318
|
+
} catch {
|
|
319
|
+
throw new TypeError(`oauth-host: resource id must be an absolute URI: '${spec.id}'`);
|
|
320
|
+
}
|
|
321
|
+
if (url.hash) {
|
|
322
|
+
throw new TypeError(`oauth-host: resource id must not contain a fragment: '${spec.id}'`);
|
|
323
|
+
}
|
|
324
|
+
return { ...spec, label: spec.label ?? spec.id };
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
function memoryStore() {
|
|
328
|
+
const buckets = /* @__PURE__ */ new Map();
|
|
329
|
+
return {
|
|
330
|
+
async hit(key, windowMs) {
|
|
331
|
+
const now = Date.now();
|
|
332
|
+
const found = buckets.get(key);
|
|
333
|
+
if (!found || found.resetAt <= now) {
|
|
334
|
+
const fresh = { count: 1, resetAt: now + windowMs };
|
|
335
|
+
buckets.set(key, fresh);
|
|
336
|
+
if (buckets.size > 1e4) {
|
|
337
|
+
for (const [k, v] of buckets) if (v.resetAt <= now) buckets.delete(k);
|
|
338
|
+
}
|
|
339
|
+
return fresh;
|
|
340
|
+
}
|
|
341
|
+
found.count += 1;
|
|
342
|
+
return found;
|
|
343
|
+
}
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
function parseCimdHost(raw) {
|
|
347
|
+
if (typeof raw !== "string" || !raw.trim()) {
|
|
348
|
+
throw new TypeError(
|
|
349
|
+
`oauth-host: clientIdMetadata.allowedHosts entries must be non-empty strings (got ${JSON.stringify(raw)})`
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
const entry = raw.trim().toLowerCase();
|
|
353
|
+
if (/[/\\?#@\s]/.test(entry) || entry.includes("://")) {
|
|
354
|
+
throw new TypeError(
|
|
355
|
+
`oauth-host: clientIdMetadata.allowedHosts takes a host, not a URL: '${raw}'. Write 'claude.ai' for that host exactly, or '.claude.ai' to include subdomains.`
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
const subdomains = entry.startsWith(".");
|
|
359
|
+
const bare = subdomains ? entry.slice(1) : entry;
|
|
360
|
+
const parts = /^([^:]+)(?::(\d+))?$/.exec(bare);
|
|
361
|
+
if (!parts || !parts[1]) {
|
|
362
|
+
throw new TypeError(`oauth-host: clientIdMetadata.allowedHosts entry is not a hostname: '${raw}'`);
|
|
363
|
+
}
|
|
364
|
+
if (parts[1].includes("*")) {
|
|
365
|
+
throw new TypeError(
|
|
366
|
+
`oauth-host: clientIdMetadata.allowedHosts does not accept wildcards: '${raw}'. Use a leading dot ('.claude.ai') to admit subdomains of one host.`
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
return { host: parts[1], port: parts[2] ?? "", subdomains };
|
|
370
|
+
}
|
|
371
|
+
function resolveCimd(input, scopeIndex) {
|
|
372
|
+
const off = {
|
|
373
|
+
enabled: false,
|
|
374
|
+
allowedHosts: [],
|
|
375
|
+
cacheTtlMs: 36e5,
|
|
376
|
+
fetchTimeoutMs: 5e3,
|
|
377
|
+
maxBytes: 65536,
|
|
378
|
+
allowedScopes: [],
|
|
379
|
+
failures: /* @__PURE__ */ new Map()
|
|
380
|
+
};
|
|
381
|
+
if (!input || !input.enabled) return off;
|
|
382
|
+
if (!Array.isArray(input.allowedHosts) || input.allowedHosts.length === 0) {
|
|
383
|
+
throw new TypeError(
|
|
384
|
+
'oauth-host: `clientIdMetadata.allowedHosts` must list at least one host when clientIdMetadata is enabled. There is no implicit "any host": the server fetches these URLs itself, on an unauthenticated request parameter.'
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
const allowedScopes = input.allowedScopes ?? [...scopeIndex.keys()];
|
|
388
|
+
if (!Array.isArray(allowedScopes) || allowedScopes.length === 0) {
|
|
389
|
+
throw new TypeError("oauth-host: `clientIdMetadata.allowedScopes`, when given, must be non-empty");
|
|
390
|
+
}
|
|
391
|
+
for (const id of allowedScopes) {
|
|
392
|
+
if (typeof id !== "string" || !scopeIndex.has(id)) {
|
|
393
|
+
throw new TypeError(
|
|
394
|
+
`oauth-host: clientIdMetadata.allowedScopes contains '${String(id)}', which is not in the configured scope catalog`
|
|
395
|
+
);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
const positive = (value, key, fallback) => {
|
|
399
|
+
if (value === void 0) return fallback;
|
|
400
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
|
|
401
|
+
throw new TypeError(`oauth-host: \`clientIdMetadata.${key}\` must be a positive number`);
|
|
402
|
+
}
|
|
403
|
+
return value;
|
|
404
|
+
};
|
|
405
|
+
return {
|
|
406
|
+
enabled: true,
|
|
407
|
+
allowedHosts: input.allowedHosts.map(parseCimdHost),
|
|
408
|
+
cacheTtlMs: positive(input.cacheTtlMs, "cacheTtlMs", 36e5),
|
|
409
|
+
fetchTimeoutMs: positive(input.fetchTimeoutMs, "fetchTimeoutMs", 5e3),
|
|
410
|
+
maxBytes: positive(input.maxBytes, "maxBytes", 65536),
|
|
411
|
+
allowedScopes: [...allowedScopes],
|
|
412
|
+
failures: /* @__PURE__ */ new Map()
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
function resolveConfig(config) {
|
|
416
|
+
if (!config || typeof config !== "object") {
|
|
417
|
+
throw new TypeError("oauth-host: createOAuthHost(config) requires a config object");
|
|
418
|
+
}
|
|
419
|
+
const {
|
|
420
|
+
connection,
|
|
421
|
+
issuer,
|
|
422
|
+
mountPath = "/oauth",
|
|
423
|
+
consentUrl,
|
|
424
|
+
loginUrl,
|
|
425
|
+
returnParam = "next",
|
|
426
|
+
userAdapter,
|
|
427
|
+
resolveUser,
|
|
428
|
+
loadUser,
|
|
429
|
+
grantContext,
|
|
430
|
+
claims,
|
|
431
|
+
subjectMode = "public",
|
|
432
|
+
pairwiseSalt,
|
|
433
|
+
signing = {},
|
|
434
|
+
rateLimits = {},
|
|
435
|
+
tokenCache = {},
|
|
436
|
+
modelNames,
|
|
437
|
+
collectionPrefix,
|
|
438
|
+
audit = {},
|
|
439
|
+
cors = {},
|
|
440
|
+
clockSkewMs = 0,
|
|
441
|
+
clientIdMetadata,
|
|
442
|
+
logger = NOOP_LOGGER,
|
|
443
|
+
track = () => {
|
|
444
|
+
}
|
|
445
|
+
} = config;
|
|
446
|
+
if (typeof issuer !== "string" || !/^https?:\/\//.test(issuer)) {
|
|
447
|
+
throw new TypeError(`oauth-host: \`issuer\` must be an absolute http(s) URL (got ${JSON.stringify(issuer)})`);
|
|
448
|
+
}
|
|
449
|
+
if (issuer.endsWith("/")) {
|
|
450
|
+
throw new TypeError(`oauth-host: \`issuer\` must not end with a slash: '${issuer}'`);
|
|
451
|
+
}
|
|
452
|
+
if (!consentUrl || typeof consentUrl !== "string") {
|
|
453
|
+
throw new TypeError("oauth-host: `consentUrl` is required \u2014 it is where /authorize sends the user");
|
|
454
|
+
}
|
|
455
|
+
if (subjectMode === "pairwise" && !pairwiseSalt) {
|
|
456
|
+
throw new TypeError(
|
|
457
|
+
'oauth-host: `pairwiseSalt` is required when subjectMode is "pairwise". It is permanent: changing it changes every `sub` a partner has stored.'
|
|
458
|
+
);
|
|
459
|
+
}
|
|
460
|
+
if (userAdapter && (resolveUser || loadUser)) {
|
|
461
|
+
throw new TypeError(
|
|
462
|
+
"oauth-host: pass either `userAdapter` or the `resolveUser`/`loadUser` shorthands, not both"
|
|
463
|
+
);
|
|
464
|
+
}
|
|
465
|
+
if (typeof track !== "function") {
|
|
466
|
+
throw new TypeError("oauth-host: `track` must be a function");
|
|
467
|
+
}
|
|
468
|
+
if (grantContext && (typeof grantContext.list !== "function" || typeof grantContext.verify !== "function")) {
|
|
469
|
+
throw new TypeError("oauth-host: `grantContext` needs both `list()` and `verify()`");
|
|
470
|
+
}
|
|
471
|
+
const scopes = normalizeScopes(config.scopes);
|
|
472
|
+
const resources = normalizeResources(config.resources);
|
|
473
|
+
const scopeIndex = new Map(scopes.map((s) => [s.id, s]));
|
|
474
|
+
for (const r of resources) {
|
|
475
|
+
for (const s of r.scopes ?? []) {
|
|
476
|
+
if (!scopeIndex.has(s)) {
|
|
477
|
+
throw new TypeError(`oauth-host: resource '${r.id}' lists scope '${s}' which is not in the catalog`);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
const defaultScopes = normalizeDefaultScopes(config.defaultScopes, scopeIndex);
|
|
482
|
+
const models = createModels({
|
|
483
|
+
connection,
|
|
484
|
+
modelNames,
|
|
485
|
+
collectionPrefix,
|
|
486
|
+
auditRetentionDays: audit.retentionDays ?? 400
|
|
487
|
+
});
|
|
488
|
+
const adapter = userAdapter ? createUserAdapter(userAdapter) : createUserAdapter({ resolveUser, loadUser });
|
|
489
|
+
if (!adapter.loadUser) {
|
|
490
|
+
logger.warn?.(
|
|
491
|
+
"oauth-host: no `loadUser` adapter \u2014 `profile`/`email` claims on /userinfo and the id_token will be empty, because those endpoints have no host session to read."
|
|
492
|
+
);
|
|
493
|
+
}
|
|
494
|
+
const store = rateLimits.store ?? memoryStore();
|
|
495
|
+
const normalizedMount = `/${String(mountPath).replace(/^\/+|\/+$/g, "")}`;
|
|
496
|
+
const ctx = {
|
|
497
|
+
models,
|
|
498
|
+
issuer,
|
|
499
|
+
mountPath: normalizedMount === "/" ? "" : normalizedMount,
|
|
500
|
+
resources,
|
|
501
|
+
scopes,
|
|
502
|
+
scopeIndex,
|
|
503
|
+
defaultScopes,
|
|
504
|
+
consentUrl,
|
|
505
|
+
loginUrl,
|
|
506
|
+
returnParam,
|
|
507
|
+
resolveUser: adapter.resolveUser,
|
|
508
|
+
async loadUser(userId) {
|
|
509
|
+
return await adapter.loadUser?.(userId) ?? { id: userId };
|
|
510
|
+
},
|
|
511
|
+
grantContext,
|
|
512
|
+
claims,
|
|
513
|
+
ttl: { ...DEFAULT_TTL, ...config.ttl },
|
|
514
|
+
subjectMode,
|
|
515
|
+
pairwiseSalt,
|
|
516
|
+
signing,
|
|
517
|
+
rateLimits: {
|
|
518
|
+
token: rateLimits.token ?? { max: 60, windowMs: 6e4 },
|
|
519
|
+
authorize: rateLimits.authorize ?? { max: 60, windowMs: 6e4 },
|
|
520
|
+
consent: rateLimits.consent ?? { max: 60, windowMs: 6e4 },
|
|
521
|
+
hit: (key, windowMs) => store.hit(key, windowMs)
|
|
522
|
+
},
|
|
523
|
+
tokenCacheTtlMs: tokenCache.ttlMs ?? 0,
|
|
524
|
+
cors: { tokenEndpoint: cors.tokenEndpoint ?? false, origins: cors.origins ?? [] },
|
|
525
|
+
clockSkewMs,
|
|
526
|
+
cimd: resolveCimd(clientIdMetadata, scopeIndex),
|
|
527
|
+
logger,
|
|
528
|
+
track,
|
|
529
|
+
async audit(entry) {
|
|
530
|
+
try {
|
|
531
|
+
await models.Audit.create(entry);
|
|
532
|
+
} catch (err) {
|
|
533
|
+
logger.error?.({ err, entry }, "oauth-host: audit write failed");
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
};
|
|
537
|
+
return ctx;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// src/server/errors.ts
|
|
541
|
+
var OAuthError = class extends Error {
|
|
542
|
+
status;
|
|
543
|
+
code;
|
|
544
|
+
description;
|
|
545
|
+
/** e.g. `WWW-Authenticate` on a 401 from the resource server. */
|
|
546
|
+
headers;
|
|
547
|
+
constructor(status, code, description, opts) {
|
|
548
|
+
super(description ? `${code}: ${description}` : code);
|
|
549
|
+
this.name = "OAuthError";
|
|
550
|
+
this.status = status;
|
|
551
|
+
this.code = code;
|
|
552
|
+
this.description = description;
|
|
553
|
+
this.headers = opts?.headers;
|
|
554
|
+
}
|
|
555
|
+
toBody() {
|
|
556
|
+
return {
|
|
557
|
+
error: this.code,
|
|
558
|
+
...this.description ? { error_description: this.description } : {}
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
};
|
|
562
|
+
var invalidRequest = (d) => new OAuthError(400, "invalid_request", d);
|
|
563
|
+
var invalidGrant = (d) => new OAuthError(400, "invalid_grant", d);
|
|
564
|
+
var invalidScope = (d) => new OAuthError(400, "invalid_scope", d);
|
|
565
|
+
var unsupportedGrantType = (d) => new OAuthError(400, "unsupported_grant_type", d);
|
|
566
|
+
var accessDenied = (d) => new OAuthError(403, "access_denied", d);
|
|
567
|
+
var UnredirectableError = class extends OAuthError {
|
|
568
|
+
constructor(code, description) {
|
|
569
|
+
super(400, code, description);
|
|
570
|
+
this.name = "UnredirectableError";
|
|
571
|
+
}
|
|
572
|
+
};
|
|
573
|
+
var RedirectableAuthError = class extends OAuthError {
|
|
574
|
+
redirectUri;
|
|
575
|
+
state;
|
|
576
|
+
constructor(code, description, redirectUri, state) {
|
|
577
|
+
super(400, code, description);
|
|
578
|
+
this.name = "RedirectableAuthError";
|
|
579
|
+
this.redirectUri = redirectUri;
|
|
580
|
+
this.state = state;
|
|
581
|
+
}
|
|
582
|
+
/** The full `redirect_uri` to 302 to, error parameters attached. */
|
|
583
|
+
toRedirect(issuer) {
|
|
584
|
+
const url = new URL(this.redirectUri);
|
|
585
|
+
url.searchParams.set("error", this.code);
|
|
586
|
+
if (this.description) url.searchParams.set("error_description", this.description);
|
|
587
|
+
if (this.state !== void 0) url.searchParams.set("state", this.state);
|
|
588
|
+
url.searchParams.set("iss", issuer);
|
|
589
|
+
return url.toString();
|
|
590
|
+
}
|
|
591
|
+
};
|
|
592
|
+
function wrap(logger, handler) {
|
|
593
|
+
return async (req, res, next) => {
|
|
594
|
+
try {
|
|
595
|
+
await handler(req, res, next);
|
|
596
|
+
} catch (err) {
|
|
597
|
+
if (err instanceof OAuthError) {
|
|
598
|
+
if (res.headersSent) return next(err);
|
|
599
|
+
if (err.headers) res.set(err.headers);
|
|
600
|
+
return res.status(err.status).json(err.toBody());
|
|
601
|
+
}
|
|
602
|
+
logger.error?.({ err, path: req.originalUrl }, "oauth-host: request failed");
|
|
603
|
+
if (res.headersSent) return next(err);
|
|
604
|
+
res.status(500).json({ error: "server_error" });
|
|
605
|
+
}
|
|
606
|
+
};
|
|
607
|
+
}
|
|
608
|
+
var ENDPOINTS = [
|
|
609
|
+
["authorization_endpoint", "/authorize"],
|
|
610
|
+
["token_endpoint", "/token"],
|
|
611
|
+
["revocation_endpoint", "/revoke"],
|
|
612
|
+
["userinfo_endpoint", "/userinfo"],
|
|
613
|
+
["jwks_uri", "/jwks"]
|
|
614
|
+
];
|
|
615
|
+
var WELL_KNOWN_AS = "/.well-known/oauth-authorization-server";
|
|
616
|
+
var WELL_KNOWN_OIDC = "/.well-known/openid-configuration";
|
|
617
|
+
var WELL_KNOWN_RESOURCE = "/.well-known/oauth-protected-resource";
|
|
618
|
+
function protectedResourceMetadataUrl(issuer, resourceId) {
|
|
619
|
+
return `${issuer}${WELL_KNOWN_RESOURCE}${identifierPath(resourceId)}`;
|
|
620
|
+
}
|
|
621
|
+
function identifierPath(identifier) {
|
|
622
|
+
const { pathname } = new URL(identifier);
|
|
623
|
+
return pathname === "/" ? "" : pathname.replace(/\/+$/, "");
|
|
624
|
+
}
|
|
625
|
+
function escapeRe(literal) {
|
|
626
|
+
return literal.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
627
|
+
}
|
|
628
|
+
function wellKnownPattern(wellKnown) {
|
|
629
|
+
return new RegExp(`^${escapeRe(wellKnown)}(?:/.*)?$`);
|
|
630
|
+
}
|
|
631
|
+
function wellKnownHandler(ctx, wellKnown, bare, bySuffix, describeMiss) {
|
|
632
|
+
return wrap(ctx.logger, (req, res) => {
|
|
633
|
+
const suffix = req.path.slice(wellKnown.length).replace(/^\/+/, "").replace(/\/+$/, "");
|
|
634
|
+
if (!suffix) return res.json(bare);
|
|
635
|
+
const doc = bySuffix.get(suffix);
|
|
636
|
+
if (!doc) {
|
|
637
|
+
return res.status(404).json({ error: "not_found", error_description: describeMiss(suffix) });
|
|
638
|
+
}
|
|
639
|
+
return res.json(doc);
|
|
640
|
+
});
|
|
641
|
+
}
|
|
642
|
+
function authorizationServerMetadata(ctx, mountPath) {
|
|
643
|
+
const base = `${ctx.issuer}${mountPath === "/" ? "" : mountPath}`;
|
|
644
|
+
const endpoints = Object.fromEntries(ENDPOINTS.map(([key, path]) => [key, `${base}${path}`]));
|
|
645
|
+
return {
|
|
646
|
+
issuer: ctx.issuer,
|
|
647
|
+
...endpoints,
|
|
648
|
+
scopes_supported: ctx.scopes.map((s) => s.id),
|
|
649
|
+
response_types_supported: ["code"],
|
|
650
|
+
grant_types_supported: ["authorization_code", "refresh_token"],
|
|
651
|
+
// S256 only. `plain` is not implemented anywhere in this package, so
|
|
652
|
+
// advertising it would be a lie a client would discover at redemption.
|
|
653
|
+
code_challenge_methods_supported: ["S256"],
|
|
654
|
+
// `none` is advertised only when CIMD is on. Listing it unconditionally
|
|
655
|
+
// would tell every client that secretless authentication is available here,
|
|
656
|
+
// and the only clients that can use it are the ones this server would then
|
|
657
|
+
// refuse to register.
|
|
658
|
+
token_endpoint_auth_methods_supported: [
|
|
659
|
+
"client_secret_basic",
|
|
660
|
+
"client_secret_post",
|
|
661
|
+
...ctx.cimd.enabled ? ["none"] : []
|
|
662
|
+
],
|
|
663
|
+
// What tells Claude and ChatGPT to skip registration entirely and send
|
|
664
|
+
// their metadata document URL as `client_id`.
|
|
665
|
+
...ctx.cimd.enabled ? { client_id_metadata_document_supported: true } : {},
|
|
666
|
+
subject_types_supported: [ctx.subjectMode],
|
|
667
|
+
id_token_signing_alg_values_supported: ["ES256"],
|
|
668
|
+
// RFC 9207. Advertising it is what lets a client REQUIRE `iss` on the
|
|
669
|
+
// authorization response and so refuse a mix-up attack.
|
|
670
|
+
authorization_response_iss_parameter_supported: true
|
|
671
|
+
};
|
|
672
|
+
}
|
|
673
|
+
function protectedResourceMetadata(ctx, resource) {
|
|
674
|
+
return {
|
|
675
|
+
resource: resource.id,
|
|
676
|
+
authorization_servers: [ctx.issuer],
|
|
677
|
+
scopes_supported: resource.scopes ?? ctx.scopes.map((s) => s.id),
|
|
678
|
+
bearer_methods_supported: ["header"]
|
|
679
|
+
};
|
|
680
|
+
}
|
|
681
|
+
function createDiscoveryRouter(ctx, mountPath = "/oauth") {
|
|
682
|
+
const router = express2.Router();
|
|
683
|
+
const asMetadata = authorizationServerMetadata(ctx, mountPath);
|
|
684
|
+
const issuerSuffix = identifierPath(ctx.issuer).replace(/^\//, "");
|
|
685
|
+
const asBySuffix = new Map(issuerSuffix ? [[issuerSuffix, asMetadata]] : []);
|
|
686
|
+
const notThisIssuer = (suffix) => `This authorization server's issuer is '${ctx.issuer}', which does not publish at '${suffix}'`;
|
|
687
|
+
const serveAs = wrap(ctx.logger, (_req, res) => res.json(asMetadata));
|
|
688
|
+
router.get(
|
|
689
|
+
wellKnownPattern(WELL_KNOWN_AS),
|
|
690
|
+
wellKnownHandler(ctx, WELL_KNOWN_AS, asMetadata, asBySuffix, notThisIssuer)
|
|
691
|
+
);
|
|
692
|
+
router.get(
|
|
693
|
+
wellKnownPattern(WELL_KNOWN_OIDC),
|
|
694
|
+
wellKnownHandler(ctx, WELL_KNOWN_OIDC, asMetadata, asBySuffix, notThisIssuer)
|
|
695
|
+
);
|
|
696
|
+
if (issuerSuffix) {
|
|
697
|
+
router.get(new RegExp(`^${escapeRe(`/${issuerSuffix}${WELL_KNOWN_OIDC}`)}/?$`), serveAs);
|
|
698
|
+
}
|
|
699
|
+
const bySuffix = new Map(
|
|
700
|
+
ctx.resources.map((r) => [identifierPath(r.id).replace(/^\//, ""), protectedResourceMetadata(ctx, r)])
|
|
701
|
+
);
|
|
702
|
+
const firstResource = protectedResourceMetadata(ctx, ctx.resources[0]);
|
|
703
|
+
router.get(
|
|
704
|
+
wellKnownPattern(WELL_KNOWN_RESOURCE),
|
|
705
|
+
wellKnownHandler(
|
|
706
|
+
ctx,
|
|
707
|
+
WELL_KNOWN_RESOURCE,
|
|
708
|
+
firstResource,
|
|
709
|
+
bySuffix,
|
|
710
|
+
(suffix) => `No resource is registered at '${suffix}'`
|
|
711
|
+
)
|
|
712
|
+
);
|
|
713
|
+
return router;
|
|
714
|
+
}
|
|
715
|
+
function randomToken(bytes = 32) {
|
|
716
|
+
return randomBytes(bytes).toString("base64url");
|
|
717
|
+
}
|
|
718
|
+
function sha256(value) {
|
|
719
|
+
return createHash("sha256").update(value).digest("base64url");
|
|
720
|
+
}
|
|
721
|
+
function safeEqual(a, b) {
|
|
722
|
+
const bufA = Buffer.from(sha256(a));
|
|
723
|
+
const bufB = Buffer.from(sha256(b));
|
|
724
|
+
if (bufA.length !== bufB.length) return false;
|
|
725
|
+
return timingSafeEqual(bufA, bufB);
|
|
726
|
+
}
|
|
727
|
+
function verifyPkce(verifier, challenge2) {
|
|
728
|
+
if (verifier.length < 43 || verifier.length > 128) return false;
|
|
729
|
+
if (!/^[A-Za-z0-9\-._~]+$/.test(verifier)) return false;
|
|
730
|
+
return safeEqual(sha256(verifier), challenge2);
|
|
731
|
+
}
|
|
732
|
+
function pairwiseSubject(userId, clientId, salt) {
|
|
733
|
+
return createHmac("sha256", salt).update(`${clientId}:${userId}`).digest("base64url");
|
|
734
|
+
}
|
|
735
|
+
function generateClientId() {
|
|
736
|
+
return randomToken(16);
|
|
737
|
+
}
|
|
738
|
+
function generateClientSecret() {
|
|
739
|
+
return randomToken(32);
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
// src/server/redirect-uris.ts
|
|
743
|
+
var LOOPBACK_HOSTS = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "[::1]"]);
|
|
744
|
+
function redirectUriMatches(registered, presented) {
|
|
745
|
+
if (registered === presented) return true;
|
|
746
|
+
let a;
|
|
747
|
+
let b;
|
|
748
|
+
try {
|
|
749
|
+
a = new URL(registered);
|
|
750
|
+
b = new URL(presented);
|
|
751
|
+
} catch {
|
|
752
|
+
return false;
|
|
753
|
+
}
|
|
754
|
+
if (!LOOPBACK_HOSTS.has(a.hostname) || !LOOPBACK_HOSTS.has(b.hostname)) return false;
|
|
755
|
+
return a.protocol === b.protocol && a.hostname === b.hostname && a.username === b.username && a.password === b.password && a.pathname === b.pathname && a.search === b.search && a.hash === b.hash;
|
|
756
|
+
}
|
|
757
|
+
function redirectUriRegistered(registered, presented) {
|
|
758
|
+
return registered.some((entry) => redirectUriMatches(entry, presented));
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
// src/server/services/tokens.ts
|
|
762
|
+
function familyFor(codeHash) {
|
|
763
|
+
return sha256(`family:${codeHash}`);
|
|
764
|
+
}
|
|
765
|
+
var parseScopes = (scope) => (scope ?? "").split(/\s+/).filter(Boolean);
|
|
766
|
+
var invalidTarget = (d) => new OAuthError(400, "invalid_target", d);
|
|
767
|
+
async function liveGrant(ctx, grantId) {
|
|
768
|
+
const grant = await ctx.models.Grant.findById(grantId);
|
|
769
|
+
if (!grant || grant.revokedAt) {
|
|
770
|
+
throw invalidGrant("the authorization behind this token has been revoked");
|
|
771
|
+
}
|
|
772
|
+
return grant;
|
|
773
|
+
}
|
|
774
|
+
async function consumeCode(ctx, rawCode, opts) {
|
|
775
|
+
const codeHash = sha256(rawCode);
|
|
776
|
+
const now = /* @__PURE__ */ new Date();
|
|
777
|
+
const code = await ctx.models.Code.findOneAndUpdate(
|
|
778
|
+
{ codeHash, consumedAt: null },
|
|
779
|
+
{ $set: { consumedAt: now } },
|
|
780
|
+
{ returnDocument: "after" }
|
|
781
|
+
);
|
|
782
|
+
if (!code) {
|
|
783
|
+
const replayed = await ctx.models.Code.findOne({ codeHash });
|
|
784
|
+
if (replayed) {
|
|
785
|
+
const familyId = familyFor(codeHash);
|
|
786
|
+
const revoked = await revokeFamily(ctx, familyId, "code_replay");
|
|
787
|
+
await ctx.audit({
|
|
788
|
+
type: "code_replay",
|
|
789
|
+
actor: "client",
|
|
790
|
+
clientId: opts.clientId,
|
|
791
|
+
userId: replayed.userId,
|
|
792
|
+
grantId: replayed.grantId,
|
|
793
|
+
meta: { familyId, tokensRevoked: revoked }
|
|
794
|
+
});
|
|
795
|
+
ctx.logger.warn?.(
|
|
796
|
+
{ clientId: opts.clientId, familyId },
|
|
797
|
+
"oauth-host: authorization code replayed \u2014 family revoked"
|
|
798
|
+
);
|
|
799
|
+
}
|
|
800
|
+
throw invalidGrant("authorization code is invalid, expired, or already used");
|
|
801
|
+
}
|
|
802
|
+
if (code.expiresAt.getTime() <= now.getTime()) {
|
|
803
|
+
throw invalidGrant("authorization code has expired");
|
|
804
|
+
}
|
|
805
|
+
if (code.clientId !== opts.clientId) {
|
|
806
|
+
throw invalidGrant("authorization code was issued to a different client");
|
|
807
|
+
}
|
|
808
|
+
if (!redirectUriMatches(code.redirectUri, opts.redirectUri)) {
|
|
809
|
+
throw invalidGrant("redirect_uri does not match the one the code was issued for");
|
|
810
|
+
}
|
|
811
|
+
if (!verifyPkce(opts.codeVerifier ?? "", code.codeChallenge)) {
|
|
812
|
+
throw invalidGrant("PKCE verification failed");
|
|
813
|
+
}
|
|
814
|
+
return code;
|
|
815
|
+
}
|
|
816
|
+
async function mintPair(ctx, args) {
|
|
817
|
+
const now = Date.now();
|
|
818
|
+
const accessToken = randomToken();
|
|
819
|
+
const refreshToken = randomToken();
|
|
820
|
+
const common = {
|
|
821
|
+
clientId: args.clientId,
|
|
822
|
+
userId: args.userId,
|
|
823
|
+
grantId: args.grant._id,
|
|
824
|
+
contextId: args.contextId,
|
|
825
|
+
scopes: args.scopes,
|
|
826
|
+
audience: args.audience,
|
|
827
|
+
familyId: args.familyId
|
|
828
|
+
};
|
|
829
|
+
await ctx.models.Token.create({
|
|
830
|
+
...common,
|
|
831
|
+
kind: "access",
|
|
832
|
+
tokenHash: sha256(accessToken),
|
|
833
|
+
expiresAt: new Date(now + ctx.ttl.accessToken * 1e3)
|
|
834
|
+
});
|
|
835
|
+
await ctx.models.Token.create({
|
|
836
|
+
...common,
|
|
837
|
+
kind: "refresh",
|
|
838
|
+
tokenHash: sha256(refreshToken),
|
|
839
|
+
parentId: args.parentId ?? null,
|
|
840
|
+
// `expiresAt` is the SLIDING window and is what the TTL index reaps;
|
|
841
|
+
// `familyExpiresAt` is the absolute ceiling and is copied unchanged onto
|
|
842
|
+
// every rotation, so no amount of refreshing extends it.
|
|
843
|
+
expiresAt: new Date(now + ctx.ttl.refreshToken * 1e3),
|
|
844
|
+
familyExpiresAt: args.familyExpiresAt
|
|
845
|
+
});
|
|
846
|
+
await ctx.models.Grant.updateOne({ _id: args.grant._id }, { $set: { lastUsedAt: new Date(now) } });
|
|
847
|
+
return {
|
|
848
|
+
accessToken,
|
|
849
|
+
refreshToken,
|
|
850
|
+
expiresIn: ctx.ttl.accessToken,
|
|
851
|
+
scopes: args.scopes,
|
|
852
|
+
audience: args.audience,
|
|
853
|
+
grantId: String(args.grant._id),
|
|
854
|
+
familyId: args.familyId,
|
|
855
|
+
userId: args.userId,
|
|
856
|
+
contextId: args.contextId
|
|
857
|
+
};
|
|
858
|
+
}
|
|
859
|
+
async function issueForCode(ctx, code) {
|
|
860
|
+
const grant = await liveGrant(ctx, code.grantId);
|
|
861
|
+
const issued = await mintPair(ctx, {
|
|
862
|
+
grant,
|
|
863
|
+
clientId: code.clientId,
|
|
864
|
+
userId: code.userId,
|
|
865
|
+
contextId: code.contextId,
|
|
866
|
+
scopes: code.scopes,
|
|
867
|
+
// Audience comes from the resources bound at /authorize, which were already
|
|
868
|
+
// checked against the client's registration. Never from the token request.
|
|
869
|
+
audience: code.resources,
|
|
870
|
+
familyId: familyFor(code.codeHash),
|
|
871
|
+
familyExpiresAt: new Date(Date.now() + ctx.ttl.refreshAbsolute * 1e3)
|
|
872
|
+
});
|
|
873
|
+
ctx.track({
|
|
874
|
+
type: "oauth.token_issued",
|
|
875
|
+
userId: issued.userId,
|
|
876
|
+
clientId: code.clientId,
|
|
877
|
+
grantId: issued.grantId,
|
|
878
|
+
contextId: issued.contextId ?? void 0,
|
|
879
|
+
scopes: issued.scopes
|
|
880
|
+
});
|
|
881
|
+
await ctx.audit({
|
|
882
|
+
type: "token_issued",
|
|
883
|
+
actor: "client",
|
|
884
|
+
clientId: code.clientId,
|
|
885
|
+
userId: code.userId,
|
|
886
|
+
grantId: grant._id,
|
|
887
|
+
meta: { familyId: issued.familyId, scopes: issued.scopes, audience: issued.audience }
|
|
888
|
+
});
|
|
889
|
+
return issued;
|
|
890
|
+
}
|
|
891
|
+
async function rotateRefresh(ctx, rawRefresh, opts) {
|
|
892
|
+
const tokenHash = sha256(rawRefresh);
|
|
893
|
+
const now = /* @__PURE__ */ new Date();
|
|
894
|
+
const token = await ctx.models.Token.findOneAndUpdate(
|
|
895
|
+
{ tokenHash, kind: "refresh", consumedAt: null, revokedAt: null },
|
|
896
|
+
{ $set: { consumedAt: now } },
|
|
897
|
+
{ returnDocument: "after" }
|
|
898
|
+
);
|
|
899
|
+
if (!token) {
|
|
900
|
+
const known = await ctx.models.Token.findOne({ tokenHash, kind: "refresh" });
|
|
901
|
+
if (known?.consumedAt) {
|
|
902
|
+
const revoked = await revokeFamily(ctx, known.familyId, "refresh_reuse_detected");
|
|
903
|
+
ctx.track({
|
|
904
|
+
type: "oauth.refresh_reuse_detected",
|
|
905
|
+
userId: known.userId,
|
|
906
|
+
clientId: known.clientId,
|
|
907
|
+
grantId: String(known.grantId),
|
|
908
|
+
contextId: known.contextId ?? void 0,
|
|
909
|
+
scopes: known.scopes,
|
|
910
|
+
meta: { familyId: known.familyId, tokensRevoked: revoked }
|
|
911
|
+
});
|
|
912
|
+
await ctx.audit({
|
|
913
|
+
type: "refresh_reuse_detected",
|
|
914
|
+
actor: "client",
|
|
915
|
+
clientId: known.clientId,
|
|
916
|
+
userId: known.userId,
|
|
917
|
+
grantId: known.grantId,
|
|
918
|
+
meta: { familyId: known.familyId, tokensRevoked: revoked }
|
|
919
|
+
});
|
|
920
|
+
ctx.logger.warn?.(
|
|
921
|
+
{ clientId: known.clientId, familyId: known.familyId },
|
|
922
|
+
"oauth-host: rotated refresh token reused \u2014 family revoked"
|
|
923
|
+
);
|
|
924
|
+
}
|
|
925
|
+
throw invalidGrant("refresh token is invalid, expired, or already used");
|
|
926
|
+
}
|
|
927
|
+
if (token.clientId !== opts.clientId) {
|
|
928
|
+
await revokeFamily(ctx, token.familyId, "refresh_wrong_client");
|
|
929
|
+
await ctx.audit({
|
|
930
|
+
type: "refresh_wrong_client",
|
|
931
|
+
actor: "client",
|
|
932
|
+
clientId: opts.clientId,
|
|
933
|
+
userId: token.userId,
|
|
934
|
+
grantId: token.grantId,
|
|
935
|
+
meta: { familyId: token.familyId, issuedTo: token.clientId }
|
|
936
|
+
});
|
|
937
|
+
throw invalidGrant("refresh token was issued to a different client");
|
|
938
|
+
}
|
|
939
|
+
if (token.expiresAt.getTime() <= now.getTime()) {
|
|
940
|
+
throw invalidGrant("refresh token has expired");
|
|
941
|
+
}
|
|
942
|
+
if (token.familyExpiresAt && token.familyExpiresAt.getTime() <= now.getTime()) {
|
|
943
|
+
await revokeFamily(ctx, token.familyId, "family_absolute_expiry");
|
|
944
|
+
throw invalidGrant("refresh token family has reached its absolute lifetime; re-authorization is required");
|
|
945
|
+
}
|
|
946
|
+
const grant = await liveGrant(ctx, token.grantId);
|
|
947
|
+
if (ctx.grantContext && token.contextId !== null) {
|
|
948
|
+
const stillAllowed = await ctx.grantContext.verify(
|
|
949
|
+
await ctx.loadUser(token.userId),
|
|
950
|
+
token.contextId
|
|
951
|
+
);
|
|
952
|
+
if (!stillAllowed) {
|
|
953
|
+
await ctx.models.Grant.updateOne(
|
|
954
|
+
{ _id: grant._id, revokedAt: null },
|
|
955
|
+
{ $set: { revokedAt: now, revokedBy: "system" } }
|
|
956
|
+
);
|
|
957
|
+
await revokeFamily(ctx, token.familyId, "context_membership_ended");
|
|
958
|
+
await ctx.audit({
|
|
959
|
+
type: "grant_revoked",
|
|
960
|
+
actor: "system",
|
|
961
|
+
clientId: token.clientId,
|
|
962
|
+
userId: token.userId,
|
|
963
|
+
grantId: token.grantId,
|
|
964
|
+
meta: { reason: "context_membership_ended", contextId: token.contextId }
|
|
965
|
+
});
|
|
966
|
+
throw invalidGrant("the context this authorization was granted for is no longer available");
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
const ceiling = token.scopes.filter((s) => grant.scopes.includes(s));
|
|
970
|
+
const requested = parseScopes(opts.scope);
|
|
971
|
+
const scopes = requested.length ? requested : ceiling;
|
|
972
|
+
for (const s of scopes) {
|
|
973
|
+
if (!ceiling.includes(s)) {
|
|
974
|
+
throw invalidScope(`scope '${s}' was not granted; a refresh may only narrow the scope set`);
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
const audience = opts.resources?.length ? opts.resources : token.audience;
|
|
978
|
+
for (const r of audience) {
|
|
979
|
+
if (!token.audience.includes(r)) {
|
|
980
|
+
throw invalidTarget(`resource '${r}' is not one this authorization is bound to`);
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
const issued = await mintPair(ctx, {
|
|
984
|
+
grant,
|
|
985
|
+
clientId: token.clientId,
|
|
986
|
+
userId: token.userId,
|
|
987
|
+
contextId: token.contextId,
|
|
988
|
+
scopes,
|
|
989
|
+
audience,
|
|
990
|
+
familyId: token.familyId,
|
|
991
|
+
familyExpiresAt: token.familyExpiresAt ?? new Date(now.getTime() + ctx.ttl.refreshAbsolute * 1e3),
|
|
992
|
+
parentId: token._id
|
|
993
|
+
});
|
|
994
|
+
ctx.track({
|
|
995
|
+
type: "oauth.token_refreshed",
|
|
996
|
+
userId: issued.userId,
|
|
997
|
+
clientId: token.clientId,
|
|
998
|
+
grantId: issued.grantId,
|
|
999
|
+
contextId: issued.contextId ?? void 0,
|
|
1000
|
+
scopes: issued.scopes
|
|
1001
|
+
});
|
|
1002
|
+
return issued;
|
|
1003
|
+
}
|
|
1004
|
+
async function revokeFamily(ctx, familyId, reason) {
|
|
1005
|
+
const { modifiedCount = 0 } = await ctx.models.Token.updateMany(
|
|
1006
|
+
{ familyId, revokedAt: null },
|
|
1007
|
+
{ $set: { revokedAt: /* @__PURE__ */ new Date() } }
|
|
1008
|
+
);
|
|
1009
|
+
if (modifiedCount) {
|
|
1010
|
+
ctx.logger.info?.({ familyId, reason, tokensRevoked: modifiedCount }, "oauth-host: token family revoked");
|
|
1011
|
+
}
|
|
1012
|
+
return modifiedCount;
|
|
1013
|
+
}
|
|
1014
|
+
var CACHE_MAX_ENTRIES = 1e4;
|
|
1015
|
+
var introspectionCache = /* @__PURE__ */ new Map();
|
|
1016
|
+
function cacheGet(ctx, hash) {
|
|
1017
|
+
if (ctx.tokenCacheTtlMs <= 0) return null;
|
|
1018
|
+
const hit = introspectionCache.get(hash);
|
|
1019
|
+
if (!hit) return null;
|
|
1020
|
+
if (hit.until <= Date.now()) {
|
|
1021
|
+
introspectionCache.delete(hash);
|
|
1022
|
+
return null;
|
|
1023
|
+
}
|
|
1024
|
+
return hit.doc;
|
|
1025
|
+
}
|
|
1026
|
+
function cachePut(ctx, hash, doc) {
|
|
1027
|
+
if (ctx.tokenCacheTtlMs <= 0) return;
|
|
1028
|
+
if (introspectionCache.size >= CACHE_MAX_ENTRIES) {
|
|
1029
|
+
const oldest = introspectionCache.keys().next().value;
|
|
1030
|
+
if (oldest !== void 0) introspectionCache.delete(oldest);
|
|
1031
|
+
}
|
|
1032
|
+
const until = Math.min(Date.now() + ctx.tokenCacheTtlMs, doc.expiresAt.getTime());
|
|
1033
|
+
introspectionCache.set(hash, { doc, until });
|
|
1034
|
+
}
|
|
1035
|
+
function forgetCachedToken(tokenHash) {
|
|
1036
|
+
introspectionCache.delete(tokenHash);
|
|
1037
|
+
}
|
|
1038
|
+
async function introspectAccessToken(ctx, raw) {
|
|
1039
|
+
const hash = sha256(raw);
|
|
1040
|
+
const cached = cacheGet(ctx, hash);
|
|
1041
|
+
if (cached) return cached;
|
|
1042
|
+
const token = await ctx.models.Token.findOne({ tokenHash: hash, kind: "access" });
|
|
1043
|
+
if (!token) return null;
|
|
1044
|
+
if (token.revokedAt) return null;
|
|
1045
|
+
if (token.expiresAt.getTime() <= Date.now()) return null;
|
|
1046
|
+
const grant = await ctx.models.Grant.findById(token.grantId, { revokedAt: 1 });
|
|
1047
|
+
if (!grant || grant.revokedAt) return null;
|
|
1048
|
+
cachePut(ctx, hash, token);
|
|
1049
|
+
return token;
|
|
1050
|
+
}
|
|
1051
|
+
async function revokeToken(ctx, raw, clientId) {
|
|
1052
|
+
const token = await ctx.models.Token.findOne({ tokenHash: sha256(raw) });
|
|
1053
|
+
if (!token) return;
|
|
1054
|
+
forgetCachedToken(token.tokenHash);
|
|
1055
|
+
if (token.clientId !== clientId) return;
|
|
1056
|
+
if (token.kind === "refresh") {
|
|
1057
|
+
await revokeFamily(ctx, token.familyId, "client_revocation");
|
|
1058
|
+
} else {
|
|
1059
|
+
await ctx.models.Token.updateOne(
|
|
1060
|
+
{ _id: token._id, revokedAt: null },
|
|
1061
|
+
{ $set: { revokedAt: /* @__PURE__ */ new Date() } }
|
|
1062
|
+
);
|
|
1063
|
+
}
|
|
1064
|
+
await ctx.audit({
|
|
1065
|
+
type: "token_revoked",
|
|
1066
|
+
actor: "client",
|
|
1067
|
+
clientId,
|
|
1068
|
+
userId: token.userId,
|
|
1069
|
+
grantId: token.grantId,
|
|
1070
|
+
meta: { kind: token.kind, familyId: token.familyId }
|
|
1071
|
+
});
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
// src/server/protect.ts
|
|
1075
|
+
function createProtect(ctx) {
|
|
1076
|
+
const declared = new Set(ctx.resources.map((r) => r.id));
|
|
1077
|
+
return function protect(scopes, opts = {}) {
|
|
1078
|
+
const required = scopes === void 0 ? [] : Array.isArray(scopes) ? scopes : [scopes];
|
|
1079
|
+
const resource = opts.resource ?? ctx.resources[0].id;
|
|
1080
|
+
const mode = opts.mode ?? "all";
|
|
1081
|
+
if (!declared.has(resource)) {
|
|
1082
|
+
throw new TypeError(
|
|
1083
|
+
`oauth-host: protect({ resource: '${resource}' }) is not one of the configured resources (${[...declared].join(", ")})`
|
|
1084
|
+
);
|
|
1085
|
+
}
|
|
1086
|
+
for (const s of required) {
|
|
1087
|
+
if (!ctx.scopeIndex.has(s)) {
|
|
1088
|
+
throw new TypeError(`oauth-host: protect() requires scope '${s}', which is not in the scope catalog`);
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
const metadataUrl = protectedResourceMetadataUrl(ctx.issuer, resource);
|
|
1092
|
+
return wrap(ctx.logger, async (req, res, next) => {
|
|
1093
|
+
const raw = bearerFromHeader(req);
|
|
1094
|
+
if (!raw) return challenge(res, 401, metadataUrl);
|
|
1095
|
+
const token = await introspectAccessToken(ctx, raw);
|
|
1096
|
+
if (!token) {
|
|
1097
|
+
return challenge(res, 401, metadataUrl, {
|
|
1098
|
+
error: "invalid_token",
|
|
1099
|
+
error_description: "The access token is expired, revoked or unknown"
|
|
1100
|
+
});
|
|
1101
|
+
}
|
|
1102
|
+
if (!token.audience.includes(resource)) {
|
|
1103
|
+
return challenge(res, 401, metadataUrl, {
|
|
1104
|
+
error: "invalid_token",
|
|
1105
|
+
error_description: "The access token was not issued for this resource"
|
|
1106
|
+
});
|
|
1107
|
+
}
|
|
1108
|
+
const held = new Set(token.scopes);
|
|
1109
|
+
const ok = mode === "any" ? required.length === 0 || required.some((s) => held.has(s)) : required.every((s) => held.has(s));
|
|
1110
|
+
if (!ok) {
|
|
1111
|
+
return challenge(res, 403, metadataUrl, {
|
|
1112
|
+
error: "insufficient_scope",
|
|
1113
|
+
error_description: `Requires ${mode === "any" ? "one of" : "all of"}: ${required.join(" ")}`,
|
|
1114
|
+
scope: required.join(" ")
|
|
1115
|
+
});
|
|
1116
|
+
}
|
|
1117
|
+
req.oauth = {
|
|
1118
|
+
userId: token.userId,
|
|
1119
|
+
clientId: token.clientId,
|
|
1120
|
+
contextId: token.contextId ?? null,
|
|
1121
|
+
scopes: token.scopes,
|
|
1122
|
+
grantId: String(token.grantId),
|
|
1123
|
+
tokenId: String(token._id),
|
|
1124
|
+
audience: token.audience
|
|
1125
|
+
};
|
|
1126
|
+
return next();
|
|
1127
|
+
});
|
|
1128
|
+
};
|
|
1129
|
+
}
|
|
1130
|
+
function bearerFromHeader(req) {
|
|
1131
|
+
const header = req.headers.authorization;
|
|
1132
|
+
if (!header) return null;
|
|
1133
|
+
const match = /^Bearer +([^\s]+)$/i.exec(header);
|
|
1134
|
+
return match ? match[1] : null;
|
|
1135
|
+
}
|
|
1136
|
+
function challenge(res, status, metadataUrl, params = {}) {
|
|
1137
|
+
const parts = [
|
|
1138
|
+
...Object.entries(params).map(([k, v]) => `${k}="${String(v).replace(/"/g, "")}"`),
|
|
1139
|
+
`resource_metadata="${metadataUrl}"`
|
|
1140
|
+
];
|
|
1141
|
+
res.set("WWW-Authenticate", `Bearer ${parts.join(", ")}`);
|
|
1142
|
+
return res.status(status).json({
|
|
1143
|
+
error: params.error ?? "invalid_request",
|
|
1144
|
+
error_description: params.error_description ?? "Missing bearer token"
|
|
1145
|
+
});
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
// src/server/rate-limit.ts
|
|
1149
|
+
function rateLimit(ctx, bucket, keyOf) {
|
|
1150
|
+
const rule = ctx.rateLimits[bucket];
|
|
1151
|
+
if (rule === false) return (_req, _res, next) => next();
|
|
1152
|
+
return (req, res, next) => {
|
|
1153
|
+
void (async () => {
|
|
1154
|
+
let hit;
|
|
1155
|
+
try {
|
|
1156
|
+
hit = await ctx.rateLimits.hit(`${bucket}:${keyOf(req)}`, rule.windowMs);
|
|
1157
|
+
} catch (err) {
|
|
1158
|
+
ctx.logger.error?.({ err, bucket }, "oauth-host: rate limit store failed, allowing request");
|
|
1159
|
+
next();
|
|
1160
|
+
return;
|
|
1161
|
+
}
|
|
1162
|
+
if (hit.count <= rule.max) {
|
|
1163
|
+
next();
|
|
1164
|
+
return;
|
|
1165
|
+
}
|
|
1166
|
+
const retryAfter = Math.max(1, Math.ceil((hit.resetAt - Date.now()) / 1e3));
|
|
1167
|
+
res.set("Retry-After", String(retryAfter));
|
|
1168
|
+
res.status(429).json({
|
|
1169
|
+
error: "too_many_requests",
|
|
1170
|
+
error_description: `Rate limit exceeded for ${bucket}. Retry in ${retryAfter}s.`
|
|
1171
|
+
});
|
|
1172
|
+
})();
|
|
1173
|
+
};
|
|
1174
|
+
}
|
|
1175
|
+
function thumbprint(jwk) {
|
|
1176
|
+
return sha256(JSON.stringify({ crv: jwk.crv, kty: jwk.kty, x: jwk.x, y: jwk.y }));
|
|
1177
|
+
}
|
|
1178
|
+
function toJwk(key) {
|
|
1179
|
+
return key.export({ format: "jwk" });
|
|
1180
|
+
}
|
|
1181
|
+
function publicHalf(jwk, kid) {
|
|
1182
|
+
const { d, ...rest } = jwk;
|
|
1183
|
+
return { ...rest, kid, alg: "ES256", use: "sig" };
|
|
1184
|
+
}
|
|
1185
|
+
function normalizeConfigured(specs) {
|
|
1186
|
+
if (!specs?.length) return [];
|
|
1187
|
+
return specs.map((spec) => {
|
|
1188
|
+
if (spec.alg && spec.alg !== "ES256") {
|
|
1189
|
+
throw new TypeError(`oauth-host: signing key '${spec.kid}' has alg '${spec.alg}'; only ES256 is supported`);
|
|
1190
|
+
}
|
|
1191
|
+
let privateKey;
|
|
1192
|
+
try {
|
|
1193
|
+
privateKey = createPrivateKey(spec.privateKeyPem);
|
|
1194
|
+
} catch (err) {
|
|
1195
|
+
throw new TypeError(
|
|
1196
|
+
`oauth-host: signing key '${spec.kid}' is not a readable PKCS#8 PEM (${err.message})`
|
|
1197
|
+
);
|
|
1198
|
+
}
|
|
1199
|
+
if (privateKey.asymmetricKeyType !== "ec") {
|
|
1200
|
+
throw new TypeError(`oauth-host: signing key '${spec.kid}' must be an EC (P-256) key`);
|
|
1201
|
+
}
|
|
1202
|
+
const jwk = toJwk(createPublicKey(privateKey));
|
|
1203
|
+
if (jwk.crv !== "P-256") {
|
|
1204
|
+
throw new TypeError(`oauth-host: signing key '${spec.kid}' uses curve ${String(jwk.crv)}; ES256 requires P-256`);
|
|
1205
|
+
}
|
|
1206
|
+
const kid = spec.kid || thumbprint(jwk);
|
|
1207
|
+
return { kid, status: spec.status ?? "active", privateKey, publicJwk: publicHalf(jwk, kid) };
|
|
1208
|
+
});
|
|
1209
|
+
}
|
|
1210
|
+
var MISSING_KEY = "oauth-host: no signing key. Set `signing.keys` to a PKCS#8 ES256 PEM, or `signing.autoGenerate: true` to generate and persist one (development only \u2014 it stores the private key in the same database as the tokens it signs).";
|
|
1211
|
+
function createKeyManager(ctx) {
|
|
1212
|
+
const configured = normalizeConfigured(ctx.signing.keys);
|
|
1213
|
+
const imported = /* @__PURE__ */ new Map();
|
|
1214
|
+
let generating = null;
|
|
1215
|
+
function importPrivate(kid, jwk) {
|
|
1216
|
+
const cached = imported.get(kid);
|
|
1217
|
+
if (cached) return cached;
|
|
1218
|
+
const key = createPrivateKey({ key: jwk, format: "jwk" });
|
|
1219
|
+
imported.set(kid, key);
|
|
1220
|
+
return key;
|
|
1221
|
+
}
|
|
1222
|
+
async function generateAndPersist() {
|
|
1223
|
+
const { privateKey } = generateKeyPairSync("ec", { namedCurve: "prime256v1" });
|
|
1224
|
+
const privateJwk = toJwk(privateKey);
|
|
1225
|
+
const kid = thumbprint(privateJwk);
|
|
1226
|
+
const doc = {
|
|
1227
|
+
kid,
|
|
1228
|
+
alg: "ES256",
|
|
1229
|
+
publicJwk: publicHalf(privateJwk, kid),
|
|
1230
|
+
privateJwk,
|
|
1231
|
+
status: "active"
|
|
1232
|
+
};
|
|
1233
|
+
try {
|
|
1234
|
+
return await ctx.models.Key.create(doc);
|
|
1235
|
+
} catch (err) {
|
|
1236
|
+
if (err.code !== 11e3) throw err;
|
|
1237
|
+
const existing = await ctx.models.Key.findOne({ kid });
|
|
1238
|
+
if (!existing) throw err;
|
|
1239
|
+
return existing;
|
|
1240
|
+
}
|
|
1241
|
+
}
|
|
1242
|
+
return {
|
|
1243
|
+
async getSigningKey() {
|
|
1244
|
+
const active = configured.find((k) => k.status === "active");
|
|
1245
|
+
if (active) return { kid: active.kid, alg: "ES256", privateKey: active.privateKey };
|
|
1246
|
+
if (configured.length) {
|
|
1247
|
+
throw new Error("oauth-host: every configured signing key is `retiring`; one must be `active` to sign with");
|
|
1248
|
+
}
|
|
1249
|
+
if (!ctx.signing.autoGenerate) throw new Error(MISSING_KEY);
|
|
1250
|
+
const doc = await ctx.models.Key.findOne({ status: "active" }).sort({ createdAt: 1, kid: 1 }) ?? await (generating ??= generateAndPersist().finally(() => {
|
|
1251
|
+
generating = null;
|
|
1252
|
+
}));
|
|
1253
|
+
return { kid: doc.kid, alg: "ES256", privateKey: importPrivate(doc.kid, doc.privateJwk) };
|
|
1254
|
+
},
|
|
1255
|
+
async jwks() {
|
|
1256
|
+
if (configured.length) {
|
|
1257
|
+
return { keys: configured.map((k) => k.publicJwk) };
|
|
1258
|
+
}
|
|
1259
|
+
const docs = await ctx.models.Key.find({ status: { $in: ["active", "retiring"] } }).sort({ createdAt: 1, kid: 1 });
|
|
1260
|
+
return { keys: docs.map((d) => ({ ...d.publicJwk, kid: d.kid, alg: d.alg, use: "sig" })) };
|
|
1261
|
+
}
|
|
1262
|
+
};
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
// src/server/services/admin.ts
|
|
1266
|
+
var MAX_LIST = 200;
|
|
1267
|
+
var DEFAULT_LIST = 50;
|
|
1268
|
+
function clampLimit(requested) {
|
|
1269
|
+
const n = typeof requested === "number" && Number.isFinite(requested) ? Math.floor(requested) : DEFAULT_LIST;
|
|
1270
|
+
if (n < 1) return 1;
|
|
1271
|
+
return Math.min(n, MAX_LIST);
|
|
1272
|
+
}
|
|
1273
|
+
function clampSkip(requested) {
|
|
1274
|
+
const n = typeof requested === "number" && Number.isFinite(requested) ? Math.floor(requested) : 0;
|
|
1275
|
+
return n > 0 ? n : 0;
|
|
1276
|
+
}
|
|
1277
|
+
function notFound(clientId) {
|
|
1278
|
+
return new Error(`oauth-host: no client registered with clientId '${clientId}'`);
|
|
1279
|
+
}
|
|
1280
|
+
function toPublicClient(doc) {
|
|
1281
|
+
return {
|
|
1282
|
+
clientId: doc.clientId,
|
|
1283
|
+
name: doc.name,
|
|
1284
|
+
type: doc.type,
|
|
1285
|
+
// Defaulted rather than read straight through: rows written before the
|
|
1286
|
+
// field existed have no value, and `undefined` here would read as "unknown
|
|
1287
|
+
// provenance" on a client that plainly was registered by hand.
|
|
1288
|
+
registration: doc.registration ?? "manual",
|
|
1289
|
+
...doc.metadataUrl ? { metadataUrl: doc.metadataUrl } : {},
|
|
1290
|
+
trusted: Boolean(doc.trusted),
|
|
1291
|
+
redirectUris: [...doc.redirectUris ?? []],
|
|
1292
|
+
allowedScopes: [...doc.allowedScopes ?? []],
|
|
1293
|
+
allowedResources: [...doc.allowedResources ?? []],
|
|
1294
|
+
branding: { ...doc.branding ?? {} },
|
|
1295
|
+
status: doc.status,
|
|
1296
|
+
createdAt: doc.createdAt
|
|
1297
|
+
};
|
|
1298
|
+
}
|
|
1299
|
+
function assertRedirectUri(value) {
|
|
1300
|
+
if (typeof value !== "string" || !value) {
|
|
1301
|
+
throw new TypeError(`oauth-host: every redirectUri must be a string (got ${JSON.stringify(value)})`);
|
|
1302
|
+
}
|
|
1303
|
+
let url;
|
|
1304
|
+
try {
|
|
1305
|
+
url = new URL(value);
|
|
1306
|
+
} catch {
|
|
1307
|
+
throw new TypeError(`oauth-host: redirectUri must be an absolute URI: '${value}'`);
|
|
1308
|
+
}
|
|
1309
|
+
const loopback = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]";
|
|
1310
|
+
if (url.protocol !== "https:" && !(url.protocol === "http:" && loopback)) {
|
|
1311
|
+
throw new TypeError(
|
|
1312
|
+
`oauth-host: redirectUri must be https (http is allowed only on localhost/127.0.0.1): '${value}'`
|
|
1313
|
+
);
|
|
1314
|
+
}
|
|
1315
|
+
if (url.hash) {
|
|
1316
|
+
throw new TypeError(`oauth-host: redirectUri must not contain a fragment: '${value}'`);
|
|
1317
|
+
}
|
|
1318
|
+
return value;
|
|
1319
|
+
}
|
|
1320
|
+
function assertRedirectUris(input) {
|
|
1321
|
+
if (!Array.isArray(input) || input.length === 0) {
|
|
1322
|
+
throw new TypeError("oauth-host: a client needs at least one redirectUri");
|
|
1323
|
+
}
|
|
1324
|
+
return input.map(assertRedirectUri);
|
|
1325
|
+
}
|
|
1326
|
+
function assertScopes(ctx, input) {
|
|
1327
|
+
if (!Array.isArray(input) || input.length === 0) {
|
|
1328
|
+
throw new TypeError("oauth-host: a client needs at least one entry in allowedScopes");
|
|
1329
|
+
}
|
|
1330
|
+
for (const id of input) {
|
|
1331
|
+
if (typeof id !== "string" || !ctx.scopeIndex.has(id)) {
|
|
1332
|
+
throw new TypeError(
|
|
1333
|
+
`oauth-host: allowedScopes contains '${String(id)}', which is not in the configured scope catalog`
|
|
1334
|
+
);
|
|
1335
|
+
}
|
|
1336
|
+
}
|
|
1337
|
+
return [...input];
|
|
1338
|
+
}
|
|
1339
|
+
function assertResources(ctx, input) {
|
|
1340
|
+
const declared = new Set(ctx.resources.map((r) => r.id));
|
|
1341
|
+
if (input === void 0) return ctx.resources.map((r) => r.id);
|
|
1342
|
+
if (!Array.isArray(input) || input.length === 0) {
|
|
1343
|
+
throw new TypeError("oauth-host: allowedResources, when given, must list at least one resource");
|
|
1344
|
+
}
|
|
1345
|
+
for (const id of input) {
|
|
1346
|
+
if (typeof id !== "string" || !declared.has(id)) {
|
|
1347
|
+
throw new TypeError(
|
|
1348
|
+
`oauth-host: allowedResources contains '${String(id)}', which is not a configured resource`
|
|
1349
|
+
);
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
1352
|
+
return [...input];
|
|
1353
|
+
}
|
|
1354
|
+
function assertName(value) {
|
|
1355
|
+
if (typeof value !== "string" || !value.trim()) {
|
|
1356
|
+
throw new TypeError("oauth-host: a client needs a non-empty `name` \u2014 it is what the consent screen shows");
|
|
1357
|
+
}
|
|
1358
|
+
return value.trim();
|
|
1359
|
+
}
|
|
1360
|
+
async function revokeGrantsMatching(ctx, filter, by) {
|
|
1361
|
+
const res = await ctx.models.Grant.updateMany(
|
|
1362
|
+
{ ...filter, revokedAt: null },
|
|
1363
|
+
{ $set: { revokedAt: /* @__PURE__ */ new Date(), revokedBy: by } }
|
|
1364
|
+
);
|
|
1365
|
+
return { grantsRevoked: res.modifiedCount ?? 0 };
|
|
1366
|
+
}
|
|
1367
|
+
async function revokeTokensMatching(ctx, filter) {
|
|
1368
|
+
const res = await ctx.models.Token.updateMany(
|
|
1369
|
+
{ ...filter, revokedAt: null },
|
|
1370
|
+
{ $set: { revokedAt: /* @__PURE__ */ new Date() } }
|
|
1371
|
+
);
|
|
1372
|
+
return res.modifiedCount ?? 0;
|
|
1373
|
+
}
|
|
1374
|
+
function createClientsApi(ctx) {
|
|
1375
|
+
return {
|
|
1376
|
+
async create(spec) {
|
|
1377
|
+
if (!spec || typeof spec !== "object") {
|
|
1378
|
+
throw new TypeError("oauth-host: clients.create(spec) requires a spec object");
|
|
1379
|
+
}
|
|
1380
|
+
const name = assertName(spec.name);
|
|
1381
|
+
const redirectUris = assertRedirectUris(spec.redirectUris);
|
|
1382
|
+
const allowedScopes = assertScopes(ctx, spec.allowedScopes);
|
|
1383
|
+
const allowedResources = assertResources(ctx, spec.allowedResources);
|
|
1384
|
+
const clientId = spec.clientId ?? generateClientId();
|
|
1385
|
+
const clientSecret = generateClientSecret();
|
|
1386
|
+
const doc = await ctx.models.Client.create({
|
|
1387
|
+
clientId,
|
|
1388
|
+
name,
|
|
1389
|
+
type: "confidential",
|
|
1390
|
+
registration: "manual",
|
|
1391
|
+
trusted: Boolean(spec.trusted),
|
|
1392
|
+
// Only the digest is stored. `clientSecret` below is the only time the
|
|
1393
|
+
// raw value exists outside the caller's variable.
|
|
1394
|
+
secrets: [{ hash: sha256(clientSecret), label: "initial", createdAt: /* @__PURE__ */ new Date() }],
|
|
1395
|
+
redirectUris,
|
|
1396
|
+
allowedScopes,
|
|
1397
|
+
allowedResources,
|
|
1398
|
+
branding: spec.branding ?? {},
|
|
1399
|
+
status: "active"
|
|
1400
|
+
});
|
|
1401
|
+
await ctx.audit({ type: "oauth.client_created", actor: "admin", clientId });
|
|
1402
|
+
return { client: toPublicClient(doc), clientId, clientSecret };
|
|
1403
|
+
},
|
|
1404
|
+
async rotateSecret(clientId, opts = {}) {
|
|
1405
|
+
const doc = await ctx.models.Client.findOne({ clientId });
|
|
1406
|
+
if (!doc) throw notFound(clientId);
|
|
1407
|
+
if (doc.type === "public") {
|
|
1408
|
+
throw new Error(
|
|
1409
|
+
`oauth-host: client '${clientId}' is a public client and has no secret to rotate. Public clients authenticate with client_id and PKCE.`
|
|
1410
|
+
);
|
|
1411
|
+
}
|
|
1412
|
+
const retireAfter = typeof opts.retireAfter === "number" && opts.retireAfter > 0 ? opts.retireAfter : 0;
|
|
1413
|
+
const retiresAt = new Date(Date.now() + retireAfter);
|
|
1414
|
+
for (const record of doc.secrets) {
|
|
1415
|
+
record.retiresAt = record.retiresAt && record.retiresAt < retiresAt ? record.retiresAt : retiresAt;
|
|
1416
|
+
}
|
|
1417
|
+
const clientSecret = generateClientSecret();
|
|
1418
|
+
doc.secrets.push({
|
|
1419
|
+
hash: sha256(clientSecret),
|
|
1420
|
+
label: opts.label ?? `rotated-${(/* @__PURE__ */ new Date()).toISOString()}`,
|
|
1421
|
+
createdAt: /* @__PURE__ */ new Date()
|
|
1422
|
+
});
|
|
1423
|
+
doc.markModified("secrets");
|
|
1424
|
+
await doc.save();
|
|
1425
|
+
ctx.track({ type: "oauth.client_secret_rotated", clientId });
|
|
1426
|
+
await ctx.audit({
|
|
1427
|
+
type: "oauth.client_secret_rotated",
|
|
1428
|
+
actor: "admin",
|
|
1429
|
+
clientId,
|
|
1430
|
+
meta: { retireAfter, retiresAt }
|
|
1431
|
+
});
|
|
1432
|
+
return { client: toPublicClient(doc), clientId, clientSecret };
|
|
1433
|
+
},
|
|
1434
|
+
async update(clientId, patch) {
|
|
1435
|
+
const doc = await ctx.models.Client.findOne({ clientId });
|
|
1436
|
+
if (!doc) throw notFound(clientId);
|
|
1437
|
+
if (patch.name !== void 0) doc.name = assertName(patch.name);
|
|
1438
|
+
if (patch.redirectUris !== void 0) doc.redirectUris = assertRedirectUris(patch.redirectUris);
|
|
1439
|
+
if (patch.allowedScopes !== void 0) doc.allowedScopes = assertScopes(ctx, patch.allowedScopes);
|
|
1440
|
+
if (patch.allowedResources !== void 0) {
|
|
1441
|
+
doc.allowedResources = assertResources(ctx, patch.allowedResources);
|
|
1442
|
+
}
|
|
1443
|
+
if (patch.branding !== void 0) doc.branding = patch.branding;
|
|
1444
|
+
if (patch.trusted !== void 0) doc.trusted = Boolean(patch.trusted);
|
|
1445
|
+
await doc.save();
|
|
1446
|
+
await ctx.audit({ type: "oauth.client_updated", actor: "admin", clientId });
|
|
1447
|
+
return toPublicClient(doc);
|
|
1448
|
+
},
|
|
1449
|
+
async list(query = {}) {
|
|
1450
|
+
const limit = clampLimit(query.limit);
|
|
1451
|
+
const skip = clampSkip(query.skip);
|
|
1452
|
+
const filter = {};
|
|
1453
|
+
if (query.status) filter.status = query.status;
|
|
1454
|
+
const docs = await ctx.models.Client.find(filter).sort({ createdAt: -1 }).skip(skip).limit(limit).lean();
|
|
1455
|
+
return { items: docs.map(toPublicClient), limit };
|
|
1456
|
+
},
|
|
1457
|
+
async get(clientId) {
|
|
1458
|
+
const doc = await ctx.models.Client.findOne({ clientId }).lean();
|
|
1459
|
+
return doc ? toPublicClient(doc) : null;
|
|
1460
|
+
},
|
|
1461
|
+
async disable(clientId) {
|
|
1462
|
+
const doc = await ctx.models.Client.findOne({ clientId });
|
|
1463
|
+
if (!doc) throw notFound(clientId);
|
|
1464
|
+
doc.status = "disabled";
|
|
1465
|
+
await doc.save();
|
|
1466
|
+
const { grantsRevoked } = await revokeGrantsMatching(ctx, { clientId }, "admin");
|
|
1467
|
+
const tokensRevoked = await revokeTokensMatching(ctx, { clientId });
|
|
1468
|
+
await ctx.models.Code.deleteMany({ clientId });
|
|
1469
|
+
await ctx.models.Request.deleteMany({ clientId });
|
|
1470
|
+
await ctx.audit({
|
|
1471
|
+
type: "oauth.client_disabled",
|
|
1472
|
+
actor: "admin",
|
|
1473
|
+
clientId,
|
|
1474
|
+
meta: { grantsRevoked, tokensRevoked }
|
|
1475
|
+
});
|
|
1476
|
+
return { grantsRevoked, tokensRevoked };
|
|
1477
|
+
}
|
|
1478
|
+
};
|
|
1479
|
+
}
|
|
1480
|
+
function formUrlDecode(value) {
|
|
1481
|
+
try {
|
|
1482
|
+
return decodeURIComponent(value.replace(/\+/g, " "));
|
|
1483
|
+
} catch {
|
|
1484
|
+
return value;
|
|
1485
|
+
}
|
|
1486
|
+
}
|
|
1487
|
+
function readCredentials(req) {
|
|
1488
|
+
const header = req.headers?.authorization;
|
|
1489
|
+
if (typeof header === "string" && /^Basic /i.test(header)) {
|
|
1490
|
+
const decoded = Buffer.from(header.slice(6).trim(), "base64").toString("utf8");
|
|
1491
|
+
const sep = decoded.indexOf(":");
|
|
1492
|
+
const clientId = formUrlDecode(sep < 0 ? decoded : decoded.slice(0, sep));
|
|
1493
|
+
const secret = sep < 0 ? "" : formUrlDecode(decoded.slice(sep + 1));
|
|
1494
|
+
return { clientId, clientSecret: secret === "" ? null : secret, viaBasic: true };
|
|
1495
|
+
}
|
|
1496
|
+
const body = req.body ?? {};
|
|
1497
|
+
if (typeof body.client_id === "string" && body.client_id) {
|
|
1498
|
+
return {
|
|
1499
|
+
clientId: body.client_id,
|
|
1500
|
+
clientSecret: typeof body.client_secret === "string" && body.client_secret ? body.client_secret : null,
|
|
1501
|
+
viaBasic: false
|
|
1502
|
+
};
|
|
1503
|
+
}
|
|
1504
|
+
return null;
|
|
1505
|
+
}
|
|
1506
|
+
function authFailure(viaBasic) {
|
|
1507
|
+
return new OAuthError(401, "invalid_client", "client authentication failed", {
|
|
1508
|
+
// RFC 6749 §5.2 — a 401 answering a Basic credential MUST carry the challenge.
|
|
1509
|
+
headers: viaBasic ? { "WWW-Authenticate": 'Basic realm="oauth", charset="UTF-8"' } : {}
|
|
1510
|
+
});
|
|
1511
|
+
}
|
|
1512
|
+
async function authenticateClient(ctx, req) {
|
|
1513
|
+
const creds = readCredentials(req);
|
|
1514
|
+
if (!creds || !creds.clientId) throw authFailure(Boolean(creds?.viaBasic));
|
|
1515
|
+
const client = await ctx.models.Client.findOne({ clientId: creds.clientId });
|
|
1516
|
+
if (!client || client.status !== "active") throw authFailure(creds.viaBasic);
|
|
1517
|
+
if (client.type === "public") {
|
|
1518
|
+
if (creds.clientSecret !== null) throw authFailure(creds.viaBasic);
|
|
1519
|
+
return client;
|
|
1520
|
+
}
|
|
1521
|
+
if (creds.clientSecret === null) throw authFailure(creds.viaBasic);
|
|
1522
|
+
const now = Date.now();
|
|
1523
|
+
let matched;
|
|
1524
|
+
for (const record of client.secrets) {
|
|
1525
|
+
if (record.retiresAt && record.retiresAt.getTime() <= now) continue;
|
|
1526
|
+
if (safeEqual(sha256(creds.clientSecret), record.hash)) matched = record;
|
|
1527
|
+
}
|
|
1528
|
+
if (!matched) throw authFailure(creds.viaBasic);
|
|
1529
|
+
const usedAt = /* @__PURE__ */ new Date();
|
|
1530
|
+
matched.lastUsedAt = usedAt;
|
|
1531
|
+
await ctx.models.Client.updateOne(
|
|
1532
|
+
{ clientId: client.clientId, "secrets.hash": matched.hash },
|
|
1533
|
+
{ $set: { "secrets.$.lastUsedAt": usedAt } }
|
|
1534
|
+
);
|
|
1535
|
+
return client;
|
|
1536
|
+
}
|
|
1537
|
+
function describeGrantScopes(ctx, ids) {
|
|
1538
|
+
return ids.map((id) => ctx.scopeIndex.get(id) ?? { id, label: id });
|
|
1539
|
+
}
|
|
1540
|
+
function createGrantsApi(ctx) {
|
|
1541
|
+
return {
|
|
1542
|
+
async list(query) {
|
|
1543
|
+
const limit = clampLimit(query?.limit);
|
|
1544
|
+
const skip = clampSkip(query?.skip);
|
|
1545
|
+
const filter = { revokedAt: null };
|
|
1546
|
+
if (query?.userId !== void 0) filter.userId = query.userId;
|
|
1547
|
+
if (query?.clientId !== void 0) filter.clientId = query.clientId;
|
|
1548
|
+
const grants = await ctx.models.Grant.find(filter).sort({ createdAt: -1 }).skip(skip).limit(limit).lean();
|
|
1549
|
+
if (grants.length === 0) return { items: [], limit };
|
|
1550
|
+
const clientIds = [...new Set(grants.map((g) => g.clientId))];
|
|
1551
|
+
const clients = await ctx.models.Client.find({ clientId: { $in: clientIds } }).lean();
|
|
1552
|
+
const byId = new Map(clients.map((c) => [c.clientId, toPublicClient(c)]));
|
|
1553
|
+
const contextCache = /* @__PURE__ */ new Map();
|
|
1554
|
+
const items = [];
|
|
1555
|
+
for (const grant of grants) {
|
|
1556
|
+
const client = byId.get(grant.clientId);
|
|
1557
|
+
const summary = {
|
|
1558
|
+
id: String(grant._id),
|
|
1559
|
+
client: client ? { clientId: client.clientId, name: client.name, branding: client.branding } : { clientId: grant.clientId, name: grant.clientId, branding: {} },
|
|
1560
|
+
scopes: describeGrantScopes(ctx, grant.scopes ?? []),
|
|
1561
|
+
createdAt: grant.createdAt,
|
|
1562
|
+
...grant.lastUsedAt ? { lastUsedAt: grant.lastUsedAt } : {}
|
|
1563
|
+
};
|
|
1564
|
+
if (ctx.grantContext && grant.contextId) {
|
|
1565
|
+
const cacheKey = `${String(grant.userId)}:${grant.clientId}`;
|
|
1566
|
+
let available = contextCache.get(cacheKey);
|
|
1567
|
+
if (!available) {
|
|
1568
|
+
const user = { id: grant.userId };
|
|
1569
|
+
available = client ? await ctx.grantContext.list(user, { client, scopes: grant.scopes ?? [] }) : [];
|
|
1570
|
+
contextCache.set(cacheKey, available);
|
|
1571
|
+
}
|
|
1572
|
+
summary.context = available.find((c) => c.id === grant.contextId) ?? { id: grant.contextId, label: grant.contextId };
|
|
1573
|
+
}
|
|
1574
|
+
items.push(summary);
|
|
1575
|
+
}
|
|
1576
|
+
return { items, limit };
|
|
1577
|
+
},
|
|
1578
|
+
async revoke(grantId, opts = {}) {
|
|
1579
|
+
const by = opts.by ?? "admin";
|
|
1580
|
+
const grant = await ctx.models.Grant.findById(grantId).catch(() => null);
|
|
1581
|
+
if (!grant) return { tokensRevoked: 0 };
|
|
1582
|
+
const alreadyRevoked = Boolean(grant.revokedAt);
|
|
1583
|
+
if (!alreadyRevoked) {
|
|
1584
|
+
grant.revokedAt = /* @__PURE__ */ new Date();
|
|
1585
|
+
grant.revokedBy = by;
|
|
1586
|
+
await grant.save();
|
|
1587
|
+
}
|
|
1588
|
+
const tokensRevoked = await revokeTokensMatching(ctx, { grantId: grant._id });
|
|
1589
|
+
if (!alreadyRevoked) {
|
|
1590
|
+
ctx.track({
|
|
1591
|
+
type: "oauth.grant_revoked",
|
|
1592
|
+
userId: grant.userId,
|
|
1593
|
+
clientId: grant.clientId,
|
|
1594
|
+
grantId: String(grant._id),
|
|
1595
|
+
...grant.contextId ? { contextId: grant.contextId } : {},
|
|
1596
|
+
scopes: grant.scopes
|
|
1597
|
+
});
|
|
1598
|
+
await ctx.audit({
|
|
1599
|
+
type: "oauth.grant_revoked",
|
|
1600
|
+
actor: by,
|
|
1601
|
+
clientId: grant.clientId,
|
|
1602
|
+
userId: grant.userId,
|
|
1603
|
+
grantId: grant._id,
|
|
1604
|
+
meta: { tokensRevoked }
|
|
1605
|
+
});
|
|
1606
|
+
}
|
|
1607
|
+
return { tokensRevoked };
|
|
1608
|
+
}
|
|
1609
|
+
};
|
|
1610
|
+
}
|
|
1611
|
+
function pairwiseKey(userId) {
|
|
1612
|
+
const key = String(userId);
|
|
1613
|
+
if (!key || key.includes(".") || key.startsWith("$")) return null;
|
|
1614
|
+
return key;
|
|
1615
|
+
}
|
|
1616
|
+
function createUsersApi(ctx) {
|
|
1617
|
+
return {
|
|
1618
|
+
/**
|
|
1619
|
+
* Erasure. The user is gone and every trace of them has to go with them.
|
|
1620
|
+
*
|
|
1621
|
+
* This is the destructive twin of `revokeAll`, and the difference is the
|
|
1622
|
+
* whole reason both exist: `forget` DELETES the grant documents and the
|
|
1623
|
+
* user's audit rows, `revokeAll` keeps both. A password change must leave a
|
|
1624
|
+
* history an operator can read; a deletion request must not.
|
|
1625
|
+
*
|
|
1626
|
+
* Idempotent — it will be called twice, by a retry or by a host that wires
|
|
1627
|
+
* it to both a soft-delete and a hard-delete hook.
|
|
1628
|
+
*/
|
|
1629
|
+
async forget(userId) {
|
|
1630
|
+
const grantsResult = await ctx.models.Grant.deleteMany({ userId });
|
|
1631
|
+
const tokensResult = await ctx.models.Token.deleteMany({ userId });
|
|
1632
|
+
await ctx.models.Code.deleteMany({ userId });
|
|
1633
|
+
await ctx.models.Request.deleteMany({ userId });
|
|
1634
|
+
const key = pairwiseKey(userId);
|
|
1635
|
+
if (key) {
|
|
1636
|
+
await ctx.models.Client.updateMany(
|
|
1637
|
+
{ [`pairwiseSubjects.${key}`]: { $exists: true } },
|
|
1638
|
+
{ $unset: { [`pairwiseSubjects.${key}`]: "" } }
|
|
1639
|
+
);
|
|
1640
|
+
}
|
|
1641
|
+
await ctx.models.Audit.deleteMany({ userId });
|
|
1642
|
+
const grants = grantsResult.deletedCount ?? 0;
|
|
1643
|
+
const tokens = tokensResult.deletedCount ?? 0;
|
|
1644
|
+
await ctx.audit({ type: "oauth.user_forgotten", actor: "system", meta: { grants, tokens } });
|
|
1645
|
+
return { grants, tokens };
|
|
1646
|
+
},
|
|
1647
|
+
/**
|
|
1648
|
+
* Password change, deactivation, suspected compromise.
|
|
1649
|
+
*
|
|
1650
|
+
* Kills live access and KEEPS everything else: the grant documents stay so
|
|
1651
|
+
* the audit trail still resolves and so the user's connected apps remain
|
|
1652
|
+
* visible, and the audit rows stay because that is the record the operator
|
|
1653
|
+
* called this to create. See `forget` above for the destructive twin.
|
|
1654
|
+
*/
|
|
1655
|
+
async revokeAll(userId, opts = {}) {
|
|
1656
|
+
const live = await ctx.models.Grant.find({ userId, revokedAt: null }).limit(MAX_LIST).lean();
|
|
1657
|
+
const { grantsRevoked } = await revokeGrantsMatching(ctx, { userId }, "system");
|
|
1658
|
+
const tokensRevoked = await revokeTokensMatching(ctx, { userId });
|
|
1659
|
+
await ctx.models.Code.deleteMany({ userId });
|
|
1660
|
+
await ctx.models.Request.deleteMany({ userId });
|
|
1661
|
+
for (const grant of live) {
|
|
1662
|
+
ctx.track({
|
|
1663
|
+
type: "oauth.grant_revoked",
|
|
1664
|
+
userId,
|
|
1665
|
+
clientId: grant.clientId,
|
|
1666
|
+
grantId: String(grant._id),
|
|
1667
|
+
...grant.contextId ? { contextId: grant.contextId } : {},
|
|
1668
|
+
scopes: grant.scopes
|
|
1669
|
+
});
|
|
1670
|
+
}
|
|
1671
|
+
await ctx.audit({
|
|
1672
|
+
type: "oauth.user_access_revoked",
|
|
1673
|
+
actor: "system",
|
|
1674
|
+
userId,
|
|
1675
|
+
meta: { grantsRevoked, tokensRevoked, ...opts.reason ? { reason: opts.reason } : {} }
|
|
1676
|
+
});
|
|
1677
|
+
return { grantsRevoked, tokensRevoked };
|
|
1678
|
+
}
|
|
1679
|
+
};
|
|
1680
|
+
}
|
|
1681
|
+
function createContextsApi(ctx) {
|
|
1682
|
+
return {
|
|
1683
|
+
/**
|
|
1684
|
+
* A grant made as an employee has to die when the employment does.
|
|
1685
|
+
*
|
|
1686
|
+
* `grantContext.verify()` catches this on the next refresh, but an access
|
|
1687
|
+
* token already issued is valid for its full hour and nothing re-checks it.
|
|
1688
|
+
* This is the push half of that pair, and it is why the adapter has an
|
|
1689
|
+
* outbound direction at all.
|
|
1690
|
+
*/
|
|
1691
|
+
async revoked(userId, contextId) {
|
|
1692
|
+
const live = await ctx.models.Grant.find({ userId, contextId, revokedAt: null }).limit(MAX_LIST).lean();
|
|
1693
|
+
const { grantsRevoked } = await revokeGrantsMatching(ctx, { userId, contextId }, "system");
|
|
1694
|
+
const tokensRevoked = await revokeTokensMatching(ctx, { userId, contextId });
|
|
1695
|
+
await ctx.models.Code.deleteMany({ userId, contextId });
|
|
1696
|
+
for (const grant of live) {
|
|
1697
|
+
ctx.track({
|
|
1698
|
+
type: "oauth.grant_revoked",
|
|
1699
|
+
userId,
|
|
1700
|
+
clientId: grant.clientId,
|
|
1701
|
+
grantId: String(grant._id),
|
|
1702
|
+
contextId,
|
|
1703
|
+
scopes: grant.scopes
|
|
1704
|
+
});
|
|
1705
|
+
await ctx.audit({
|
|
1706
|
+
type: "oauth.grant_revoked",
|
|
1707
|
+
actor: "system",
|
|
1708
|
+
clientId: grant.clientId,
|
|
1709
|
+
userId,
|
|
1710
|
+
grantId: grant._id,
|
|
1711
|
+
meta: { contextId, reason: "context_membership_ended" }
|
|
1712
|
+
});
|
|
1713
|
+
}
|
|
1714
|
+
return { grantsRevoked, tokensRevoked };
|
|
1715
|
+
}
|
|
1716
|
+
};
|
|
1717
|
+
}
|
|
1718
|
+
|
|
1719
|
+
// src/server/services/cimd.ts
|
|
1720
|
+
var NEGATIVE_TTL_MS = 6e4;
|
|
1721
|
+
var MAX_REMEMBERED_FAILURES = 1e3;
|
|
1722
|
+
function refuse(description) {
|
|
1723
|
+
return new UnredirectableError("invalid_client", description);
|
|
1724
|
+
}
|
|
1725
|
+
function isMetadataUrl(clientId) {
|
|
1726
|
+
return /^https:\/\//i.test(clientId);
|
|
1727
|
+
}
|
|
1728
|
+
function hostAllowed(rules, url) {
|
|
1729
|
+
const hostname = url.hostname.toLowerCase();
|
|
1730
|
+
return rules.some((rule) => {
|
|
1731
|
+
if (url.port !== rule.port) return false;
|
|
1732
|
+
if (hostname === rule.host) return true;
|
|
1733
|
+
return rule.subdomains && hostname.endsWith(`.${rule.host}`);
|
|
1734
|
+
});
|
|
1735
|
+
}
|
|
1736
|
+
function assertFetchableUrl(ctx, clientId) {
|
|
1737
|
+
let url;
|
|
1738
|
+
try {
|
|
1739
|
+
url = new URL(clientId);
|
|
1740
|
+
} catch {
|
|
1741
|
+
throw refuse(`client_id is not a valid URL: '${clientId}'`);
|
|
1742
|
+
}
|
|
1743
|
+
if (url.protocol !== "https:") {
|
|
1744
|
+
throw refuse(`client_id metadata must be served over https (got '${url.protocol}')`);
|
|
1745
|
+
}
|
|
1746
|
+
if (url.username || url.password) {
|
|
1747
|
+
throw refuse("client_id must not contain URL credentials");
|
|
1748
|
+
}
|
|
1749
|
+
if (url.hash) {
|
|
1750
|
+
throw refuse("client_id must not contain a fragment");
|
|
1751
|
+
}
|
|
1752
|
+
if (!hostAllowed(ctx.cimd.allowedHosts, url)) {
|
|
1753
|
+
throw refuse(`client_id host '${url.host}' is not in clientIdMetadata.allowedHosts`);
|
|
1754
|
+
}
|
|
1755
|
+
return url;
|
|
1756
|
+
}
|
|
1757
|
+
var JSON_CONTENT_TYPE = /^application\/(?:[\w.+-]+\+)?json\s*(?:;|$)/i;
|
|
1758
|
+
async function readCapped(res, maxBytes) {
|
|
1759
|
+
const body = res.body;
|
|
1760
|
+
if (!body) throw refuse("client_id metadata document was empty");
|
|
1761
|
+
const reader = body.getReader();
|
|
1762
|
+
const chunks = [];
|
|
1763
|
+
let total = 0;
|
|
1764
|
+
for (; ; ) {
|
|
1765
|
+
const { done, value } = await reader.read();
|
|
1766
|
+
if (done) break;
|
|
1767
|
+
if (!value) continue;
|
|
1768
|
+
total += value.byteLength;
|
|
1769
|
+
if (total > maxBytes) {
|
|
1770
|
+
void reader.cancel();
|
|
1771
|
+
throw refuse(`client_id metadata document exceeds ${maxBytes} bytes`);
|
|
1772
|
+
}
|
|
1773
|
+
chunks.push(Buffer.from(value));
|
|
1774
|
+
}
|
|
1775
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
1776
|
+
}
|
|
1777
|
+
async function fetchMetadata(ctx, url, etag) {
|
|
1778
|
+
let res;
|
|
1779
|
+
try {
|
|
1780
|
+
res = await fetch(url.toString(), {
|
|
1781
|
+
method: "GET",
|
|
1782
|
+
// The single most important line in this file. A 3xx is a failure below,
|
|
1783
|
+
// not a hop: following redirects would let an allowlisted host forward us
|
|
1784
|
+
// to any address it likes, allowlist intact.
|
|
1785
|
+
redirect: "manual",
|
|
1786
|
+
headers: {
|
|
1787
|
+
accept: "application/json",
|
|
1788
|
+
...etag ? { "if-none-match": etag } : {}
|
|
1789
|
+
},
|
|
1790
|
+
signal: AbortSignal.timeout(ctx.cimd.fetchTimeoutMs)
|
|
1791
|
+
});
|
|
1792
|
+
} catch (err) {
|
|
1793
|
+
const name = err?.name;
|
|
1794
|
+
if (name === "TimeoutError" || name === "AbortError") {
|
|
1795
|
+
throw refuse(`client_id metadata fetch timed out after ${ctx.cimd.fetchTimeoutMs}ms`);
|
|
1796
|
+
}
|
|
1797
|
+
throw refuse(`client_id metadata could not be fetched: ${err?.message ?? "network error"}`);
|
|
1798
|
+
}
|
|
1799
|
+
if (res.status === 304) return { document: null };
|
|
1800
|
+
if (res.status >= 300 && res.status < 400) {
|
|
1801
|
+
throw refuse(
|
|
1802
|
+
`client_id metadata returned a ${res.status} redirect, which is not followed \u2014 serve the document at the client_id URL itself`
|
|
1803
|
+
);
|
|
1804
|
+
}
|
|
1805
|
+
if (res.status !== 200) {
|
|
1806
|
+
throw refuse(`client_id metadata returned HTTP ${res.status}`);
|
|
1807
|
+
}
|
|
1808
|
+
const contentType = res.headers.get("content-type") ?? "";
|
|
1809
|
+
if (!JSON_CONTENT_TYPE.test(contentType)) {
|
|
1810
|
+
throw refuse(`client_id metadata must be JSON (got content-type '${contentType || "none"}')`);
|
|
1811
|
+
}
|
|
1812
|
+
const text = await readCapped(res, ctx.cimd.maxBytes);
|
|
1813
|
+
let parsed;
|
|
1814
|
+
try {
|
|
1815
|
+
parsed = JSON.parse(text);
|
|
1816
|
+
} catch {
|
|
1817
|
+
throw refuse("client_id metadata document is not valid JSON");
|
|
1818
|
+
}
|
|
1819
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
1820
|
+
throw refuse("client_id metadata document must be a JSON object");
|
|
1821
|
+
}
|
|
1822
|
+
return {
|
|
1823
|
+
document: parsed,
|
|
1824
|
+
...res.headers.get("etag") ? { etag: res.headers.get("etag") } : {}
|
|
1825
|
+
};
|
|
1826
|
+
}
|
|
1827
|
+
function requireString(doc, field) {
|
|
1828
|
+
const value = doc[field];
|
|
1829
|
+
if (typeof value !== "string" || !value.trim()) {
|
|
1830
|
+
throw refuse(`client_id metadata field '${field}' must be a non-empty string`);
|
|
1831
|
+
}
|
|
1832
|
+
return value.trim();
|
|
1833
|
+
}
|
|
1834
|
+
function optionalHttpsUri(doc, field) {
|
|
1835
|
+
const value = doc[field];
|
|
1836
|
+
if (value === void 0 || value === null) return void 0;
|
|
1837
|
+
if (typeof value !== "string" || !value.trim()) {
|
|
1838
|
+
throw refuse(`client_id metadata field '${field}' must be a string`);
|
|
1839
|
+
}
|
|
1840
|
+
let url;
|
|
1841
|
+
try {
|
|
1842
|
+
url = new URL(value);
|
|
1843
|
+
} catch {
|
|
1844
|
+
throw refuse(`client_id metadata field '${field}' must be an absolute URL: '${value}'`);
|
|
1845
|
+
}
|
|
1846
|
+
if (url.protocol !== "https:") {
|
|
1847
|
+
throw refuse(`client_id metadata field '${field}' must be https (got '${url.protocol}')`);
|
|
1848
|
+
}
|
|
1849
|
+
return value;
|
|
1850
|
+
}
|
|
1851
|
+
function assertRedirectUris2(doc) {
|
|
1852
|
+
const value = doc.redirect_uris;
|
|
1853
|
+
if (!Array.isArray(value) || value.length === 0) {
|
|
1854
|
+
throw refuse("client_id metadata field 'redirect_uris' must be a non-empty array");
|
|
1855
|
+
}
|
|
1856
|
+
return value.map((entry) => {
|
|
1857
|
+
if (typeof entry !== "string" || !entry) {
|
|
1858
|
+
throw refuse(`client_id metadata 'redirect_uris' entries must be strings (got ${JSON.stringify(entry)})`);
|
|
1859
|
+
}
|
|
1860
|
+
let url;
|
|
1861
|
+
try {
|
|
1862
|
+
url = new URL(entry);
|
|
1863
|
+
} catch {
|
|
1864
|
+
throw refuse(`client_id metadata 'redirect_uris' entry must be an absolute URI: '${entry}'`);
|
|
1865
|
+
}
|
|
1866
|
+
const loopback = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]";
|
|
1867
|
+
if (url.protocol !== "https:" && !(url.protocol === "http:" && loopback)) {
|
|
1868
|
+
throw refuse(
|
|
1869
|
+
`client_id metadata 'redirect_uris' entry must be https (http is allowed only on localhost/127.0.0.1): '${entry}'`
|
|
1870
|
+
);
|
|
1871
|
+
}
|
|
1872
|
+
if (url.hash) {
|
|
1873
|
+
throw refuse(`client_id metadata 'redirect_uris' entry must not contain a fragment: '${entry}'`);
|
|
1874
|
+
}
|
|
1875
|
+
return entry;
|
|
1876
|
+
});
|
|
1877
|
+
}
|
|
1878
|
+
function validateMetadataDocument(ctx, url, doc) {
|
|
1879
|
+
const declared = requireString(doc, "client_id");
|
|
1880
|
+
let declaredUrl;
|
|
1881
|
+
try {
|
|
1882
|
+
declaredUrl = new URL(declared);
|
|
1883
|
+
} catch {
|
|
1884
|
+
throw refuse(`client_id metadata field 'client_id' is not a URL: '${declared}'`);
|
|
1885
|
+
}
|
|
1886
|
+
if (declaredUrl.href !== url.href) {
|
|
1887
|
+
throw refuse(
|
|
1888
|
+
`client_id metadata field 'client_id' is '${declared}', which is not the URL it was fetched from ('${url.href}')`
|
|
1889
|
+
);
|
|
1890
|
+
}
|
|
1891
|
+
const method = doc.token_endpoint_auth_method;
|
|
1892
|
+
if (method !== void 0 && method !== "none") {
|
|
1893
|
+
throw refuse(
|
|
1894
|
+
`client_id metadata field 'token_endpoint_auth_method' must be 'none' (got ${JSON.stringify(method)}) \u2014 a CIMD client holds no secret`
|
|
1895
|
+
);
|
|
1896
|
+
}
|
|
1897
|
+
const name = requireString(doc, "client_name");
|
|
1898
|
+
const redirectUris = assertRedirectUris2(doc);
|
|
1899
|
+
const permitted = new Set(ctx.cimd.allowedScopes.filter((id) => ctx.scopeIndex.has(id)));
|
|
1900
|
+
let allowedScopes;
|
|
1901
|
+
if (doc.scope === void 0 || doc.scope === null) {
|
|
1902
|
+
allowedScopes = [...permitted];
|
|
1903
|
+
} else {
|
|
1904
|
+
if (typeof doc.scope !== "string") {
|
|
1905
|
+
throw refuse("client_id metadata field 'scope' must be a space-delimited string");
|
|
1906
|
+
}
|
|
1907
|
+
const requested = new Set(doc.scope.split(/\s+/).filter(Boolean));
|
|
1908
|
+
allowedScopes = [...permitted].filter((id) => requested.has(id));
|
|
1909
|
+
}
|
|
1910
|
+
if (allowedScopes.length === 0) {
|
|
1911
|
+
throw refuse(
|
|
1912
|
+
"client_id metadata field 'scope' has no overlap with what this server offers \u2014 there is nothing this client could be granted"
|
|
1913
|
+
);
|
|
1914
|
+
}
|
|
1915
|
+
const branding = {};
|
|
1916
|
+
const logoUrl = optionalHttpsUri(doc, "logo_uri");
|
|
1917
|
+
const homepageUrl = optionalHttpsUri(doc, "client_uri");
|
|
1918
|
+
const tosUrl = optionalHttpsUri(doc, "tos_uri");
|
|
1919
|
+
const privacyUrl = optionalHttpsUri(doc, "policy_uri");
|
|
1920
|
+
if (logoUrl) branding.logoUrl = logoUrl;
|
|
1921
|
+
if (homepageUrl) branding.homepageUrl = homepageUrl;
|
|
1922
|
+
if (tosUrl) branding.tosUrl = tosUrl;
|
|
1923
|
+
if (privacyUrl) branding.privacyUrl = privacyUrl;
|
|
1924
|
+
return { name, redirectUris, allowedScopes, branding };
|
|
1925
|
+
}
|
|
1926
|
+
function rememberFailure(ctx, key, message) {
|
|
1927
|
+
const { failures } = ctx.cimd;
|
|
1928
|
+
if (failures.size >= MAX_REMEMBERED_FAILURES) {
|
|
1929
|
+
const oldest = failures.keys().next();
|
|
1930
|
+
if (!oldest.done) failures.delete(oldest.value);
|
|
1931
|
+
}
|
|
1932
|
+
failures.set(key, { until: Date.now() + NEGATIVE_TTL_MS, message });
|
|
1933
|
+
}
|
|
1934
|
+
function rememberedFailure(ctx, key) {
|
|
1935
|
+
const found = ctx.cimd.failures.get(key);
|
|
1936
|
+
if (!found) return null;
|
|
1937
|
+
if (found.until <= Date.now()) {
|
|
1938
|
+
ctx.cimd.failures.delete(key);
|
|
1939
|
+
return null;
|
|
1940
|
+
}
|
|
1941
|
+
return found.message;
|
|
1942
|
+
}
|
|
1943
|
+
function isFresh(ctx, client) {
|
|
1944
|
+
const fetchedAt = client.metadataFetchedAt?.getTime();
|
|
1945
|
+
if (fetchedAt === void 0) return false;
|
|
1946
|
+
return Date.now() - fetchedAt < ctx.cimd.cacheTtlMs;
|
|
1947
|
+
}
|
|
1948
|
+
async function persist(ctx, clientId, url, registration, etag) {
|
|
1949
|
+
const doc = await ctx.models.Client.findOneAndUpdate(
|
|
1950
|
+
{ clientId },
|
|
1951
|
+
{
|
|
1952
|
+
$set: {
|
|
1953
|
+
name: registration.name,
|
|
1954
|
+
redirectUris: registration.redirectUris,
|
|
1955
|
+
allowedScopes: registration.allowedScopes,
|
|
1956
|
+
// Every declared resource. A CIMD client cannot express an audience
|
|
1957
|
+
// preference, and RFC 8707 validation at `/authorize` narrows it anyway.
|
|
1958
|
+
allowedResources: ctx.resources.map((r) => r.id),
|
|
1959
|
+
branding: registration.branding,
|
|
1960
|
+
metadataUrl: url.href,
|
|
1961
|
+
metadataFetchedAt: /* @__PURE__ */ new Date(),
|
|
1962
|
+
...etag ? { metadataEtag: etag } : {}
|
|
1963
|
+
},
|
|
1964
|
+
$setOnInsert: {
|
|
1965
|
+
type: "public",
|
|
1966
|
+
registration: "cimd",
|
|
1967
|
+
trusted: false,
|
|
1968
|
+
secrets: [],
|
|
1969
|
+
status: "active"
|
|
1970
|
+
}
|
|
1971
|
+
},
|
|
1972
|
+
{ upsert: true, returnDocument: "after", setDefaultsOnInsert: false }
|
|
1973
|
+
).exec();
|
|
1974
|
+
return doc;
|
|
1975
|
+
}
|
|
1976
|
+
async function resolveCimdClient(ctx, clientId) {
|
|
1977
|
+
const url = assertFetchableUrl(ctx, clientId);
|
|
1978
|
+
const existing = await ctx.models.Client.findOne({ clientId }).exec();
|
|
1979
|
+
if (existing) {
|
|
1980
|
+
if (existing.status !== "active") {
|
|
1981
|
+
throw refuse(`client '${clientId}' is disabled`);
|
|
1982
|
+
}
|
|
1983
|
+
if (existing.registration !== "cimd") return existing;
|
|
1984
|
+
if (isFresh(ctx, existing)) return existing;
|
|
1985
|
+
}
|
|
1986
|
+
const remembered = rememberedFailure(ctx, url.href);
|
|
1987
|
+
if (remembered) throw refuse(remembered);
|
|
1988
|
+
try {
|
|
1989
|
+
const { document, etag } = await fetchMetadata(ctx, url, existing?.metadataEtag);
|
|
1990
|
+
if (!document) {
|
|
1991
|
+
if (existing) {
|
|
1992
|
+
existing.metadataFetchedAt = /* @__PURE__ */ new Date();
|
|
1993
|
+
await existing.save();
|
|
1994
|
+
return existing;
|
|
1995
|
+
}
|
|
1996
|
+
throw refuse("client_id metadata returned 304 with nothing cached to answer from");
|
|
1997
|
+
}
|
|
1998
|
+
const registration = validateMetadataDocument(ctx, url, document);
|
|
1999
|
+
const client = await persist(ctx, clientId, url, registration, etag);
|
|
2000
|
+
ctx.logger.debug?.({ clientId }, "oauth-host: client_id metadata document resolved");
|
|
2001
|
+
return client;
|
|
2002
|
+
} catch (err) {
|
|
2003
|
+
const message = err instanceof UnredirectableError ? err.description ?? "client_id metadata could not be resolved" : "client_id metadata could not be resolved";
|
|
2004
|
+
rememberFailure(ctx, url.href, message);
|
|
2005
|
+
ctx.logger.warn?.({ clientId, err }, "oauth-host: client_id metadata resolution failed");
|
|
2006
|
+
throw err instanceof UnredirectableError ? err : refuse(message);
|
|
2007
|
+
}
|
|
2008
|
+
}
|
|
2009
|
+
|
|
2010
|
+
// src/server/services/scopes.ts
|
|
2011
|
+
function parseScope(raw) {
|
|
2012
|
+
if (typeof raw !== "string") return [];
|
|
2013
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2014
|
+
for (const id of raw.split(/\s+/)) if (id) seen.add(id);
|
|
2015
|
+
return [...seen];
|
|
2016
|
+
}
|
|
2017
|
+
function validateScopes(ctx, requested, client) {
|
|
2018
|
+
const allowed = new Set(client.allowedScopes);
|
|
2019
|
+
for (const id of requested) {
|
|
2020
|
+
if (!ctx.scopeIndex.has(id)) {
|
|
2021
|
+
throw invalidScope(`unknown scope '${id}'`);
|
|
2022
|
+
}
|
|
2023
|
+
if (!allowed.has(id)) {
|
|
2024
|
+
throw invalidScope(`client '${client.clientId}' is not allowed the scope '${id}'`);
|
|
2025
|
+
}
|
|
2026
|
+
}
|
|
2027
|
+
return [...requested];
|
|
2028
|
+
}
|
|
2029
|
+
function resolveDefaultScopes(ctx, client) {
|
|
2030
|
+
if (!ctx.defaultScopes) {
|
|
2031
|
+
throw invalidScope(
|
|
2032
|
+
"no `scope` was requested and no `defaultScopes` is configured. Send `scope` on /authorize, or configure `defaultScopes` on createOAuthHost \u2014 an empty scope set would issue a token that can do nothing."
|
|
2033
|
+
);
|
|
2034
|
+
}
|
|
2035
|
+
const allowed = new Set(client.allowedScopes);
|
|
2036
|
+
const granted = ctx.defaultScopes.filter((id) => allowed.has(id));
|
|
2037
|
+
if (granted.length === 0) {
|
|
2038
|
+
throw invalidScope(
|
|
2039
|
+
`no \`scope\` was requested and client '${client.clientId}' is allowed none of the configured defaultScopes (${ctx.defaultScopes.join(" ")}). Send \`scope\` explicitly, or add one of those scopes to the client's allowedScopes.`
|
|
2040
|
+
);
|
|
2041
|
+
}
|
|
2042
|
+
return granted;
|
|
2043
|
+
}
|
|
2044
|
+
function describeScopes(ctx, ids, opts = {}) {
|
|
2045
|
+
const granted = new Set(opts.previouslyGranted ?? []);
|
|
2046
|
+
return ids.map((id) => {
|
|
2047
|
+
const spec = ctx.scopeIndex.get(id) ?? { label: id };
|
|
2048
|
+
return {
|
|
2049
|
+
id,
|
|
2050
|
+
label: spec.label ?? id,
|
|
2051
|
+
...spec.description ? { description: spec.description } : {},
|
|
2052
|
+
...spec.sensitive ? { sensitive: true } : {},
|
|
2053
|
+
isNew: !granted.has(id)
|
|
2054
|
+
};
|
|
2055
|
+
});
|
|
2056
|
+
}
|
|
2057
|
+
function validateResources(ctx, requested, client) {
|
|
2058
|
+
const declared = new Set(ctx.resources.map((r) => r.id));
|
|
2059
|
+
const allowed = client.allowedResources.length ? new Set(client.allowedResources) : declared;
|
|
2060
|
+
if (requested.length === 0) {
|
|
2061
|
+
const fallback = client.allowedResources[0] ?? ctx.resources[0]?.id;
|
|
2062
|
+
return fallback ? [fallback] : [];
|
|
2063
|
+
}
|
|
2064
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2065
|
+
for (const id of requested) {
|
|
2066
|
+
if (!declared.has(id)) {
|
|
2067
|
+
throw new OAuthError(400, "invalid_target", `unknown resource '${id}'`);
|
|
2068
|
+
}
|
|
2069
|
+
if (!allowed.has(id)) {
|
|
2070
|
+
throw new OAuthError(
|
|
2071
|
+
400,
|
|
2072
|
+
"invalid_target",
|
|
2073
|
+
`client '${client.clientId}' is not allowed the resource '${id}'`
|
|
2074
|
+
);
|
|
2075
|
+
}
|
|
2076
|
+
seen.add(id);
|
|
2077
|
+
}
|
|
2078
|
+
return [...seen];
|
|
2079
|
+
}
|
|
2080
|
+
|
|
2081
|
+
// src/server/services/authorize.ts
|
|
2082
|
+
function one(query, name) {
|
|
2083
|
+
const value = query[name];
|
|
2084
|
+
return typeof value === "string" && value.length > 0 ? value : void 0;
|
|
2085
|
+
}
|
|
2086
|
+
function many(query, name) {
|
|
2087
|
+
const value = query[name];
|
|
2088
|
+
if (typeof value === "string") return value ? [value] : [];
|
|
2089
|
+
if (!Array.isArray(value)) return [];
|
|
2090
|
+
return value.filter((v) => typeof v === "string" && v.length > 0);
|
|
2091
|
+
}
|
|
2092
|
+
async function resolveClient(ctx, clientId) {
|
|
2093
|
+
if (ctx.cimd.enabled && isMetadataUrl(clientId)) {
|
|
2094
|
+
return resolveCimdClient(ctx, clientId);
|
|
2095
|
+
}
|
|
2096
|
+
const client = await ctx.models.Client.findOne({ clientId }).exec();
|
|
2097
|
+
if (!client) {
|
|
2098
|
+
throw new UnredirectableError("invalid_client", `unknown client_id '${clientId}'`);
|
|
2099
|
+
}
|
|
2100
|
+
return client;
|
|
2101
|
+
}
|
|
2102
|
+
async function validateAuthorizationRequest(ctx, query) {
|
|
2103
|
+
const clientId = one(query, "client_id");
|
|
2104
|
+
if (!clientId) {
|
|
2105
|
+
throw new UnredirectableError("invalid_request", "`client_id` is required");
|
|
2106
|
+
}
|
|
2107
|
+
const client = await resolveClient(ctx, clientId);
|
|
2108
|
+
if (client.status !== "active") {
|
|
2109
|
+
throw new UnredirectableError("invalid_client", `client '${clientId}' is disabled`);
|
|
2110
|
+
}
|
|
2111
|
+
const redirectUri = one(query, "redirect_uri");
|
|
2112
|
+
if (!redirectUri) {
|
|
2113
|
+
throw new UnredirectableError("invalid_request", "`redirect_uri` is required");
|
|
2114
|
+
}
|
|
2115
|
+
if (!redirectUriRegistered(client.redirectUris, redirectUri)) {
|
|
2116
|
+
throw new UnredirectableError(
|
|
2117
|
+
"invalid_request",
|
|
2118
|
+
`redirect_uri is not registered for client '${clientId}'`
|
|
2119
|
+
);
|
|
2120
|
+
}
|
|
2121
|
+
const state = one(query, "state");
|
|
2122
|
+
const fail = (code, description) => new RedirectableAuthError(code, description, redirectUri, state);
|
|
2123
|
+
const responseType = one(query, "response_type");
|
|
2124
|
+
if (responseType !== "code") {
|
|
2125
|
+
throw new RedirectableAuthError(
|
|
2126
|
+
"unsupported_response_type",
|
|
2127
|
+
"only `response_type=code` is supported",
|
|
2128
|
+
redirectUri,
|
|
2129
|
+
state
|
|
2130
|
+
);
|
|
2131
|
+
}
|
|
2132
|
+
const codeChallenge = one(query, "code_challenge");
|
|
2133
|
+
if (!codeChallenge) {
|
|
2134
|
+
throw fail("invalid_request", "`code_challenge` is required (PKCE, RFC 7636)");
|
|
2135
|
+
}
|
|
2136
|
+
if (one(query, "code_challenge_method") !== "S256") {
|
|
2137
|
+
throw fail("invalid_request", "`code_challenge_method` must be `S256`");
|
|
2138
|
+
}
|
|
2139
|
+
let scopes;
|
|
2140
|
+
let resources;
|
|
2141
|
+
try {
|
|
2142
|
+
const requested = parseScope(one(query, "scope"));
|
|
2143
|
+
scopes = requested.length ? validateScopes(ctx, requested, client) : resolveDefaultScopes(ctx, client);
|
|
2144
|
+
resources = validateResources(ctx, many(query, "resource"), client);
|
|
2145
|
+
} catch (err) {
|
|
2146
|
+
if (err instanceof OAuthError) throw fail(err.code, err.description ?? err.code);
|
|
2147
|
+
throw err;
|
|
2148
|
+
}
|
|
2149
|
+
let maxAge;
|
|
2150
|
+
const rawMaxAge = one(query, "max_age");
|
|
2151
|
+
if (rawMaxAge !== void 0) {
|
|
2152
|
+
const parsed = Number(rawMaxAge);
|
|
2153
|
+
if (!Number.isInteger(parsed) || parsed < 0) {
|
|
2154
|
+
throw fail("invalid_request", "`max_age` must be a non-negative integer");
|
|
2155
|
+
}
|
|
2156
|
+
maxAge = parsed;
|
|
2157
|
+
}
|
|
2158
|
+
return {
|
|
2159
|
+
client,
|
|
2160
|
+
redirectUri,
|
|
2161
|
+
scopes,
|
|
2162
|
+
resources,
|
|
2163
|
+
...state !== void 0 ? { state } : {},
|
|
2164
|
+
...one(query, "nonce") ? { nonce: one(query, "nonce") } : {},
|
|
2165
|
+
codeChallenge,
|
|
2166
|
+
...one(query, "prompt") ? { prompt: one(query, "prompt") } : {},
|
|
2167
|
+
...maxAge !== void 0 ? { maxAge } : {}
|
|
2168
|
+
};
|
|
2169
|
+
}
|
|
2170
|
+
async function createAuthorizationRequest(ctx, validated, user) {
|
|
2171
|
+
const doc = await ctx.models.Request.create({
|
|
2172
|
+
requestId: randomToken(),
|
|
2173
|
+
clientId: validated.client.clientId,
|
|
2174
|
+
userId: user.id,
|
|
2175
|
+
redirectUri: validated.redirectUri,
|
|
2176
|
+
scopes: validated.scopes,
|
|
2177
|
+
resources: validated.resources,
|
|
2178
|
+
state: validated.state,
|
|
2179
|
+
nonce: validated.nonce,
|
|
2180
|
+
codeChallenge: validated.codeChallenge,
|
|
2181
|
+
codeChallengeMethod: "S256",
|
|
2182
|
+
prompt: validated.prompt,
|
|
2183
|
+
maxAge: validated.maxAge,
|
|
2184
|
+
expiresAt: new Date(Date.now() + ctx.ttl.authorizationRequest * 1e3)
|
|
2185
|
+
});
|
|
2186
|
+
ctx.track({
|
|
2187
|
+
type: "oauth.authorization_requested",
|
|
2188
|
+
userId: user.id,
|
|
2189
|
+
clientId: validated.client.clientId,
|
|
2190
|
+
scopes: validated.scopes,
|
|
2191
|
+
meta: { resources: validated.resources }
|
|
2192
|
+
});
|
|
2193
|
+
return doc;
|
|
2194
|
+
}
|
|
2195
|
+
function withParam(configured, name, value) {
|
|
2196
|
+
const absolute = /^https?:\/\//i.test(configured);
|
|
2197
|
+
const url = new URL(configured, "http://oauth-host.invalid");
|
|
2198
|
+
url.searchParams.set(name, value);
|
|
2199
|
+
return absolute ? url.toString() : `${url.pathname}${url.search}${url.hash}`;
|
|
2200
|
+
}
|
|
2201
|
+
function consentRedirectUrl(ctx, requestId) {
|
|
2202
|
+
return withParam(ctx.consentUrl, "request_id", requestId);
|
|
2203
|
+
}
|
|
2204
|
+
function loginRedirectUrl(ctx, returnTo) {
|
|
2205
|
+
if (!ctx.loginUrl) return null;
|
|
2206
|
+
return withParam(ctx.loginUrl, ctx.returnParam, returnTo);
|
|
2207
|
+
}
|
|
2208
|
+
|
|
2209
|
+
// src/server/services/consent.ts
|
|
2210
|
+
var CONSENT_CONTEXT_LIMIT = 50;
|
|
2211
|
+
function sameUser(a, b) {
|
|
2212
|
+
return String(a) === String(b);
|
|
2213
|
+
}
|
|
2214
|
+
async function loadPending(ctx, requestId, user) {
|
|
2215
|
+
const request = await ctx.models.Request.findOne({ requestId }).exec();
|
|
2216
|
+
if (!request || request.expiresAt.getTime() <= Date.now() || request.decision) {
|
|
2217
|
+
throw new OAuthError(404, "invalid_request", "unknown or expired authorization request");
|
|
2218
|
+
}
|
|
2219
|
+
if (!sameUser(request.userId, user.id)) {
|
|
2220
|
+
throw accessDenied("this authorization request belongs to another user");
|
|
2221
|
+
}
|
|
2222
|
+
const client = await ctx.models.Client.findOne({ clientId: request.clientId }).exec();
|
|
2223
|
+
if (!client || client.status !== "active") {
|
|
2224
|
+
throw new OAuthError(404, "invalid_request", "unknown or expired authorization request");
|
|
2225
|
+
}
|
|
2226
|
+
return { request, client };
|
|
2227
|
+
}
|
|
2228
|
+
async function alreadyGranted(ctx, clientId, userId) {
|
|
2229
|
+
const grants = await ctx.models.Grant.find({ clientId, userId, revokedAt: null }).select("scopes").lean().exec();
|
|
2230
|
+
return [...new Set(grants.flatMap((g) => g.scopes))];
|
|
2231
|
+
}
|
|
2232
|
+
async function getConsentPayload(ctx, requestId, user) {
|
|
2233
|
+
const { request, client } = await loadPending(ctx, requestId, user);
|
|
2234
|
+
const previouslyGranted = await alreadyGranted(ctx, request.clientId, request.userId);
|
|
2235
|
+
const branding = client.branding ?? {};
|
|
2236
|
+
const payload = {
|
|
2237
|
+
client: {
|
|
2238
|
+
name: client.name,
|
|
2239
|
+
...branding.logoUrl ? { logoUrl: branding.logoUrl } : {},
|
|
2240
|
+
...branding.publisher ? { publisher: branding.publisher } : {},
|
|
2241
|
+
...branding.homepageUrl ? { homepageUrl: branding.homepageUrl } : {},
|
|
2242
|
+
...branding.tosUrl ? { tosUrl: branding.tosUrl } : {},
|
|
2243
|
+
...branding.privacyUrl ? { privacyUrl: branding.privacyUrl } : {}
|
|
2244
|
+
},
|
|
2245
|
+
scopes: describeScopes(ctx, request.scopes, { previouslyGranted }),
|
|
2246
|
+
user: {
|
|
2247
|
+
displayName: user.displayName ?? null,
|
|
2248
|
+
...user.email ? { email: user.email } : {}
|
|
2249
|
+
},
|
|
2250
|
+
expiresAt: request.expiresAt
|
|
2251
|
+
};
|
|
2252
|
+
if (ctx.grantContext) {
|
|
2253
|
+
const all = await ctx.grantContext.list(user, {
|
|
2254
|
+
client: toPublicClient(client),
|
|
2255
|
+
scopes: request.scopes
|
|
2256
|
+
});
|
|
2257
|
+
payload.contexts = all.slice(0, CONSENT_CONTEXT_LIMIT);
|
|
2258
|
+
payload.contextsHasMore = all.length > CONSENT_CONTEXT_LIMIT;
|
|
2259
|
+
}
|
|
2260
|
+
return payload;
|
|
2261
|
+
}
|
|
2262
|
+
function authorizationResponse(ctx, redirectUri, state, params) {
|
|
2263
|
+
const url = new URL(redirectUri);
|
|
2264
|
+
for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value);
|
|
2265
|
+
if (state !== void 0) url.searchParams.set("state", state);
|
|
2266
|
+
url.searchParams.set("iss", ctx.issuer);
|
|
2267
|
+
return url.toString();
|
|
2268
|
+
}
|
|
2269
|
+
async function decideConsent(ctx, requestId, user, decision) {
|
|
2270
|
+
const { request, client } = await loadPending(ctx, requestId, user);
|
|
2271
|
+
const approvedScopes = decision.approve ? [...new Set(decision.scopes ?? request.scopes)] : [];
|
|
2272
|
+
for (const id of approvedScopes) {
|
|
2273
|
+
if (!request.scopes.includes(id)) {
|
|
2274
|
+
throw invalidRequest(`scope '${id}' was not part of this authorization request`);
|
|
2275
|
+
}
|
|
2276
|
+
}
|
|
2277
|
+
let contextId = null;
|
|
2278
|
+
if (decision.approve) {
|
|
2279
|
+
if (ctx.grantContext) {
|
|
2280
|
+
if (!decision.contextId) {
|
|
2281
|
+
throw invalidRequest("`contextId` is required \u2014 this host configured a grant context");
|
|
2282
|
+
}
|
|
2283
|
+
if (!await ctx.grantContext.verify(user, decision.contextId)) {
|
|
2284
|
+
throw accessDenied(`not a member of context '${decision.contextId}'`);
|
|
2285
|
+
}
|
|
2286
|
+
contextId = decision.contextId;
|
|
2287
|
+
} else if (decision.contextId) {
|
|
2288
|
+
throw invalidRequest("`contextId` is not accepted \u2014 this host has no grant context");
|
|
2289
|
+
}
|
|
2290
|
+
}
|
|
2291
|
+
const claimed = await ctx.models.Request.findOneAndUpdate(
|
|
2292
|
+
{ requestId, decision: null },
|
|
2293
|
+
{ $set: { decision: decision.approve ? "approved" : "denied", decidedAt: /* @__PURE__ */ new Date() } },
|
|
2294
|
+
{ returnDocument: "after" }
|
|
2295
|
+
).exec();
|
|
2296
|
+
if (!claimed) {
|
|
2297
|
+
throw invalidRequest("this authorization request has already been decided");
|
|
2298
|
+
}
|
|
2299
|
+
if (!decision.approve) {
|
|
2300
|
+
ctx.track({
|
|
2301
|
+
type: "oauth.consent_denied",
|
|
2302
|
+
userId: user.id,
|
|
2303
|
+
clientId: request.clientId,
|
|
2304
|
+
scopes: request.scopes
|
|
2305
|
+
});
|
|
2306
|
+
await ctx.audit({
|
|
2307
|
+
type: "oauth.consent_denied",
|
|
2308
|
+
actor: "user",
|
|
2309
|
+
clientId: request.clientId,
|
|
2310
|
+
userId: user.id
|
|
2311
|
+
});
|
|
2312
|
+
return {
|
|
2313
|
+
redirectTo: authorizationResponse(ctx, request.redirectUri, request.state, {
|
|
2314
|
+
error: "access_denied",
|
|
2315
|
+
error_description: "the user denied the request"
|
|
2316
|
+
})
|
|
2317
|
+
};
|
|
2318
|
+
}
|
|
2319
|
+
const now = /* @__PURE__ */ new Date();
|
|
2320
|
+
const existing = await ctx.models.Grant.findOne({
|
|
2321
|
+
clientId: request.clientId,
|
|
2322
|
+
userId: request.userId,
|
|
2323
|
+
contextId,
|
|
2324
|
+
revokedAt: null
|
|
2325
|
+
}).exec();
|
|
2326
|
+
const scopes = [.../* @__PURE__ */ new Set([...existing?.scopes ?? [], ...approvedScopes])];
|
|
2327
|
+
const resources = [.../* @__PURE__ */ new Set([...existing?.resources ?? [], ...request.resources])];
|
|
2328
|
+
const grew = Boolean(existing) && scopes.length > (existing?.scopes.length ?? 0);
|
|
2329
|
+
const grant = await ctx.models.Grant.findOneAndUpdate(
|
|
2330
|
+
{ clientId: request.clientId, userId: request.userId, contextId, revokedAt: null },
|
|
2331
|
+
{
|
|
2332
|
+
$set: { scopes, resources, lastUsedAt: now },
|
|
2333
|
+
// Mongo rejects an update that touches one path through two operators,
|
|
2334
|
+
// even when only one of them can apply — and `grew` already implies an
|
|
2335
|
+
// existing document, so the two branches are genuinely exclusive.
|
|
2336
|
+
...grew ? { $inc: { version: 1 } } : { $setOnInsert: { version: 1 } }
|
|
2337
|
+
},
|
|
2338
|
+
{ returnDocument: "after", upsert: true, setDefaultsOnInsert: true }
|
|
2339
|
+
).exec();
|
|
2340
|
+
const code = randomToken();
|
|
2341
|
+
await ctx.models.Code.create({
|
|
2342
|
+
codeHash: sha256(code),
|
|
2343
|
+
clientId: request.clientId,
|
|
2344
|
+
userId: request.userId,
|
|
2345
|
+
grantId: grant._id,
|
|
2346
|
+
contextId,
|
|
2347
|
+
// What was approved now, not the grant's union: the token issued from this
|
|
2348
|
+
// code answers for this authorization, not for the history of the grant.
|
|
2349
|
+
scopes: approvedScopes,
|
|
2350
|
+
resources: request.resources,
|
|
2351
|
+
redirectUri: request.redirectUri,
|
|
2352
|
+
codeChallenge: request.codeChallenge,
|
|
2353
|
+
codeChallengeMethod: "S256",
|
|
2354
|
+
nonce: request.nonce,
|
|
2355
|
+
authTime: user.authTime,
|
|
2356
|
+
expiresAt: new Date(now.getTime() + ctx.ttl.code * 1e3)
|
|
2357
|
+
});
|
|
2358
|
+
ctx.track({
|
|
2359
|
+
type: "oauth.consent_granted",
|
|
2360
|
+
userId: user.id,
|
|
2361
|
+
clientId: request.clientId,
|
|
2362
|
+
grantId: String(grant._id),
|
|
2363
|
+
...contextId ? { contextId } : {},
|
|
2364
|
+
scopes: approvedScopes
|
|
2365
|
+
});
|
|
2366
|
+
await ctx.audit({
|
|
2367
|
+
type: "oauth.consent_granted",
|
|
2368
|
+
actor: "user",
|
|
2369
|
+
clientId: request.clientId,
|
|
2370
|
+
userId: user.id,
|
|
2371
|
+
grantId: grant._id,
|
|
2372
|
+
meta: { scopes: approvedScopes, contextId, version: grant.version }
|
|
2373
|
+
});
|
|
2374
|
+
return {
|
|
2375
|
+
redirectTo: authorizationResponse(ctx, request.redirectUri, request.state, { code })
|
|
2376
|
+
};
|
|
2377
|
+
}
|
|
2378
|
+
var b64u = (value) => Buffer.from(value).toString("base64url");
|
|
2379
|
+
var RESERVED_ID_TOKEN_CLAIMS = /* @__PURE__ */ new Set(["iss", "sub", "aud", "exp", "iat", "nonce", "at_hash"]);
|
|
2380
|
+
function signJws(key, payload) {
|
|
2381
|
+
const header = { alg: key.alg, typ: "JWT", kid: key.kid };
|
|
2382
|
+
const data = `${b64u(JSON.stringify(header))}.${b64u(JSON.stringify(payload))}`;
|
|
2383
|
+
const signature = sign("sha256", Buffer.from(data), {
|
|
2384
|
+
key: key.privateKey,
|
|
2385
|
+
dsaEncoding: "ieee-p1363"
|
|
2386
|
+
});
|
|
2387
|
+
return `${data}.${signature.toString("base64url")}`;
|
|
2388
|
+
}
|
|
2389
|
+
function atHash(accessToken) {
|
|
2390
|
+
const digest = createHash("sha256").update(accessToken, "ascii").digest();
|
|
2391
|
+
return digest.subarray(0, digest.length / 2).toString("base64url");
|
|
2392
|
+
}
|
|
2393
|
+
var publicClient = toPublicClient;
|
|
2394
|
+
function storedSubject(client, key) {
|
|
2395
|
+
const map = client.pairwiseSubjects;
|
|
2396
|
+
if (map instanceof Map) return map.get(key);
|
|
2397
|
+
if (map && typeof map === "object") return map[key];
|
|
2398
|
+
return void 0;
|
|
2399
|
+
}
|
|
2400
|
+
async function subjectFor(ctx, client, userId) {
|
|
2401
|
+
const key = String(userId);
|
|
2402
|
+
if (ctx.subjectMode !== "pairwise") return key;
|
|
2403
|
+
if (!ctx.pairwiseSalt) {
|
|
2404
|
+
throw new Error('oauth-host: subjectMode is "pairwise" but no `pairwiseSalt` is configured');
|
|
2405
|
+
}
|
|
2406
|
+
const existing = storedSubject(client, key);
|
|
2407
|
+
if (existing) return existing;
|
|
2408
|
+
const subject = pairwiseSubject(key, client.clientId, ctx.pairwiseSalt);
|
|
2409
|
+
await ctx.models.Client.updateOne(
|
|
2410
|
+
{ clientId: client.clientId, [`pairwiseSubjects.${key}`]: { $exists: false } },
|
|
2411
|
+
{ $set: { [`pairwiseSubjects.${key}`]: subject } }
|
|
2412
|
+
);
|
|
2413
|
+
const stored = await ctx.models.Client.findOne(
|
|
2414
|
+
{ clientId: client.clientId },
|
|
2415
|
+
{ pairwiseSubjects: 1 }
|
|
2416
|
+
);
|
|
2417
|
+
return stored && storedSubject(stored, key) || subject;
|
|
2418
|
+
}
|
|
2419
|
+
function standardClaims(user, scopes) {
|
|
2420
|
+
const out = {};
|
|
2421
|
+
const granted = new Set(scopes);
|
|
2422
|
+
if (granted.has("profile")) {
|
|
2423
|
+
if (user.displayName) out.name = user.displayName;
|
|
2424
|
+
if (user.avatarUrl) out.picture = user.avatarUrl;
|
|
2425
|
+
}
|
|
2426
|
+
if (granted.has("email") && user.email) out.email = user.email;
|
|
2427
|
+
return out;
|
|
2428
|
+
}
|
|
2429
|
+
async function hostClaims(ctx, args) {
|
|
2430
|
+
if (!ctx.claims) return {};
|
|
2431
|
+
return await ctx.claims(args.user, {
|
|
2432
|
+
scopes: args.scopes,
|
|
2433
|
+
contextId: args.contextId ?? void 0,
|
|
2434
|
+
client: publicClient(args.client)
|
|
2435
|
+
});
|
|
2436
|
+
}
|
|
2437
|
+
async function buildIdToken(ctx, keys, args) {
|
|
2438
|
+
const key = await keys.getSigningKey();
|
|
2439
|
+
const now = Date.now();
|
|
2440
|
+
const skew = ctx.clockSkewMs;
|
|
2441
|
+
const payload = {
|
|
2442
|
+
...standardClaims(args.user, args.scopes),
|
|
2443
|
+
...await hostClaims(ctx, args)
|
|
2444
|
+
};
|
|
2445
|
+
for (const claim of RESERVED_ID_TOKEN_CLAIMS) delete payload[claim];
|
|
2446
|
+
payload.iss = ctx.issuer;
|
|
2447
|
+
payload.sub = await subjectFor(ctx, args.client, args.user.id);
|
|
2448
|
+
payload.aud = args.client.clientId;
|
|
2449
|
+
payload.iat = Math.floor(now / 1e3);
|
|
2450
|
+
payload.nbf = Math.floor((now - skew) / 1e3);
|
|
2451
|
+
payload.exp = Math.floor((now + ctx.ttl.accessToken * 1e3 + skew) / 1e3);
|
|
2452
|
+
const authTime = args.authTime ?? args.user.authTime;
|
|
2453
|
+
if (authTime) payload.auth_time = Math.floor(authTime.getTime() / 1e3);
|
|
2454
|
+
if (args.nonce) payload.nonce = args.nonce;
|
|
2455
|
+
if (args.accessToken) payload.at_hash = atHash(args.accessToken);
|
|
2456
|
+
return signJws(key, payload);
|
|
2457
|
+
}
|
|
2458
|
+
async function buildUserInfo(ctx, args) {
|
|
2459
|
+
const claims = {
|
|
2460
|
+
...standardClaims(args.user, args.scopes),
|
|
2461
|
+
...await hostClaims(ctx, args)
|
|
2462
|
+
};
|
|
2463
|
+
delete claims.sub;
|
|
2464
|
+
return { sub: await subjectFor(ctx, args.client, args.user.id), ...claims };
|
|
2465
|
+
}
|
|
2466
|
+
|
|
2467
|
+
// src/server/routes/oauth.ts
|
|
2468
|
+
function createOAuthRouter(ctx) {
|
|
2469
|
+
const router = express2.Router();
|
|
2470
|
+
const w = (h) => wrap(ctx.logger, h);
|
|
2471
|
+
const keys = createKeyManager(ctx);
|
|
2472
|
+
const form = express2.urlencoded({ extended: false, limit: "10kb" });
|
|
2473
|
+
const ipKey = (req) => req.ip ?? "unknown";
|
|
2474
|
+
const clientKey = (req) => clientIdFromRequest(req) ?? ipKey(req);
|
|
2475
|
+
const cors = corsMiddleware(ctx);
|
|
2476
|
+
router.get(
|
|
2477
|
+
"/authorize",
|
|
2478
|
+
rateLimit(ctx, "authorize", ipKey),
|
|
2479
|
+
w(async (req, res) => {
|
|
2480
|
+
let validated;
|
|
2481
|
+
try {
|
|
2482
|
+
validated = await validateAuthorizationRequest(ctx, req.query);
|
|
2483
|
+
} catch (err) {
|
|
2484
|
+
if (err instanceof RedirectableAuthError) {
|
|
2485
|
+
return res.redirect(302, err.toRedirect(ctx.issuer));
|
|
2486
|
+
}
|
|
2487
|
+
throw err;
|
|
2488
|
+
}
|
|
2489
|
+
const user = await ctx.resolveUser(req);
|
|
2490
|
+
if (!user) {
|
|
2491
|
+
const to = loginRedirectUrl(ctx, `${ctx.issuer}${req.originalUrl}`);
|
|
2492
|
+
if (!to) {
|
|
2493
|
+
throw new OAuthError(
|
|
2494
|
+
401,
|
|
2495
|
+
"login_required",
|
|
2496
|
+
"No user is signed in and no `loginUrl` is configured to send them to"
|
|
2497
|
+
);
|
|
2498
|
+
}
|
|
2499
|
+
return res.redirect(302, to);
|
|
2500
|
+
}
|
|
2501
|
+
const request = await createAuthorizationRequest(ctx, validated, user);
|
|
2502
|
+
return res.redirect(302, consentRedirectUrl(ctx, request.requestId));
|
|
2503
|
+
})
|
|
2504
|
+
);
|
|
2505
|
+
router.get(
|
|
2506
|
+
"/me/grants",
|
|
2507
|
+
w(async (req, res) => {
|
|
2508
|
+
const user = await requireUser(ctx, req);
|
|
2509
|
+
const limit = Math.min(Math.max(Number(req.query.limit) || 20, 1), 100);
|
|
2510
|
+
const skip = Math.max(Number(req.query.skip) || 0, 0);
|
|
2511
|
+
const grants = await ctx.models.Grant.find({ userId: user.id, revokedAt: null }).sort({ createdAt: -1 }).skip(skip).limit(limit).lean();
|
|
2512
|
+
const clients = await ctx.models.Client.find({ clientId: { $in: grants.map((g) => g.clientId) } }).lean();
|
|
2513
|
+
const byId = new Map(clients.map((c) => [c.clientId, c]));
|
|
2514
|
+
res.json({
|
|
2515
|
+
limit,
|
|
2516
|
+
items: grants.map((g) => ({
|
|
2517
|
+
// The grant id is the ONLY identifier that leaves: the DELETE below
|
|
2518
|
+
// needs it. No user id, no client _id, no token ids.
|
|
2519
|
+
id: String(g._id),
|
|
2520
|
+
client: {
|
|
2521
|
+
clientId: g.clientId,
|
|
2522
|
+
name: byId.get(g.clientId)?.name ?? g.clientId,
|
|
2523
|
+
branding: byId.get(g.clientId)?.branding ?? {}
|
|
2524
|
+
},
|
|
2525
|
+
scopes: g.scopes.map((id) => ctx.scopeIndex.get(id) ?? { id, label: id }),
|
|
2526
|
+
...g.contextId ? { contextId: g.contextId } : {},
|
|
2527
|
+
createdAt: g.createdAt,
|
|
2528
|
+
lastUsedAt: g.lastUsedAt
|
|
2529
|
+
}))
|
|
2530
|
+
});
|
|
2531
|
+
})
|
|
2532
|
+
);
|
|
2533
|
+
router.post(
|
|
2534
|
+
"/token",
|
|
2535
|
+
cors,
|
|
2536
|
+
form,
|
|
2537
|
+
rateLimit(ctx, "token", clientKey),
|
|
2538
|
+
w(async (req, res) => {
|
|
2539
|
+
res.set("Cache-Control", "no-store");
|
|
2540
|
+
res.set("Pragma", "no-cache");
|
|
2541
|
+
const client = await authenticateClient(ctx, req);
|
|
2542
|
+
const body = req.body ?? {};
|
|
2543
|
+
const grantType = str(body.grant_type);
|
|
2544
|
+
if (grantType === "authorization_code") {
|
|
2545
|
+
const code = await consumeCode(ctx, str(body.code), {
|
|
2546
|
+
clientId: client.clientId,
|
|
2547
|
+
redirectUri: str(body.redirect_uri),
|
|
2548
|
+
codeVerifier: str(body.code_verifier)
|
|
2549
|
+
});
|
|
2550
|
+
const issued = await issueForCode(ctx, code);
|
|
2551
|
+
const idToken = issued.scopes.includes("openid") ? await buildIdToken(ctx, keys, {
|
|
2552
|
+
client,
|
|
2553
|
+
user: await ctx.loadUser(issued.userId),
|
|
2554
|
+
scopes: issued.scopes,
|
|
2555
|
+
contextId: issued.contextId,
|
|
2556
|
+
nonce: code.nonce,
|
|
2557
|
+
authTime: code.authTime,
|
|
2558
|
+
accessToken: issued.accessToken
|
|
2559
|
+
}) : void 0;
|
|
2560
|
+
return res.json(tokenResponse(issued, idToken));
|
|
2561
|
+
}
|
|
2562
|
+
if (grantType === "refresh_token") {
|
|
2563
|
+
const issued = await rotateRefresh(ctx, str(body.refresh_token), {
|
|
2564
|
+
clientId: client.clientId,
|
|
2565
|
+
scope: body.scope === void 0 ? void 0 : str(body.scope),
|
|
2566
|
+
resources: list(body.resource)
|
|
2567
|
+
});
|
|
2568
|
+
const idToken = issued.scopes.includes("openid") ? await buildIdToken(ctx, keys, {
|
|
2569
|
+
client,
|
|
2570
|
+
user: await ctx.loadUser(issued.userId),
|
|
2571
|
+
scopes: issued.scopes,
|
|
2572
|
+
contextId: issued.contextId,
|
|
2573
|
+
accessToken: issued.accessToken
|
|
2574
|
+
}) : void 0;
|
|
2575
|
+
return res.json(tokenResponse(issued, idToken));
|
|
2576
|
+
}
|
|
2577
|
+
throw unsupportedGrantType(
|
|
2578
|
+
`'${grantType}' is not supported. This server issues authorization_code and refresh_token only.`
|
|
2579
|
+
);
|
|
2580
|
+
})
|
|
2581
|
+
);
|
|
2582
|
+
router.post(
|
|
2583
|
+
"/revoke",
|
|
2584
|
+
cors,
|
|
2585
|
+
form,
|
|
2586
|
+
rateLimit(ctx, "token", clientKey),
|
|
2587
|
+
w(async (req, res) => {
|
|
2588
|
+
res.set("Cache-Control", "no-store");
|
|
2589
|
+
const client = await authenticateClient(ctx, req);
|
|
2590
|
+
const body = req.body ?? {};
|
|
2591
|
+
await revokeToken(ctx, str(body.token), client.clientId);
|
|
2592
|
+
res.status(200).json({});
|
|
2593
|
+
})
|
|
2594
|
+
);
|
|
2595
|
+
router.get(
|
|
2596
|
+
"/userinfo",
|
|
2597
|
+
w(async (req, res) => {
|
|
2598
|
+
const raw = /^Bearer +([^\s]+)$/i.exec(req.headers.authorization ?? "")?.[1];
|
|
2599
|
+
if (!raw) throw bearerChallenge("invalid_request", "A bearer access token is required");
|
|
2600
|
+
const token = await introspectAccessToken(ctx, raw);
|
|
2601
|
+
if (!token) throw bearerChallenge("invalid_token", "The access token is expired, revoked or unknown");
|
|
2602
|
+
if (!token.scopes.includes("openid")) {
|
|
2603
|
+
throw new OAuthError(403, "insufficient_scope", "The `openid` scope is required", {
|
|
2604
|
+
headers: { "WWW-Authenticate": 'Bearer error="insufficient_scope", scope="openid"' }
|
|
2605
|
+
});
|
|
2606
|
+
}
|
|
2607
|
+
const client = await ctx.models.Client.findOne({ clientId: token.clientId });
|
|
2608
|
+
if (!client) throw bearerChallenge("invalid_token", "The client this token was issued to no longer exists");
|
|
2609
|
+
res.set("Cache-Control", "no-store");
|
|
2610
|
+
res.json(await buildUserInfo(ctx, {
|
|
2611
|
+
client,
|
|
2612
|
+
user: await ctx.loadUser(token.userId),
|
|
2613
|
+
scopes: token.scopes,
|
|
2614
|
+
contextId: token.contextId
|
|
2615
|
+
}));
|
|
2616
|
+
})
|
|
2617
|
+
);
|
|
2618
|
+
router.get("/jwks", w(async (_req, res) => {
|
|
2619
|
+
res.set("Cache-Control", "public, max-age=300");
|
|
2620
|
+
res.json(await keys.jwks());
|
|
2621
|
+
}));
|
|
2622
|
+
if (ctx.cors.tokenEndpoint) {
|
|
2623
|
+
router.options("/token", cors, (_req, res) => {
|
|
2624
|
+
res.sendStatus(204);
|
|
2625
|
+
});
|
|
2626
|
+
router.options("/revoke", cors, (_req, res) => {
|
|
2627
|
+
res.sendStatus(204);
|
|
2628
|
+
});
|
|
2629
|
+
}
|
|
2630
|
+
router.get(
|
|
2631
|
+
"/consent/:requestId",
|
|
2632
|
+
rateLimit(ctx, "consent", ipKey),
|
|
2633
|
+
w(async (req, res) => {
|
|
2634
|
+
const user = await requireUser(ctx, req);
|
|
2635
|
+
res.json(await getConsentPayload(ctx, pathParam(req, "requestId"), user));
|
|
2636
|
+
})
|
|
2637
|
+
);
|
|
2638
|
+
router.post(
|
|
2639
|
+
"/consent/:requestId",
|
|
2640
|
+
rateLimit(ctx, "consent", ipKey),
|
|
2641
|
+
w(async (req, res) => {
|
|
2642
|
+
const user = await requireUser(ctx, req);
|
|
2643
|
+
const body = req.body ?? {};
|
|
2644
|
+
if (typeof body.approve !== "boolean") {
|
|
2645
|
+
throw invalidRequest("`approve` must be a boolean");
|
|
2646
|
+
}
|
|
2647
|
+
res.json(await decideConsent(ctx, pathParam(req, "requestId"), user, {
|
|
2648
|
+
approve: body.approve,
|
|
2649
|
+
scopes: Array.isArray(body.scopes) ? body.scopes.map(String) : void 0,
|
|
2650
|
+
contextId: body.contextId === void 0 ? void 0 : String(body.contextId)
|
|
2651
|
+
}));
|
|
2652
|
+
})
|
|
2653
|
+
);
|
|
2654
|
+
router.delete(
|
|
2655
|
+
"/me/grants/:id",
|
|
2656
|
+
w(async (req, res) => {
|
|
2657
|
+
const user = await requireUser(ctx, req);
|
|
2658
|
+
const id = pathParam(req, "id");
|
|
2659
|
+
if (!mongoose.Types.ObjectId.isValid(id)) {
|
|
2660
|
+
throw new OAuthError(404, "not_found", "No such grant");
|
|
2661
|
+
}
|
|
2662
|
+
const grant = await ctx.models.Grant.findOneAndUpdate(
|
|
2663
|
+
{ _id: id, userId: user.id, revokedAt: null },
|
|
2664
|
+
{ $set: { revokedAt: /* @__PURE__ */ new Date(), revokedBy: "user" } }
|
|
2665
|
+
);
|
|
2666
|
+
if (!grant) throw new OAuthError(404, "not_found", "No such grant");
|
|
2667
|
+
const { modifiedCount } = await ctx.models.Token.updateMany(
|
|
2668
|
+
{ grantId: grant._id, revokedAt: null },
|
|
2669
|
+
{ $set: { revokedAt: /* @__PURE__ */ new Date() } }
|
|
2670
|
+
);
|
|
2671
|
+
ctx.track({ type: "oauth.grant_revoked", userId: user.id, clientId: grant.clientId, grantId: String(grant._id) });
|
|
2672
|
+
void ctx.audit({ type: "oauth.grant_revoked", actor: "user", userId: user.id, clientId: grant.clientId, grantId: grant._id });
|
|
2673
|
+
res.json({ revoked: true, tokensRevoked: modifiedCount ?? 0 });
|
|
2674
|
+
})
|
|
2675
|
+
);
|
|
2676
|
+
return router;
|
|
2677
|
+
}
|
|
2678
|
+
async function requireUser(ctx, req) {
|
|
2679
|
+
const user = await ctx.resolveUser(req);
|
|
2680
|
+
if (!user) {
|
|
2681
|
+
throw new OAuthError(401, "login_required", "No user is signed in for this request");
|
|
2682
|
+
}
|
|
2683
|
+
return user;
|
|
2684
|
+
}
|
|
2685
|
+
function bearerChallenge(code, description) {
|
|
2686
|
+
return new OAuthError(401, code, description, {
|
|
2687
|
+
headers: { "WWW-Authenticate": `Bearer error="${code}", error_description="${description}"` }
|
|
2688
|
+
});
|
|
2689
|
+
}
|
|
2690
|
+
function tokenResponse(issued, idToken) {
|
|
2691
|
+
return {
|
|
2692
|
+
access_token: issued.accessToken,
|
|
2693
|
+
token_type: "Bearer",
|
|
2694
|
+
expires_in: issued.expiresIn,
|
|
2695
|
+
...issued.refreshToken ? { refresh_token: issued.refreshToken } : {},
|
|
2696
|
+
scope: issued.scopes.join(" "),
|
|
2697
|
+
// Only when `openid` was granted. Minting one unasked hands a client an
|
|
2698
|
+
// identity assertion it never requested consent for.
|
|
2699
|
+
...idToken ? { id_token: idToken } : {}
|
|
2700
|
+
};
|
|
2701
|
+
}
|
|
2702
|
+
function pathParam(req, name) {
|
|
2703
|
+
const value = req.params[name];
|
|
2704
|
+
return Array.isArray(value) ? value[0] ?? "" : value ?? "";
|
|
2705
|
+
}
|
|
2706
|
+
function str(value) {
|
|
2707
|
+
return typeof value === "string" ? value : "";
|
|
2708
|
+
}
|
|
2709
|
+
function list(value) {
|
|
2710
|
+
if (value === void 0) return void 0;
|
|
2711
|
+
return Array.isArray(value) ? value.map(String) : [String(value)];
|
|
2712
|
+
}
|
|
2713
|
+
function clientIdFromRequest(req) {
|
|
2714
|
+
const header = req.headers.authorization;
|
|
2715
|
+
if (header?.startsWith("Basic ")) {
|
|
2716
|
+
const decoded = Buffer.from(header.slice(6), "base64").toString("utf8");
|
|
2717
|
+
const id = decoded.split(":")[0];
|
|
2718
|
+
if (id) return decodeURIComponent(id);
|
|
2719
|
+
}
|
|
2720
|
+
const body = req.body ?? {};
|
|
2721
|
+
return typeof body.client_id === "string" ? body.client_id : null;
|
|
2722
|
+
}
|
|
2723
|
+
function corsMiddleware(ctx) {
|
|
2724
|
+
if (!ctx.cors.tokenEndpoint) return (_req, _res, next) => next();
|
|
2725
|
+
const allowed = new Set(ctx.cors.origins);
|
|
2726
|
+
return (req, res, next) => {
|
|
2727
|
+
const origin = req.headers.origin;
|
|
2728
|
+
if (allowed.size === 0) {
|
|
2729
|
+
res.set("Access-Control-Allow-Origin", "*");
|
|
2730
|
+
} else if (origin && allowed.has(origin)) {
|
|
2731
|
+
res.set("Access-Control-Allow-Origin", origin);
|
|
2732
|
+
res.set("Vary", "Origin");
|
|
2733
|
+
}
|
|
2734
|
+
res.set("Access-Control-Allow-Headers", "Authorization, Content-Type");
|
|
2735
|
+
res.set("Access-Control-Allow-Methods", "POST, OPTIONS");
|
|
2736
|
+
res.set("Access-Control-Max-Age", "600");
|
|
2737
|
+
next();
|
|
2738
|
+
};
|
|
2739
|
+
}
|
|
2740
|
+
|
|
2741
|
+
// src/server/index.ts
|
|
2742
|
+
function createOAuthHost(config) {
|
|
2743
|
+
const ctx = resolveConfig(config);
|
|
2744
|
+
return {
|
|
2745
|
+
routes: {
|
|
2746
|
+
// The discovery document publishes ABSOLUTE endpoint URLs, so it has to be
|
|
2747
|
+
// told where the oauth router lives. A router cannot learn its own mount
|
|
2748
|
+
// until a request arrives, by which time the metadata is already built.
|
|
2749
|
+
discovery: createDiscoveryRouter(ctx, ctx.mountPath),
|
|
2750
|
+
oauth: createOAuthRouter(ctx)
|
|
2751
|
+
},
|
|
2752
|
+
protect: createProtect(ctx),
|
|
2753
|
+
clients: createClientsApi(ctx),
|
|
2754
|
+
grants: createGrantsApi(ctx),
|
|
2755
|
+
users: createUsersApi(ctx),
|
|
2756
|
+
contexts: createContextsApi(ctx),
|
|
2757
|
+
/**
|
|
2758
|
+
* Build every index before the first write.
|
|
2759
|
+
*
|
|
2760
|
+
* Not optional and not lazy: the unique partial index on grants is what
|
|
2761
|
+
* stops one user holding two live grants for the same client, and mongoose
|
|
2762
|
+
* builds indexes in the background — a cold database will happily serve
|
|
2763
|
+
* the write that violates it first. See standards/traps.md #3.
|
|
2764
|
+
*/
|
|
2765
|
+
syncIndexes: () => syncModelIndexes(ctx.models),
|
|
2766
|
+
/** Escape hatch. Prefer the APIs above; these carry no invariants. */
|
|
2767
|
+
models: ctx.models
|
|
2768
|
+
};
|
|
2769
|
+
}
|
|
2770
|
+
|
|
2771
|
+
export { OAuthError, RedirectableAuthError, UnredirectableError, createModels, createOAuthHost, createUserAdapter, defaultResolveUser, syncModelIndexes };
|
|
2772
|
+
//# sourceMappingURL=index.js.map
|
|
2773
|
+
//# sourceMappingURL=index.js.map
|