@boopkit/cap-oauth-server 0.1.4 → 0.3.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/capability.json +14 -2
- package/dist/authorization-code-service.d.ts +40 -11
- package/dist/authorization-code-service.d.ts.map +1 -1
- package/dist/authorization-code-service.js +93 -41
- package/dist/authorization-code-service.js.map +1 -1
- package/dist/bearer.d.ts +27 -4
- package/dist/bearer.d.ts.map +1 -1
- package/dist/bearer.js +4 -4
- package/dist/bearer.js.map +1 -1
- package/dist/client-prune.d.ts +102 -0
- package/dist/client-prune.d.ts.map +1 -0
- package/dist/client-prune.js +51 -0
- package/dist/client-prune.js.map +1 -0
- package/dist/client-registry-service.d.ts +78 -7
- package/dist/client-registry-service.d.ts.map +1 -1
- package/dist/client-registry-service.js +200 -52
- package/dist/client-registry-service.js.map +1 -1
- package/dist/config.d.ts +6 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +1 -0
- package/dist/config.js.map +1 -1
- package/dist/connections-controller.js +1 -1
- package/dist/connections-controller.js.map +1 -1
- package/dist/consent-controller.d.ts +80 -6
- package/dist/consent-controller.d.ts.map +1 -1
- package/dist/consent-controller.js +134 -18
- package/dist/consent-controller.js.map +1 -1
- package/dist/discovery-controller.d.ts +2 -1
- package/dist/discovery-controller.d.ts.map +1 -1
- package/dist/discovery-controller.js +14 -4
- package/dist/discovery-controller.js.map +1 -1
- package/dist/discovery.d.ts +26 -0
- package/dist/discovery.d.ts.map +1 -1
- package/dist/discovery.js +55 -1
- package/dist/discovery.js.map +1 -1
- package/dist/errors.d.ts +38 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +59 -0
- package/dist/errors.js.map +1 -1
- package/dist/grant-service.d.ts +11 -4
- package/dist/grant-service.d.ts.map +1 -1
- package/dist/grant-service.js +6 -3
- package/dist/grant-service.js.map +1 -1
- package/dist/index.d.ts +10 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +18 -3
- package/dist/index.js.map +1 -1
- package/dist/metadata-document.d.ts +22 -0
- package/dist/metadata-document.d.ts.map +1 -1
- package/dist/metadata-document.js +44 -2
- package/dist/metadata-document.js.map +1 -1
- package/dist/outbound-target.d.ts +48 -0
- package/dist/outbound-target.d.ts.map +1 -0
- package/dist/outbound-target.js +211 -0
- package/dist/outbound-target.js.map +1 -0
- package/dist/rate-limit.d.ts +53 -11
- package/dist/rate-limit.d.ts.map +1 -1
- package/dist/rate-limit.js +27 -4
- package/dist/rate-limit.js.map +1 -1
- package/dist/redirect-uri.d.ts +17 -9
- package/dist/redirect-uri.d.ts.map +1 -1
- package/dist/redirect-uri.js +26 -10
- package/dist/redirect-uri.js.map +1 -1
- package/dist/registration-controller.d.ts +10 -0
- package/dist/registration-controller.d.ts.map +1 -1
- package/dist/registration-controller.js +10 -0
- package/dist/registration-controller.js.map +1 -1
- package/dist/same-site.d.ts +54 -0
- package/dist/same-site.d.ts.map +1 -0
- package/dist/same-site.js +124 -0
- package/dist/same-site.js.map +1 -0
- package/dist/token-controller.d.ts +21 -0
- package/dist/token-controller.d.ts.map +1 -1
- package/dist/token-controller.js +42 -18
- package/dist/token-controller.js.map +1 -1
- package/package.json +2 -2
- package/scaffold/reference/oauth-server.ref.md +276 -10
- package/scaffold/src/middleware/oauth-server.ts +33 -17
- package/scaffold/src/scripts/prune-oauth-clients.ts +86 -0
- package/scaffold/src/services/oauth-server.service.ts +24 -0
- package/scaffold/views/oauth-consent-error.hbs +10 -3
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.createClientInUsePredicate = createClientInUsePredicate;
|
|
4
|
+
exports.createClientPruneJob = createClientPruneJob;
|
|
5
|
+
const client_registry_service_js_1 = require("./client-registry-service.js");
|
|
6
|
+
const grant_service_js_1 = require("./grant-service.js");
|
|
7
|
+
/**
|
|
8
|
+
* The real `isInUse` predicate: is anybody still connected to this client?
|
|
9
|
+
*
|
|
10
|
+
* Suitable for passing straight to `pruneAbandonedClients`. It is `false` for a
|
|
11
|
+
* client nobody ever connected and for one every user has disconnected, and
|
|
12
|
+
* `true` the moment one live grant names the client's `publicId`.
|
|
13
|
+
*/
|
|
14
|
+
function createClientInUsePredicate(options) {
|
|
15
|
+
const { grantService, tokenService } = options;
|
|
16
|
+
return async function isInUse(client) {
|
|
17
|
+
const userIds = await tokenService.findUserIdsForClient(client.publicId);
|
|
18
|
+
for (const userId of userIds) {
|
|
19
|
+
const grants = await grantService.list(userId);
|
|
20
|
+
// The full `client_public_id`, never a prefix of it: a `startsWith` here
|
|
21
|
+
// would let a neighbouring client's live grant keep this row alive, which
|
|
22
|
+
// is the same too-broad match `revokeClient` refuses for the same reason.
|
|
23
|
+
const connected = grants.some((grant) => grant.client_public_id === client.publicId && (0, grant_service_js_1.isGrantActive)(grant));
|
|
24
|
+
if (connected)
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
return false;
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* The scheduled sweep, as one callable object.
|
|
32
|
+
*
|
|
33
|
+
* A job rather than a loose function because the thing that was missing was a
|
|
34
|
+
* *call site*: a scaffolded application schedules `run()` — from its own cron,
|
|
35
|
+
* from Heroku Scheduler, or from a cap-background-jobs consumer — and the
|
|
36
|
+
* capability owns which rows go and which stay.
|
|
37
|
+
*/
|
|
38
|
+
function createClientPruneJob(options) {
|
|
39
|
+
const { clientRegistry } = options;
|
|
40
|
+
const ttlMs = options.ttlMs ?? client_registry_service_js_1.DEFAULT_ABANDONED_CLIENT_TTL_MS;
|
|
41
|
+
const now = options.now ?? (() => new Date());
|
|
42
|
+
const isInUse = createClientInUsePredicate(options);
|
|
43
|
+
return {
|
|
44
|
+
async run() {
|
|
45
|
+
const before = new Date(now().getTime() - ttlMs);
|
|
46
|
+
const removed = await clientRegistry.pruneAbandonedClients({ before, isInUse });
|
|
47
|
+
return { before, removed };
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
//# sourceMappingURL=client-prune.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"client-prune.js","sourceRoot":"","sources":["../src/client-prune.ts"],"names":[],"mappings":";;AA0EA,gEAqBC;AAkCD,oDAaC;AA7ID,6EAA+E;AAC/E,yDAAsE;AAiEtE;;;;;;GAMG;AACH,SAAgB,0BAA0B,CACxC,OAA2B;IAE3B,MAAM,EAAE,YAAY,EAAE,YAAY,EAAE,GAAG,OAAO,CAAC;IAE/C,OAAO,KAAK,UAAU,OAAO,CAAC,MAAmB;QAC/C,MAAM,OAAO,GAAG,MAAM,YAAY,CAAC,oBAAoB,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC;QAEzE,KAAK,MAAM,MAAM,IAAI,OAAO,EAAE,CAAC;YAC7B,MAAM,MAAM,GAAG,MAAM,YAAY,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;YAC/C,yEAAyE;YACzE,0EAA0E;YAC1E,0EAA0E;YAC1E,MAAM,SAAS,GAAG,MAAM,CAAC,IAAI,CAC3B,CAAC,KAAK,EAAE,EAAE,CAAC,KAAK,CAAC,gBAAgB,KAAK,MAAM,CAAC,QAAQ,IAAI,IAAA,gCAAa,EAAC,KAAK,CAAC,CAC9E,CAAC;YACF,IAAI,SAAS;gBAAE,OAAO,IAAI,CAAC;QAC7B,CAAC;QAED,OAAO,KAAK,CAAC;IACf,CAAC,CAAC;AACJ,CAAC;AA0BD;;;;;;;GAOG;AACH,SAAgB,oBAAoB,CAAC,OAA2B;IAC9D,MAAM,EAAE,cAAc,EAAE,GAAG,OAAO,CAAC;IACnC,MAAM,KAAK,GAAG,OAAO,CAAC,KAAK,IAAI,4DAA+B,CAAC;IAC/D,MAAM,GAAG,GAAG,OAAO,CAAC,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC,IAAI,IAAI,EAAE,CAAC,CAAC;IAC9C,MAAM,OAAO,GAAG,0BAA0B,CAAC,OAAO,CAAC,CAAC;IAEpD,OAAO;QACL,KAAK,CAAC,GAAG;YACP,MAAM,MAAM,GAAG,IAAI,IAAI,CAAC,GAAG,EAAE,CAAC,OAAO,EAAE,GAAG,KAAK,CAAC,CAAC;YACjD,MAAM,OAAO,GAAG,MAAM,cAAc,CAAC,qBAAqB,CAAC,EAAE,MAAM,EAAE,OAAO,EAAE,CAAC,CAAC;YAChF,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,CAAC;QAC7B,CAAC;KACF,CAAC;AACJ,CAAC"}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type FetchLike } from './metadata-document.js';
|
|
2
|
-
import type { ClientMetadataInput, OauthClient, OauthClientDelegate, StaticClientInput } from './types.js';
|
|
2
|
+
import type { ClientMetadataInput, ClientMode, OauthClient, OauthClientDelegate, StaticClientInput } from './types.js';
|
|
3
3
|
/**
|
|
4
4
|
* The client registry — who is asking, and what they are allowed to ask for.
|
|
5
5
|
*
|
|
@@ -26,6 +26,11 @@ import type { ClientMetadataInput, OauthClient, OauthClientDelegate, StaticClien
|
|
|
26
26
|
* `redirect_uris` required and validated at registration, a ceiling on total
|
|
27
27
|
* rows, per-IP rate limiting at the transport, and a prune for rows that never
|
|
28
28
|
* went anywhere.
|
|
29
|
+
*
|
|
30
|
+
* All four cover `metadata_document` as well as `dynamic`, because both modes
|
|
31
|
+
* are written on behalf of somebody nobody authenticated. Scoping them to
|
|
32
|
+
* `dynamic` is the shape of boop-jd4x: the CIMD path wrote an uncounted,
|
|
33
|
+
* unlimited, uncollected row per distinct URL a caller named.
|
|
29
34
|
*/
|
|
30
35
|
/** Sensible RFC 7591 defaults for a client that asked for nothing specific. */
|
|
31
36
|
export declare const DEFAULT_GRANT_TYPES: string[];
|
|
@@ -36,6 +41,22 @@ export declare const DEFAULT_METADATA_MAX_AGE_MS: number;
|
|
|
36
41
|
export declare const DEFAULT_ABANDONED_CLIENT_TTL_MS: number;
|
|
37
42
|
/** How many rows open registration may create before it starts refusing. */
|
|
38
43
|
export declare const DEFAULT_MAX_REGISTERED_CLIENTS = 1000;
|
|
44
|
+
/**
|
|
45
|
+
* The modes the ceiling and the prune are about: every row a stranger caused.
|
|
46
|
+
*
|
|
47
|
+
* `dynamic` is the obvious one — `POST /oauth/register` is an unauthenticated
|
|
48
|
+
* write. `metadata_document` is the one that was missed, and it is the same
|
|
49
|
+
* thing wearing a different hat: an unauthenticated `GET /oauth/authorize`
|
|
50
|
+
* naming an https `client_id` nobody vetted writes a permanent row too. Scoping
|
|
51
|
+
* the abuse controls to `dynamic` left that path with no ceiling, no limiter and
|
|
52
|
+
* no collector, so a loop over `?client_id=https://attacker.example/c?n=<N>`
|
|
53
|
+
* filled the table one row at a time (boop-jd4x).
|
|
54
|
+
*
|
|
55
|
+
* `static` is deliberately absent. An operator wrote those rows, and letting
|
|
56
|
+
* them consume the ceiling would let an operator's own seed script lock open
|
|
57
|
+
* registration shut.
|
|
58
|
+
*/
|
|
59
|
+
export declare const STRANGER_CREATED_MODES: readonly ClientMode[];
|
|
39
60
|
export interface ClientRegistryServiceOptions {
|
|
40
61
|
/**
|
|
41
62
|
* The HTTP boundary for metadata documents. Required only if
|
|
@@ -43,22 +64,47 @@ export interface ClientRegistryServiceOptions {
|
|
|
43
64
|
* never calls it.
|
|
44
65
|
*/
|
|
45
66
|
fetch?: FetchLike;
|
|
46
|
-
/**
|
|
67
|
+
/**
|
|
68
|
+
* Ceiling on rows a stranger caused — `dynamic` *and* `metadata_document`.
|
|
69
|
+
* `Infinity` disables the check. See `STRANGER_CREATED_MODES`.
|
|
70
|
+
*/
|
|
47
71
|
maxRegisteredClients?: number;
|
|
48
72
|
/** Cache age past which a metadata document is re-fetched. */
|
|
49
73
|
metadataMaxAgeMs?: number;
|
|
50
74
|
metadataTimeoutMs?: number;
|
|
51
75
|
metadataMaxBytes?: number;
|
|
52
76
|
metadataMaxRedirects?: number;
|
|
77
|
+
/**
|
|
78
|
+
* Resolve a hostname to the addresses it points at, so that a *name* aimed at
|
|
79
|
+
* the internal network is refused like a literal address is.
|
|
80
|
+
*
|
|
81
|
+
* Optional and injected because this package has no business importing
|
|
82
|
+
* `node:dns` — the scaffold wires `dns.promises.lookup`. Without it the guard
|
|
83
|
+
* still refuses every literal private, loopback and link-local target; with
|
|
84
|
+
* it, `internal.example` resolving to `10.0.0.5` is refused too.
|
|
85
|
+
*/
|
|
86
|
+
metadataResolveHost?: (hostname: string) => Promise<string[]>;
|
|
53
87
|
/** Scopes a client may be granted, if it named none. */
|
|
54
88
|
defaultScopes?: string[];
|
|
89
|
+
/**
|
|
90
|
+
* Every scope this server advertises — the ceiling on what any registration
|
|
91
|
+
* may claim, whoever wrote it.
|
|
92
|
+
*
|
|
93
|
+
* Registration is unauthenticated, so `scope` on the way in is a string a
|
|
94
|
+
* stranger chose. Storing it unfiltered makes the discovery document's
|
|
95
|
+
* `scopes_supported` untrue and puts an undescribed word in front of a user
|
|
96
|
+
* on the consent screen. Defaults to `defaultScopes`, which for a correctly
|
|
97
|
+
* wired server is the same list; an empty list bounds nothing, which is what
|
|
98
|
+
* a registry built with no scope configuration at all has always done.
|
|
99
|
+
*/
|
|
100
|
+
scopesSupported?: string[];
|
|
55
101
|
/** Injectable clock, so expiry and cache-age tests do not sleep. */
|
|
56
102
|
now?: () => Date;
|
|
57
103
|
/** Called when a re-fetch fails and the cached copy is used instead. */
|
|
58
104
|
onMetadataRefreshFailed?(clientId: string, err: unknown): void;
|
|
59
105
|
}
|
|
60
106
|
export interface PruneOptions {
|
|
61
|
-
/** Delete abandoned
|
|
107
|
+
/** Delete abandoned stranger-created rows created before this instant. */
|
|
62
108
|
before?: Date;
|
|
63
109
|
/**
|
|
64
110
|
* Is this row still in use?
|
|
@@ -91,13 +137,38 @@ export interface ClientRegistryService {
|
|
|
91
137
|
suspend(clientId: string): Promise<OauthClient>;
|
|
92
138
|
/** Re-enable a suspended client. */
|
|
93
139
|
unsuspend(clientId: string): Promise<OauthClient>;
|
|
94
|
-
/**
|
|
140
|
+
/**
|
|
141
|
+
* Amend a registered client's metadata. Refuses a `static` row.
|
|
142
|
+
*
|
|
143
|
+
* There is no HTTP route behind this and there is not meant to be: RFC 7592
|
|
144
|
+
* client management is deliberately not implemented (see the note on
|
|
145
|
+
* `refuseStatic`). This is the host application's own management API — what
|
|
146
|
+
* an admin screen or a support script calls — and it validates exactly what
|
|
147
|
+
* `registerDynamic` validates, so a client cannot reach a value through an
|
|
148
|
+
* amendment that it could not have registered in the first place.
|
|
149
|
+
*/
|
|
95
150
|
updateRegistration(clientId: string, input: ClientMetadataInput): Promise<OauthClient>;
|
|
96
|
-
/**
|
|
151
|
+
/**
|
|
152
|
+
* Remove a registered client. Refuses a `static` row.
|
|
153
|
+
*
|
|
154
|
+
* The same host-application surface as `updateRegistration`, and the blunt
|
|
155
|
+
* instrument of the two: it drops the row, and with it the `publicId` that
|
|
156
|
+
* every live token for that client carries as its prefix. Prefer `suspend`
|
|
157
|
+
* for a client that may still have tokens outstanding — see
|
|
158
|
+
* `pruneAbandonedClients`, which asks `isInUse` before deleting anything.
|
|
159
|
+
*/
|
|
97
160
|
deleteRegistration(clientId: string): Promise<void>;
|
|
98
|
-
/** How many rows the ceiling counts
|
|
161
|
+
/** How many rows the ceiling counts — see `STRANGER_CREATED_MODES`. */
|
|
99
162
|
countRegistered(): Promise<number>;
|
|
100
|
-
/**
|
|
163
|
+
/**
|
|
164
|
+
* Garbage-collect abandoned stranger-created rows. Returns the client ids
|
|
165
|
+
* removed.
|
|
166
|
+
*
|
|
167
|
+
* A `metadata_document` row is swept on the same terms as a `dynamic` one.
|
|
168
|
+
* Its metadata is re-fetchable, so re-creating it costs one request; what is
|
|
169
|
+
* not re-creatable is its `publicId`, which is the prefix every live token for
|
|
170
|
+
* that client carries — and that is exactly what `isInUse` is for.
|
|
171
|
+
*/
|
|
101
172
|
pruneAbandonedClients(options?: PruneOptions): Promise<string[]>;
|
|
102
173
|
}
|
|
103
174
|
/**
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"client-registry-service.d.ts","sourceRoot":"","sources":["../src/client-registry-service.ts"],"names":[],"mappings":"AACA,OAAO,EAML,KAAK,SAAS,EACf,MAAM,wBAAwB,CAAC;AAGhC,OAAO,KAAK,EAEV,mBAAmB,
|
|
1
|
+
{"version":3,"file":"client-registry-service.d.ts","sourceRoot":"","sources":["../src/client-registry-service.ts"],"names":[],"mappings":"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"}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.DEFAULT_MAX_REGISTERED_CLIENTS = exports.DEFAULT_ABANDONED_CLIENT_TTL_MS = exports.DEFAULT_METADATA_MAX_AGE_MS = exports.DEFAULT_RESPONSE_TYPES = exports.DEFAULT_GRANT_TYPES = void 0;
|
|
3
|
+
exports.STRANGER_CREATED_MODES = exports.DEFAULT_MAX_REGISTERED_CLIENTS = exports.DEFAULT_ABANDONED_CLIENT_TTL_MS = exports.DEFAULT_METADATA_MAX_AGE_MS = exports.DEFAULT_RESPONSE_TYPES = exports.DEFAULT_GRANT_TYPES = void 0;
|
|
4
4
|
exports.validateRedirectUris = validateRedirectUris;
|
|
5
5
|
exports.createClientRegistryService = createClientRegistryService;
|
|
6
6
|
const errors_js_1 = require("./errors.js");
|
|
@@ -33,6 +33,11 @@ const redirect_uri_js_1 = require("./redirect-uri.js");
|
|
|
33
33
|
* `redirect_uris` required and validated at registration, a ceiling on total
|
|
34
34
|
* rows, per-IP rate limiting at the transport, and a prune for rows that never
|
|
35
35
|
* went anywhere.
|
|
36
|
+
*
|
|
37
|
+
* All four cover `metadata_document` as well as `dynamic`, because both modes
|
|
38
|
+
* are written on behalf of somebody nobody authenticated. Scoping them to
|
|
39
|
+
* `dynamic` is the shape of boop-jd4x: the CIMD path wrote an uncounted,
|
|
40
|
+
* unlimited, uncollected row per distinct URL a caller named.
|
|
36
41
|
*/
|
|
37
42
|
/** Sensible RFC 7591 defaults for a client that asked for nothing specific. */
|
|
38
43
|
exports.DEFAULT_GRANT_TYPES = ['authorization_code', 'refresh_token'];
|
|
@@ -43,6 +48,22 @@ exports.DEFAULT_METADATA_MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
|
|
43
48
|
exports.DEFAULT_ABANDONED_CLIENT_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
|
44
49
|
/** How many rows open registration may create before it starts refusing. */
|
|
45
50
|
exports.DEFAULT_MAX_REGISTERED_CLIENTS = 1000;
|
|
51
|
+
/**
|
|
52
|
+
* The modes the ceiling and the prune are about: every row a stranger caused.
|
|
53
|
+
*
|
|
54
|
+
* `dynamic` is the obvious one — `POST /oauth/register` is an unauthenticated
|
|
55
|
+
* write. `metadata_document` is the one that was missed, and it is the same
|
|
56
|
+
* thing wearing a different hat: an unauthenticated `GET /oauth/authorize`
|
|
57
|
+
* naming an https `client_id` nobody vetted writes a permanent row too. Scoping
|
|
58
|
+
* the abuse controls to `dynamic` left that path with no ceiling, no limiter and
|
|
59
|
+
* no collector, so a loop over `?client_id=https://attacker.example/c?n=<N>`
|
|
60
|
+
* filled the table one row at a time (boop-jd4x).
|
|
61
|
+
*
|
|
62
|
+
* `static` is deliberately absent. An operator wrote those rows, and letting
|
|
63
|
+
* them consume the ceiling would let an operator's own seed script lock open
|
|
64
|
+
* registration shut.
|
|
65
|
+
*/
|
|
66
|
+
exports.STRANGER_CREATED_MODES = ['dynamic', 'metadata_document'];
|
|
46
67
|
// ---------------------------------------------------------------------------
|
|
47
68
|
// Validation
|
|
48
69
|
// ---------------------------------------------------------------------------
|
|
@@ -95,6 +116,39 @@ function optionalString(value, field, maxLength = 2048) {
|
|
|
95
116
|
throw invalidMetadata(`${field} is longer than ${maxLength}`);
|
|
96
117
|
return value;
|
|
97
118
|
}
|
|
119
|
+
/**
|
|
120
|
+
* A display URI a stranger supplies, which this server will later put in a page.
|
|
121
|
+
*
|
|
122
|
+
* `https` or absent. The consent screen renders `logo_uri` in an `<img src>` and
|
|
123
|
+
* `client_uri`, `policy_uri` and `tos_uri` in an `<a href>`, and template
|
|
124
|
+
* escaping leaves a scheme alone — `javascript:` reaches the href intact. The
|
|
125
|
+
* page sanitises on the way out as well (`safeHttpsUri`), which is what protects
|
|
126
|
+
* rows written before this rule existed; refusing here is what tells the
|
|
127
|
+
* registrant, who would otherwise watch their privacy-policy link silently fail
|
|
128
|
+
* to appear.
|
|
129
|
+
*
|
|
130
|
+
* Not applied by `registerStaticClient`. That input comes from the people who
|
|
131
|
+
* run the site, through their own seed script rather than over the network, and
|
|
132
|
+
* it is the seam the consent screen's render-time defence is tested through.
|
|
133
|
+
*/
|
|
134
|
+
function optionalHttpsUri(value, field) {
|
|
135
|
+
const text = optionalString(value, field);
|
|
136
|
+
if (text === null)
|
|
137
|
+
return null;
|
|
138
|
+
let url;
|
|
139
|
+
try {
|
|
140
|
+
url = new URL(text);
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
throw invalidMetadata(`${field} must be an https URL`);
|
|
144
|
+
}
|
|
145
|
+
if (url.protocol !== 'https:') {
|
|
146
|
+
throw invalidMetadata(`${field} must be an https URL; got ${url.protocol}`);
|
|
147
|
+
}
|
|
148
|
+
// The raw spelling, not the parsed URL's serialization — this is a value we
|
|
149
|
+
// display back to the person who registered it, not one we match against.
|
|
150
|
+
return text;
|
|
151
|
+
}
|
|
98
152
|
function optionalStringArray(value, field) {
|
|
99
153
|
if (value === undefined || value === null)
|
|
100
154
|
return [];
|
|
@@ -106,14 +160,31 @@ function optionalStringArray(value, field) {
|
|
|
106
160
|
return entry;
|
|
107
161
|
});
|
|
108
162
|
}
|
|
109
|
-
/**
|
|
110
|
-
|
|
163
|
+
/**
|
|
164
|
+
* RFC 6749 §3.3: `scope` on the wire is one space-delimited string.
|
|
165
|
+
*
|
|
166
|
+
* Narrowed to what the server advertises, the way `grant_types` is: RFC 7591
|
|
167
|
+
* §3.2.1 lets us register a client with a scope list that differs from the one
|
|
168
|
+
* it asked for, and the registration response carries what was stored, so the
|
|
169
|
+
* client is told. A list that narrows to nothing is refused rather than allowed
|
|
170
|
+
* to fall back — falling back would hand a registrant that asked only for
|
|
171
|
+
* scopes we do not advertise the full default set, which is the opposite of
|
|
172
|
+
* narrowing.
|
|
173
|
+
*/
|
|
174
|
+
function parseScope(value, fallback, supported) {
|
|
175
|
+
const bound = (scopes) => supported.length > 0 ? scopes.filter((scope) => supported.includes(scope)) : scopes;
|
|
111
176
|
if (value === undefined || value === null || value === '')
|
|
112
|
-
return [...fallback];
|
|
177
|
+
return bound([...fallback]);
|
|
113
178
|
if (typeof value !== 'string')
|
|
114
179
|
throw invalidMetadata('scope must be a space-delimited string');
|
|
115
180
|
const scopes = value.split(/\s+/).filter(Boolean);
|
|
116
|
-
|
|
181
|
+
if (scopes.length === 0)
|
|
182
|
+
return bound([...fallback]);
|
|
183
|
+
const narrowed = bound(scopes);
|
|
184
|
+
if (narrowed.length === 0) {
|
|
185
|
+
throw invalidMetadata(`scope names nothing this server advertises: ${scopes.join(' ')}`);
|
|
186
|
+
}
|
|
187
|
+
return narrowed;
|
|
117
188
|
}
|
|
118
189
|
/**
|
|
119
190
|
* Only public clients, v1.
|
|
@@ -169,22 +240,33 @@ function toClient(row) {
|
|
|
169
240
|
function createClientRegistryService(model, options = {}) {
|
|
170
241
|
const now = options.now ?? (() => new Date());
|
|
171
242
|
const defaultScopes = options.defaultScopes ?? [];
|
|
243
|
+
const scopesSupported = options.scopesSupported ?? defaultScopes;
|
|
172
244
|
const maxRegisteredClients = options.maxRegisteredClients ?? exports.DEFAULT_MAX_REGISTERED_CLIENTS;
|
|
173
245
|
const metadataMaxAgeMs = options.metadataMaxAgeMs ?? exports.DEFAULT_METADATA_MAX_AGE_MS;
|
|
174
246
|
const fetchLimits = {
|
|
175
247
|
timeoutMs: options.metadataTimeoutMs ?? metadata_document_js_1.DEFAULT_METADATA_TIMEOUT_MS,
|
|
176
248
|
maxBytes: options.metadataMaxBytes ?? metadata_document_js_1.DEFAULT_METADATA_MAX_BYTES,
|
|
177
249
|
maxRedirects: options.metadataMaxRedirects ?? metadata_document_js_1.DEFAULT_METADATA_MAX_REDIRECTS,
|
|
250
|
+
resolveHost: options.metadataResolveHost,
|
|
178
251
|
};
|
|
252
|
+
/**
|
|
253
|
+
* The ceiling, asked by both paths that write a stranger's row.
|
|
254
|
+
*
|
|
255
|
+
* One function rather than a check at each call site, because the defect this
|
|
256
|
+
* closes was precisely two call sites where only one carried the check.
|
|
257
|
+
*/
|
|
258
|
+
async function assertCapacity() {
|
|
259
|
+
if (!Number.isFinite(maxRegisteredClients))
|
|
260
|
+
return;
|
|
261
|
+
const registered = await model.count({ where: { mode: { in: [...exports.STRANGER_CREATED_MODES] } } });
|
|
262
|
+
if (registered >= maxRegisteredClients) {
|
|
263
|
+
throw new errors_js_1.ClientRegistryError('boop_registration_capacity', 'the client registry has reached its configured ceiling');
|
|
264
|
+
}
|
|
265
|
+
}
|
|
179
266
|
const service = {
|
|
180
267
|
async registerDynamic(input) {
|
|
181
268
|
const redirectUris = validateRedirectUris(input.redirect_uris);
|
|
182
|
-
|
|
183
|
-
const registered = await model.count({ where: { mode: 'dynamic' } });
|
|
184
|
-
if (registered >= maxRegisteredClients) {
|
|
185
|
-
throw new errors_js_1.ClientRegistryError('boop_registration_capacity', 'the client registry has reached its configured ceiling');
|
|
186
|
-
}
|
|
187
|
-
}
|
|
269
|
+
await assertCapacity();
|
|
188
270
|
const publicId = (0, public_id_js_1.generateClientPublicId)();
|
|
189
271
|
const row = await model.create({
|
|
190
272
|
data: {
|
|
@@ -198,17 +280,17 @@ function createClientRegistryService(model, options = {}) {
|
|
|
198
280
|
status: 'enabled',
|
|
199
281
|
name: optionalString(input.client_name, 'client_name', 255),
|
|
200
282
|
description: null,
|
|
201
|
-
logoUri:
|
|
202
|
-
clientUri:
|
|
283
|
+
logoUri: optionalHttpsUri(input.logo_uri, 'logo_uri'),
|
|
284
|
+
clientUri: optionalHttpsUri(input.client_uri, 'client_uri'),
|
|
203
285
|
contacts: optionalStringArray(input.contacts, 'contacts'),
|
|
204
286
|
softwareId: optionalString(input.software_id, 'software_id', 255),
|
|
205
287
|
softwareVersion: optionalString(input.software_version, 'software_version', 255),
|
|
206
|
-
policyUri:
|
|
207
|
-
tosUri:
|
|
288
|
+
policyUri: optionalHttpsUri(input.policy_uri, 'policy_uri'),
|
|
289
|
+
tosUri: optionalHttpsUri(input.tos_uri, 'tos_uri'),
|
|
208
290
|
redirectUris,
|
|
209
291
|
grantTypes: normaliseGrantTypes(input.grant_types),
|
|
210
292
|
responseTypes: normaliseResponseTypes(input.response_types),
|
|
211
|
-
scopes: parseScope(input.scope, defaultScopes),
|
|
293
|
+
scopes: parseScope(input.scope, defaultScopes, scopesSupported),
|
|
212
294
|
tokenEndpointAuthMethod: parseAuthMethod(input.token_endpoint_auth_method),
|
|
213
295
|
},
|
|
214
296
|
});
|
|
@@ -227,21 +309,52 @@ function createClientRegistryService(model, options = {}) {
|
|
|
227
309
|
// because there is nothing to fall back to and consent would otherwise
|
|
228
310
|
// show a nameless client.
|
|
229
311
|
if (!existing) {
|
|
312
|
+
// The ceiling, **before** the fetch rather than before the write. A
|
|
313
|
+
// caller past it must not still be able to make this server issue one
|
|
314
|
+
// outbound request per attempt — that half of the abuse is a
|
|
315
|
+
// request-amplification probe whether or not a row comes of it.
|
|
316
|
+
//
|
|
317
|
+
// Only this branch is gated. A client that already has a row is served
|
|
318
|
+
// past the ceiling, including its lazy re-fetch: bounding new rows must
|
|
319
|
+
// not break consent for every connection at once the moment the table
|
|
320
|
+
// fills up.
|
|
321
|
+
await assertCapacity();
|
|
230
322
|
const doc = await (0, metadata_document_js_1.fetchMetadataDocument)(clientId, fetchImpl, fetchLimits);
|
|
231
|
-
const fields = readMetadataDocument(doc, defaultScopes);
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
323
|
+
const fields = readMetadataDocument(doc, defaultScopes, scopesSupported);
|
|
324
|
+
try {
|
|
325
|
+
const row = await model.create({
|
|
326
|
+
data: {
|
|
327
|
+
publicId: (0, public_id_js_1.generateClientPublicId)(),
|
|
328
|
+
clientId,
|
|
329
|
+
mode: 'metadata_document',
|
|
330
|
+
status: 'enabled',
|
|
331
|
+
metadataUri: clientId,
|
|
332
|
+
metadataFetchedAt: now(),
|
|
333
|
+
description: null,
|
|
334
|
+
...fields,
|
|
335
|
+
},
|
|
336
|
+
});
|
|
337
|
+
return toClient(row);
|
|
338
|
+
}
|
|
339
|
+
catch (err) {
|
|
340
|
+
// The widest race in the registry: the fetch above is a network
|
|
341
|
+
// round-trip sitting between the lookup and this write, so two
|
|
342
|
+
// authorization requests naming the same new `client_id` overlap for
|
|
343
|
+
// as long as the client's server takes to answer. The loser used to
|
|
344
|
+
// surface a `P2002` as a 500 on `/oauth/authorize`.
|
|
345
|
+
//
|
|
346
|
+
// Both callers fetched the same document, so the winner's row is the
|
|
347
|
+
// answer to this caller's question too. Re-read rather than write:
|
|
348
|
+
// the row that won might be `static` or `dynamic` — somebody racing
|
|
349
|
+
// an operator's seed of the same URL — and overwriting one of those
|
|
350
|
+
// is exactly what the mode check below this refuses to do.
|
|
351
|
+
if (!(0, errors_js_1.isUniqueConstraintViolation)(err, 'clientId'))
|
|
352
|
+
throw err;
|
|
353
|
+
const won = await model.findUnique({ where: { clientId } });
|
|
354
|
+
if (!won)
|
|
355
|
+
throw err;
|
|
356
|
+
return toClient(won);
|
|
357
|
+
}
|
|
245
358
|
}
|
|
246
359
|
const cached = toClient(existing);
|
|
247
360
|
if (cached.mode !== 'metadata_document') {
|
|
@@ -260,7 +373,7 @@ function createClientRegistryService(model, options = {}) {
|
|
|
260
373
|
// is metadata we validated ourselves the last time it was reachable.
|
|
261
374
|
try {
|
|
262
375
|
const doc = await (0, metadata_document_js_1.fetchMetadataDocument)(cached.metadataUri ?? clientId, fetchImpl, fetchLimits);
|
|
263
|
-
const fields = readMetadataDocument(doc, defaultScopes);
|
|
376
|
+
const fields = readMetadataDocument(doc, defaultScopes, scopesSupported);
|
|
264
377
|
const row = await model.update({
|
|
265
378
|
where: { clientId },
|
|
266
379
|
data: { ...fields, metadataFetchedAt: now() },
|
|
@@ -305,10 +418,23 @@ function createClientRegistryService(model, options = {}) {
|
|
|
305
418
|
const row = await model.update({ where: { clientId: input.clientId }, data });
|
|
306
419
|
return toClient(row);
|
|
307
420
|
}
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
421
|
+
try {
|
|
422
|
+
const row = await model.create({
|
|
423
|
+
data: { publicId: (0, public_id_js_1.generateClientPublicId)(), clientId: input.clientId, ...data },
|
|
424
|
+
});
|
|
425
|
+
return toClient(row);
|
|
426
|
+
}
|
|
427
|
+
catch (err) {
|
|
428
|
+
// "Safe to run twice" has to mean twice *at once*: two app instances
|
|
429
|
+
// booting together run the same seed, and the lookup above is a whole
|
|
430
|
+
// `await` away from this write. The loser converges on the winner's row
|
|
431
|
+
// rather than crashing a boot — and `data` carries no `publicId`, so
|
|
432
|
+
// the winner's prefix is what survives, which is the point.
|
|
433
|
+
if (!(0, errors_js_1.isUniqueConstraintViolation)(err, 'clientId'))
|
|
434
|
+
throw err;
|
|
435
|
+
const row = await model.update({ where: { clientId: input.clientId }, data });
|
|
436
|
+
return toClient(row);
|
|
437
|
+
}
|
|
312
438
|
},
|
|
313
439
|
async findByClientId(clientId) {
|
|
314
440
|
if (typeof clientId !== 'string' || clientId === '')
|
|
@@ -343,17 +469,17 @@ function createClientRegistryService(model, options = {}) {
|
|
|
343
469
|
data.name = optionalString(input.client_name, 'client_name', 255);
|
|
344
470
|
}
|
|
345
471
|
if (input.client_uri !== undefined)
|
|
346
|
-
data.clientUri =
|
|
472
|
+
data.clientUri = optionalHttpsUri(input.client_uri, 'client_uri');
|
|
347
473
|
if (input.logo_uri !== undefined)
|
|
348
|
-
data.logoUri =
|
|
474
|
+
data.logoUri = optionalHttpsUri(input.logo_uri, 'logo_uri');
|
|
349
475
|
if (input.contacts !== undefined)
|
|
350
476
|
data.contacts = optionalStringArray(input.contacts, 'contacts');
|
|
351
477
|
if (input.policy_uri !== undefined)
|
|
352
|
-
data.policyUri =
|
|
478
|
+
data.policyUri = optionalHttpsUri(input.policy_uri, 'policy_uri');
|
|
353
479
|
if (input.tos_uri !== undefined)
|
|
354
|
-
data.tosUri =
|
|
480
|
+
data.tosUri = optionalHttpsUri(input.tos_uri, 'tos_uri');
|
|
355
481
|
if (input.scope !== undefined)
|
|
356
|
-
data.scopes = parseScope(input.scope, defaultScopes);
|
|
482
|
+
data.scopes = parseScope(input.scope, defaultScopes, scopesSupported);
|
|
357
483
|
const row = await model.update({ where: { clientId }, data });
|
|
358
484
|
return toClient(row);
|
|
359
485
|
},
|
|
@@ -363,13 +489,13 @@ function createClientRegistryService(model, options = {}) {
|
|
|
363
489
|
await model.delete({ where: { clientId } });
|
|
364
490
|
},
|
|
365
491
|
async countRegistered() {
|
|
366
|
-
return model.count({ where: { mode:
|
|
492
|
+
return model.count({ where: { mode: { in: [...exports.STRANGER_CREATED_MODES] } } });
|
|
367
493
|
},
|
|
368
494
|
async pruneAbandonedClients(pruneOptions = {}) {
|
|
369
495
|
const before = pruneOptions.before ?? new Date(now().getTime() - exports.DEFAULT_ABANDONED_CLIENT_TTL_MS);
|
|
370
496
|
const isInUse = pruneOptions.isInUse ?? (async () => false);
|
|
371
497
|
const rows = await model.findMany({
|
|
372
|
-
where: { mode:
|
|
498
|
+
where: { mode: { in: [...exports.STRANGER_CREATED_MODES] }, createdAt: { lt: before } },
|
|
373
499
|
});
|
|
374
500
|
const removed = [];
|
|
375
501
|
for (const row of rows) {
|
|
@@ -394,10 +520,32 @@ async function requireClient(model, clientId) {
|
|
|
394
520
|
* A `static` row belongs to the operator who wrote it.
|
|
395
521
|
*
|
|
396
522
|
* This is a check in the service rather than a convention anyone remembers,
|
|
397
|
-
* because the thing it prevents —
|
|
398
|
-
*
|
|
399
|
-
*
|
|
400
|
-
*
|
|
523
|
+
* because the thing it prevents — something rewriting the redirect URIs of a
|
|
524
|
+
* client the operator vouched for — is exactly the kind of hole a convention
|
|
525
|
+
* leaves open the first time someone adds a route.
|
|
526
|
+
*
|
|
527
|
+
* ## The route it is waiting for does not exist, and the guard is still the
|
|
528
|
+
* right place for this
|
|
529
|
+
*
|
|
530
|
+
* RFC 7592 client management (`PUT`/`DELETE /oauth/register/:client_id`) is not
|
|
531
|
+
* implemented here, and that is a decision rather than a slice that has not
|
|
532
|
+
* landed yet. Authenticating those endpoints takes a
|
|
533
|
+
* `registration_access_token`: a third credential kind, with its own hashing,
|
|
534
|
+
* rotation and revocation story, bought so that a client can amend a
|
|
535
|
+
* registration it can re-create with one unauthenticated `POST`. Nothing in the
|
|
536
|
+
* Claude ecosystem checklist asks for it. So the registration response omits
|
|
537
|
+
* the token, the discovery document advertises no management surface, and the
|
|
538
|
+
* endpoints are absent rather than unauthenticated — which is what makes them
|
|
539
|
+
* safe, since an unauthenticated mount would let any caller rewrite any dynamic
|
|
540
|
+
* client's redirect URIs.
|
|
541
|
+
*
|
|
542
|
+
* What is left in front of this guard, then, is the host application's own
|
|
543
|
+
* management code — `updateRegistration` and `deleteRegistration` called from
|
|
544
|
+
* an admin screen or a support script. That code has a caller and can be
|
|
545
|
+
* trusted to be an operator; what it must still not be able to do is quietly
|
|
546
|
+
* unpick a seeded client by going through the same door a dynamic one uses.
|
|
547
|
+
* And if the decision above is ever reversed, this guard is already standing
|
|
548
|
+
* where the new route would land.
|
|
401
549
|
*/
|
|
402
550
|
function refuseStatic(client, verb) {
|
|
403
551
|
if (client.mode === 'static') {
|
|
@@ -479,18 +627,18 @@ function intersectSupported(requested, supported) {
|
|
|
479
627
|
* validation the registration endpoint applies runs here too — in particular
|
|
480
628
|
* `redirect_uris`, which is the field an attacker would want to control.
|
|
481
629
|
*/
|
|
482
|
-
function readMetadataDocument(doc, defaultScopes) {
|
|
630
|
+
function readMetadataDocument(doc, defaultScopes, scopesSupported) {
|
|
483
631
|
return {
|
|
484
632
|
name: optionalString(doc.client_name, 'client_name', 255),
|
|
485
|
-
logoUri:
|
|
486
|
-
clientUri:
|
|
633
|
+
logoUri: optionalHttpsUri(doc.logo_uri, 'logo_uri'),
|
|
634
|
+
clientUri: optionalHttpsUri(doc.client_uri, 'client_uri'),
|
|
487
635
|
contacts: optionalStringArray(doc.contacts, 'contacts'),
|
|
488
|
-
policyUri:
|
|
489
|
-
tosUri:
|
|
636
|
+
policyUri: optionalHttpsUri(doc.policy_uri, 'policy_uri'),
|
|
637
|
+
tosUri: optionalHttpsUri(doc.tos_uri, 'tos_uri'),
|
|
490
638
|
redirectUris: validateRedirectUris(doc.redirect_uris),
|
|
491
639
|
grantTypes: normaliseGrantTypes(doc.grant_types),
|
|
492
640
|
responseTypes: normaliseResponseTypes(doc.response_types),
|
|
493
|
-
scopes: parseScope(doc.scope, defaultScopes),
|
|
641
|
+
scopes: parseScope(doc.scope, defaultScopes, scopesSupported),
|
|
494
642
|
tokenEndpointAuthMethod: parseAuthMethod(doc.token_endpoint_auth_method),
|
|
495
643
|
};
|
|
496
644
|
}
|