@boopkit/cap-oauth-server 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/capability.json +120 -0
- package/dist/authorization-code-service.d.ts +105 -0
- package/dist/authorization-code-service.d.ts.map +1 -0
- package/dist/authorization-code-service.js +142 -0
- package/dist/authorization-code-service.js.map +1 -0
- package/dist/bearer.d.ts +146 -0
- package/dist/bearer.d.ts.map +1 -0
- package/dist/bearer.js +150 -0
- package/dist/bearer.js.map +1 -0
- package/dist/body-field.d.ts +24 -0
- package/dist/body-field.d.ts.map +1 -0
- package/dist/body-field.js +43 -0
- package/dist/body-field.js.map +1 -0
- package/dist/client-registry-service.d.ts +120 -0
- package/dist/client-registry-service.d.ts.map +1 -0
- package/dist/client-registry-service.js +450 -0
- package/dist/client-registry-service.js.map +1 -0
- package/dist/config.d.ts +82 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +157 -0
- package/dist/config.js.map +1 -0
- package/dist/connections-controller.d.ts +106 -0
- package/dist/connections-controller.d.ts.map +1 -0
- package/dist/connections-controller.js +161 -0
- package/dist/connections-controller.js.map +1 -0
- package/dist/consent-controller.d.ts +197 -0
- package/dist/consent-controller.d.ts.map +1 -0
- package/dist/consent-controller.js +532 -0
- package/dist/consent-controller.js.map +1 -0
- package/dist/discovery-controller.d.ts +16 -0
- package/dist/discovery-controller.d.ts.map +1 -0
- package/dist/discovery-controller.js +52 -0
- package/dist/discovery-controller.js.map +1 -0
- package/dist/discovery.d.ts +95 -0
- package/dist/discovery.d.ts.map +1 -0
- package/dist/discovery.js +101 -0
- package/dist/discovery.js.map +1 -0
- package/dist/errors.d.ts +26 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +41 -0
- package/dist/errors.js.map +1 -0
- package/dist/express.d.ts +66 -0
- package/dist/express.d.ts.map +1 -0
- package/dist/express.js +3 -0
- package/dist/express.js.map +1 -0
- package/dist/grant-service.d.ts +102 -0
- package/dist/grant-service.d.ts.map +1 -0
- package/dist/grant-service.js +137 -0
- package/dist/grant-service.js.map +1 -0
- package/dist/index.d.ts +67 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +148 -0
- package/dist/index.js.map +1 -0
- package/dist/metadata-document.d.ts +60 -0
- package/dist/metadata-document.d.ts.map +1 -0
- package/dist/metadata-document.js +148 -0
- package/dist/metadata-document.js.map +1 -0
- package/dist/oauth-error.d.ts +45 -0
- package/dist/oauth-error.d.ts.map +1 -0
- package/dist/oauth-error.js +51 -0
- package/dist/oauth-error.js.map +1 -0
- package/dist/pkce.d.ts +29 -0
- package/dist/pkce.d.ts.map +1 -0
- package/dist/pkce.js +65 -0
- package/dist/pkce.js.map +1 -0
- package/dist/property-array.d.ts +60 -0
- package/dist/property-array.d.ts.map +1 -0
- package/dist/property-array.js +31 -0
- package/dist/property-array.js.map +1 -0
- package/dist/public-id.d.ts +35 -0
- package/dist/public-id.d.ts.map +1 -0
- package/dist/public-id.js +76 -0
- package/dist/public-id.js.map +1 -0
- package/dist/rate-limit.d.ts +56 -0
- package/dist/rate-limit.d.ts.map +1 -0
- package/dist/rate-limit.js +55 -0
- package/dist/rate-limit.js.map +1 -0
- package/dist/redirect-uri.d.ts +89 -0
- package/dist/redirect-uri.d.ts.map +1 -0
- package/dist/redirect-uri.js +249 -0
- package/dist/redirect-uri.js.map +1 -0
- package/dist/registration-controller.d.ts +62 -0
- package/dist/registration-controller.d.ts.map +1 -0
- package/dist/registration-controller.js +79 -0
- package/dist/registration-controller.js.map +1 -0
- package/dist/request-client.d.ts +19 -0
- package/dist/request-client.d.ts.map +1 -0
- package/dist/request-client.js +32 -0
- package/dist/request-client.js.map +1 -0
- package/dist/revocation-controller.d.ts +28 -0
- package/dist/revocation-controller.d.ts.map +1 -0
- package/dist/revocation-controller.js +176 -0
- package/dist/revocation-controller.js.map +1 -0
- package/dist/revocation-service.d.ts +113 -0
- package/dist/revocation-service.d.ts.map +1 -0
- package/dist/revocation-service.js +121 -0
- package/dist/revocation-service.js.map +1 -0
- package/dist/token-controller.d.ts +79 -0
- package/dist/token-controller.d.ts.map +1 -0
- package/dist/token-controller.js +207 -0
- package/dist/token-controller.js.map +1 -0
- package/dist/token-service.d.ts +175 -0
- package/dist/token-service.d.ts.map +1 -0
- package/dist/token-service.js +251 -0
- package/dist/token-service.js.map +1 -0
- package/dist/types.d.ts +130 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +13 -0
- package/dist/types.js.map +1 -0
- package/dist/user-store.d.ts +110 -0
- package/dist/user-store.d.ts.map +1 -0
- package/dist/user-store.js +40 -0
- package/dist/user-store.js.map +1 -0
- package/package.json +37 -0
- package/scaffold/prisma/schema/oauth-clients.prisma +80 -0
- package/scaffold/reference/oauth-server.ref.md +478 -0
- package/scaffold/src/middleware/oauth-server.ts +227 -0
- package/scaffold/src/scripts/seed-oauth-clients.ts +72 -0
- package/scaffold/src/services/oauth-server.service.ts +101 -0
- package/scaffold/styles/oauth-server.css +345 -0
- package/scaffold/views/oauth-connected-clients.hbs +98 -0
- package/scaffold/views/oauth-consent-error.hbs +22 -0
- package/scaffold/views/oauth-consent.hbs +138 -0
- package/scaffold/views/oauth-disconnect-confirm.hbs +57 -0
package/dist/pkce.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.CODE_CHALLENGE_METHOD = void 0;
|
|
4
|
+
exports.isValidCodeVerifier = isValidCodeVerifier;
|
|
5
|
+
exports.isValidCodeChallenge = isValidCodeChallenge;
|
|
6
|
+
exports.deriveCodeChallenge = deriveCodeChallenge;
|
|
7
|
+
exports.verifyCodeChallenge = verifyCodeChallenge;
|
|
8
|
+
const node_crypto_1 = require("node:crypto");
|
|
9
|
+
/**
|
|
10
|
+
* PKCE, RFC 7636 — and only `S256`.
|
|
11
|
+
*
|
|
12
|
+
* `plain` is not a weaker method, it is the absence of one: the "challenge" is
|
|
13
|
+
* the verifier, so an attacker who intercepted the authorization response has
|
|
14
|
+
* everything needed to redeem the code. The discovery document advertises
|
|
15
|
+
* `["S256"]` and this module is the other half of that promise — a request
|
|
16
|
+
* carrying `code_challenge_method=plain` is refused rather than downgraded to.
|
|
17
|
+
*
|
|
18
|
+
* OAuth 2.1 requires PKCE for public clients, and every client this server will
|
|
19
|
+
* ever see is public, so the challenge is **required** rather than optional.
|
|
20
|
+
*/
|
|
21
|
+
/** The only method this server accepts. Matches the discovery document. */
|
|
22
|
+
exports.CODE_CHALLENGE_METHOD = 'S256';
|
|
23
|
+
/** RFC 7636 §4.1: 43–128 characters from the unreserved set. */
|
|
24
|
+
const VERIFIER_RE = /^[A-Za-z0-9\-._~]{43,128}$/;
|
|
25
|
+
/**
|
|
26
|
+
* RFC 7636 §4.2: base64url of a SHA-256 digest — 43 characters, no padding.
|
|
27
|
+
*
|
|
28
|
+
* Checked as a shape before it is compared, because a challenge that was stored
|
|
29
|
+
* from a malformed registration request should fail loudly rather than by never
|
|
30
|
+
* matching anything.
|
|
31
|
+
*/
|
|
32
|
+
const CHALLENGE_RE = /^[A-Za-z0-9\-_]{43}$/;
|
|
33
|
+
function isValidCodeVerifier(value) {
|
|
34
|
+
return typeof value === 'string' && VERIFIER_RE.test(value);
|
|
35
|
+
}
|
|
36
|
+
function isValidCodeChallenge(value) {
|
|
37
|
+
return typeof value === 'string' && CHALLENGE_RE.test(value);
|
|
38
|
+
}
|
|
39
|
+
/** BASE64URL-ENCODE(SHA256(ASCII(verifier))), per RFC 7636 §4.6. */
|
|
40
|
+
function deriveCodeChallenge(verifier) {
|
|
41
|
+
return (0, node_crypto_1.createHash)('sha256').update(verifier, 'ascii').digest('base64url');
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Does `verifier` satisfy the `challenge` the authorization request carried?
|
|
45
|
+
*
|
|
46
|
+
* Compared with `timingSafeEqual` rather than `===`. The challenge is not a
|
|
47
|
+
* secret — it travelled in a query string — but the comparison is on the
|
|
48
|
+
* redemption path next to comparisons that are, and a codebase where one string
|
|
49
|
+
* equality is constant-time and its neighbour is not teaches the wrong habit to
|
|
50
|
+
* whoever writes the third one.
|
|
51
|
+
*/
|
|
52
|
+
function verifyCodeChallenge(verifier, challenge, method = exports.CODE_CHALLENGE_METHOD) {
|
|
53
|
+
if (method !== exports.CODE_CHALLENGE_METHOD)
|
|
54
|
+
return false;
|
|
55
|
+
if (!isValidCodeVerifier(verifier))
|
|
56
|
+
return false;
|
|
57
|
+
if (!isValidCodeChallenge(challenge))
|
|
58
|
+
return false;
|
|
59
|
+
const derived = Buffer.from(deriveCodeChallenge(verifier), 'utf8');
|
|
60
|
+
const expected = Buffer.from(challenge, 'utf8');
|
|
61
|
+
if (derived.length !== expected.length)
|
|
62
|
+
return false;
|
|
63
|
+
return (0, node_crypto_1.timingSafeEqual)(derived, expected);
|
|
64
|
+
}
|
|
65
|
+
//# sourceMappingURL=pkce.js.map
|
package/dist/pkce.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"pkce.js","sourceRoot":"","sources":["../src/pkce.ts"],"names":[],"mappings":";;;AA8BA,kDAEC;AAED,oDAEC;AAGD,kDAEC;AAWD,kDAaC;AAjED,6CAA0D;AAE1D;;;;;;;;;;;GAWG;AAEH,2EAA2E;AAC9D,QAAA,qBAAqB,GAAG,MAAM,CAAC;AAE5C,gEAAgE;AAChE,MAAM,WAAW,GAAG,4BAA4B,CAAC;AAEjD;;;;;;GAMG;AACH,MAAM,YAAY,GAAG,sBAAsB,CAAC;AAE5C,SAAgB,mBAAmB,CAAC,KAAc;IAChD,OAAO,OAAO,KAAK,KAAK,QAAQ,IAAI,WAAW,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;AAC9D,CAAC;AAED,SAAgB,oBAAoB,CAAC,KAAc;IACjD,OAAO,OAAO,KAAK,KAAK,QAAQ,IAAI,YAAY,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;AAC/D,CAAC;AAED,oEAAoE;AACpE,SAAgB,mBAAmB,CAAC,QAAgB;IAClD,OAAO,IAAA,wBAAU,EAAC,QAAQ,CAAC,CAAC,MAAM,CAAC,QAAQ,EAAE,OAAO,CAAC,CAAC,MAAM,CAAC,WAAW,CAAC,CAAC;AAC5E,CAAC;AAED;;;;;;;;GAQG;AACH,SAAgB,mBAAmB,CACjC,QAAiB,EACjB,SAAkB,EAClB,SAAkB,6BAAqB;IAEvC,IAAI,MAAM,KAAK,6BAAqB;QAAE,OAAO,KAAK,CAAC;IACnD,IAAI,CAAC,mBAAmB,CAAC,QAAQ,CAAC;QAAE,OAAO,KAAK,CAAC;IACjD,IAAI,CAAC,oBAAoB,CAAC,SAAS,CAAC;QAAE,OAAO,KAAK,CAAC;IAEnD,MAAM,OAAO,GAAG,MAAM,CAAC,IAAI,CAAC,mBAAmB,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC,CAAC;IACnE,MAAM,QAAQ,GAAG,MAAM,CAAC,IAAI,CAAC,SAAS,EAAE,MAAM,CAAC,CAAC;IAChD,IAAI,OAAO,CAAC,MAAM,KAAK,QAAQ,CAAC,MAAM;QAAE,OAAO,KAAK,CAAC;IACrD,OAAO,IAAA,6BAAe,EAAC,OAAO,EAAE,QAAQ,CAAC,CAAC;AAC5C,CAAC"}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { PropertyStore, PropertyWriteOptions, StoredProperty } from './user-store.js';
|
|
2
|
+
/**
|
|
3
|
+
* The read-modify-write discipline both OAuth user properties are written
|
|
4
|
+
* under, in one place so the two cannot drift apart.
|
|
5
|
+
*
|
|
6
|
+
* ## Why the whole array, every time
|
|
7
|
+
*
|
|
8
|
+
* `oauth-grants` and `oauth-codes` are arrays inside a single `info` blob.
|
|
9
|
+
* Writing one means writing all of it, and that will feel wasteful next to
|
|
10
|
+
* cap-users' `addAuthenticationMethod`, which updates one entry. Reaching for
|
|
11
|
+
* that narrower shape is the bug in boop-zj8: it *replaces* an entry rather
|
|
12
|
+
* than merging it, so it silently destroys every field the write did not name.
|
|
13
|
+
* The pattern to follow is `setAuthorization`'s.
|
|
14
|
+
*
|
|
15
|
+
* The array rewrite is not merely the safe option, it is what makes the
|
|
16
|
+
* property worth being a property. A concurrent consent and revoke both read
|
|
17
|
+
* revision N and both write; the second write carries a stale
|
|
18
|
+
* `expectedRevision` and is **refused**. Without the revision check one of the
|
|
19
|
+
* two would simply vanish, and nothing anywhere would say so.
|
|
20
|
+
*
|
|
21
|
+
* ## No retry here, on purpose
|
|
22
|
+
*
|
|
23
|
+
* `write` surfaces the refusal rather than re-reading and trying again. A retry
|
|
24
|
+
* loop in this module would make the collision invisible to its caller, and the
|
|
25
|
+
* two callers want different things from it: the token endpoint should answer
|
|
26
|
+
* the client rather than race a human, and a revocation should be told that
|
|
27
|
+
* something else changed the grant underneath it.
|
|
28
|
+
*/
|
|
29
|
+
/** A property's current contents plus the revision a write must quote back. */
|
|
30
|
+
export interface ArraySnapshot<T> {
|
|
31
|
+
entries: T[];
|
|
32
|
+
/**
|
|
33
|
+
* `undefined` when the property does not exist yet — which is what a first
|
|
34
|
+
* write must pass, since quoting a revision for a row that is about to be
|
|
35
|
+
* created would refuse every first write.
|
|
36
|
+
*/
|
|
37
|
+
revision: number | undefined;
|
|
38
|
+
record: StoredProperty<{
|
|
39
|
+
entries?: unknown;
|
|
40
|
+
}> | null;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Read a property whose `info` is `{ entries: [...] }`.
|
|
44
|
+
*
|
|
45
|
+
* A missing property, a null `info`, or an `entries` that is not an array all
|
|
46
|
+
* read as empty rather than throwing. The blob accepts arbitrary JSON and the
|
|
47
|
+
* database cannot object, so the read path has to treat the shape as a claim.
|
|
48
|
+
*/
|
|
49
|
+
export declare function readArray<T>(store: PropertyStore, userId: number, property: string): Promise<ArraySnapshot<T>>;
|
|
50
|
+
/**
|
|
51
|
+
* Write the whole array back, quoting the revision the caller read.
|
|
52
|
+
*
|
|
53
|
+
* Throws cap-services' `StaleEntityError` when the stored revision has moved
|
|
54
|
+
* on. Use `isStaleWriteError` to recognise it; see this module's header for why
|
|
55
|
+
* that is not caught here.
|
|
56
|
+
*/
|
|
57
|
+
export declare function writeArray<T>(store: PropertyStore, userId: number, property: string, entries: T[], snapshot: Pick<ArraySnapshot<T>, 'revision'>, options?: Omit<PropertyWriteOptions, 'expectedRevision'>): Promise<StoredProperty<{
|
|
58
|
+
entries: T[];
|
|
59
|
+
}>>;
|
|
60
|
+
//# sourceMappingURL=property-array.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"property-array.d.ts","sourceRoot":"","sources":["../src/property-array.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,oBAAoB,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAE3F;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AAEH,+EAA+E;AAC/E,MAAM,WAAW,aAAa,CAAC,CAAC;IAC9B,OAAO,EAAE,CAAC,EAAE,CAAC;IACb;;;;OAIG;IACH,QAAQ,EAAE,MAAM,GAAG,SAAS,CAAC;IAC7B,MAAM,EAAE,cAAc,CAAC;QAAE,OAAO,CAAC,EAAE,OAAO,CAAA;KAAE,CAAC,GAAG,IAAI,CAAC;CACtD;AAED;;;;;;GAMG;AACH,wBAAsB,SAAS,CAAC,CAAC,EAC/B,KAAK,EAAE,aAAa,EACpB,MAAM,EAAE,MAAM,EACd,QAAQ,EAAE,MAAM,GACf,OAAO,CAAC,aAAa,CAAC,CAAC,CAAC,CAAC,CAQ3B;AAED;;;;;;GAMG;AACH,wBAAsB,UAAU,CAAC,CAAC,EAChC,KAAK,EAAE,aAAa,EACpB,MAAM,EAAE,MAAM,EACd,QAAQ,EAAE,MAAM,EAChB,OAAO,EAAE,CAAC,EAAE,EACZ,QAAQ,EAAE,IAAI,CAAC,aAAa,CAAC,CAAC,CAAC,EAAE,UAAU,CAAC,EAC5C,OAAO,GAAE,IAAI,CAAC,oBAAoB,EAAE,kBAAkB,CAAM,GAC3D,OAAO,CAAC,cAAc,CAAC;IAAE,OAAO,EAAE,CAAC,EAAE,CAAA;CAAE,CAAC,CAAC,CAO3C"}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.readArray = readArray;
|
|
4
|
+
exports.writeArray = writeArray;
|
|
5
|
+
/**
|
|
6
|
+
* Read a property whose `info` is `{ entries: [...] }`.
|
|
7
|
+
*
|
|
8
|
+
* A missing property, a null `info`, or an `entries` that is not an array all
|
|
9
|
+
* read as empty rather than throwing. The blob accepts arbitrary JSON and the
|
|
10
|
+
* database cannot object, so the read path has to treat the shape as a claim.
|
|
11
|
+
*/
|
|
12
|
+
async function readArray(store, userId, property) {
|
|
13
|
+
const record = await store.get(userId, property);
|
|
14
|
+
const raw = record?.info?.entries;
|
|
15
|
+
return {
|
|
16
|
+
entries: Array.isArray(raw) ? raw : [],
|
|
17
|
+
revision: record?.revision,
|
|
18
|
+
record: record ?? null,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Write the whole array back, quoting the revision the caller read.
|
|
23
|
+
*
|
|
24
|
+
* Throws cap-services' `StaleEntityError` when the stored revision has moved
|
|
25
|
+
* on. Use `isStaleWriteError` to recognise it; see this module's header for why
|
|
26
|
+
* that is not caught here.
|
|
27
|
+
*/
|
|
28
|
+
async function writeArray(store, userId, property, entries, snapshot, options = {}) {
|
|
29
|
+
return store.put(userId, property, { entries }, { ...options, expectedRevision: snapshot.revision });
|
|
30
|
+
}
|
|
31
|
+
//# sourceMappingURL=property-array.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"property-array.js","sourceRoot":"","sources":["../src/property-array.ts"],"names":[],"mappings":";;AAiDA,8BAYC;AASD,gCAcC;AA1CD;;;;;;GAMG;AACI,KAAK,UAAU,SAAS,CAC7B,KAAoB,EACpB,MAAc,EACd,QAAgB;IAEhB,MAAM,MAAM,GAAG,MAAM,KAAK,CAAC,GAAG,CAAwB,MAAM,EAAE,QAAQ,CAAC,CAAC;IACxE,MAAM,GAAG,GAAG,MAAM,EAAE,IAAI,EAAE,OAAO,CAAC;IAClC,OAAO;QACL,OAAO,EAAE,KAAK,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,CAAE,GAAW,CAAC,CAAC,CAAC,EAAE;QAC/C,QAAQ,EAAE,MAAM,EAAE,QAAQ;QAC1B,MAAM,EAAE,MAAM,IAAI,IAAI;KACvB,CAAC;AACJ,CAAC;AAED;;;;;;GAMG;AACI,KAAK,UAAU,UAAU,CAC9B,KAAoB,EACpB,MAAc,EACd,QAAgB,EAChB,OAAY,EACZ,QAA4C,EAC5C,UAA0D,EAAE;IAE5D,OAAO,KAAK,CAAC,GAAG,CACd,MAAM,EACN,QAAQ,EACR,EAAE,OAAO,EAAE,EACX,EAAE,GAAG,OAAO,EAAE,gBAAgB,EAAE,QAAQ,CAAC,QAAQ,EAAE,CACpD,CAAC;AACJ,CAAC"}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The client's `publicId` — the token prefix, in every registration mode.
|
|
3
|
+
*
|
|
4
|
+
* Plan, Decision 3: twelve lowercase base36 characters that *we* assign at
|
|
5
|
+
* registration. It is never the raw `client_id`, because a Client ID Metadata
|
|
6
|
+
* Document client's `client_id` is a URL containing `/`, `:` and `.`, and all
|
|
7
|
+
* three of those break something:
|
|
8
|
+
*
|
|
9
|
+
* - `<publicId>.<generateToken()>` is the token wire format, split on the
|
|
10
|
+
* first `.`;
|
|
11
|
+
* - `<publicId>|<sha256>` is the `user_identities.key`, split on the `|` and
|
|
12
|
+
* bounded by that column's `varchar(255)`;
|
|
13
|
+
* - `key LIKE '<publicId>|%'` is the prefix scan client-wide revocation
|
|
14
|
+
* depends on, which an unbounded URL would make meaningless.
|
|
15
|
+
*
|
|
16
|
+
* So the alphabet is the load-bearing part, not the length. Twelve base36
|
|
17
|
+
* characters is about 62 bits, which is ample for an identifier that is not
|
|
18
|
+
* itself a secret; the token half carries the entropy that matters.
|
|
19
|
+
*
|
|
20
|
+
* This is deliberately *not* `generatePublicId()` from `@boopkit/cap-database`.
|
|
21
|
+
* The house format (Crockford base32, `xxxxxx-xxxxxx`) would in fact survive
|
|
22
|
+
* both splits, but it is a 13-character value with a hyphen, and Decision 3
|
|
23
|
+
* fixed a 12-character `varchar(16)` column against the base36 alphabet. Taking
|
|
24
|
+
* the dependency to get a different-shaped value than the schema declares would
|
|
25
|
+
* trade one small module for a mismatch the schema cannot express.
|
|
26
|
+
*/
|
|
27
|
+
/** Lowercase base36. No `.`, no `|`, no `/`, no `:` — see the header. */
|
|
28
|
+
export declare const CLIENT_PUBLIC_ID_ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyz";
|
|
29
|
+
/** Twelve characters, per Decision 3. The `varchar(16)` column allows headroom. */
|
|
30
|
+
export declare const CLIENT_PUBLIC_ID_LENGTH = 12;
|
|
31
|
+
/** A fresh client `publicId`. Uniqueness is enforced by the column, not here. */
|
|
32
|
+
export declare function generateClientPublicId(): string;
|
|
33
|
+
/** Is this a well-formed client `publicId`? */
|
|
34
|
+
export declare function isValidClientPublicId(value: string): boolean;
|
|
35
|
+
//# sourceMappingURL=public-id.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"public-id.d.ts","sourceRoot":"","sources":["../src/public-id.ts"],"names":[],"mappings":"AAEA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAEH,yEAAyE;AACzE,eAAO,MAAM,yBAAyB,yCAAyC,CAAC;AAEhF,mFAAmF;AACnF,eAAO,MAAM,uBAAuB,KAAK,CAAC;AAiC1C,iFAAiF;AACjF,wBAAgB,sBAAsB,IAAI,MAAM,CAE/C;AAED,+CAA+C;AAC/C,wBAAgB,qBAAqB,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAK5D"}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.CLIENT_PUBLIC_ID_LENGTH = exports.CLIENT_PUBLIC_ID_ALPHABET = void 0;
|
|
4
|
+
exports.generateClientPublicId = generateClientPublicId;
|
|
5
|
+
exports.isValidClientPublicId = isValidClientPublicId;
|
|
6
|
+
const node_crypto_1 = require("node:crypto");
|
|
7
|
+
/**
|
|
8
|
+
* The client's `publicId` — the token prefix, in every registration mode.
|
|
9
|
+
*
|
|
10
|
+
* Plan, Decision 3: twelve lowercase base36 characters that *we* assign at
|
|
11
|
+
* registration. It is never the raw `client_id`, because a Client ID Metadata
|
|
12
|
+
* Document client's `client_id` is a URL containing `/`, `:` and `.`, and all
|
|
13
|
+
* three of those break something:
|
|
14
|
+
*
|
|
15
|
+
* - `<publicId>.<generateToken()>` is the token wire format, split on the
|
|
16
|
+
* first `.`;
|
|
17
|
+
* - `<publicId>|<sha256>` is the `user_identities.key`, split on the `|` and
|
|
18
|
+
* bounded by that column's `varchar(255)`;
|
|
19
|
+
* - `key LIKE '<publicId>|%'` is the prefix scan client-wide revocation
|
|
20
|
+
* depends on, which an unbounded URL would make meaningless.
|
|
21
|
+
*
|
|
22
|
+
* So the alphabet is the load-bearing part, not the length. Twelve base36
|
|
23
|
+
* characters is about 62 bits, which is ample for an identifier that is not
|
|
24
|
+
* itself a secret; the token half carries the entropy that matters.
|
|
25
|
+
*
|
|
26
|
+
* This is deliberately *not* `generatePublicId()` from `@boopkit/cap-database`.
|
|
27
|
+
* The house format (Crockford base32, `xxxxxx-xxxxxx`) would in fact survive
|
|
28
|
+
* both splits, but it is a 13-character value with a hyphen, and Decision 3
|
|
29
|
+
* fixed a 12-character `varchar(16)` column against the base36 alphabet. Taking
|
|
30
|
+
* the dependency to get a different-shaped value than the schema declares would
|
|
31
|
+
* trade one small module for a mismatch the schema cannot express.
|
|
32
|
+
*/
|
|
33
|
+
/** Lowercase base36. No `.`, no `|`, no `/`, no `:` — see the header. */
|
|
34
|
+
exports.CLIENT_PUBLIC_ID_ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyz';
|
|
35
|
+
/** Twelve characters, per Decision 3. The `varchar(16)` column allows headroom. */
|
|
36
|
+
exports.CLIENT_PUBLIC_ID_LENGTH = 12;
|
|
37
|
+
/**
|
|
38
|
+
* Draw `length` characters uniformly from the alphabet.
|
|
39
|
+
*
|
|
40
|
+
* Rejection sampling rather than `% alphabet.length`: 256 is not a multiple of
|
|
41
|
+
* 36, so the naive modulo would make the first four symbols about 14% more
|
|
42
|
+
* likely than the rest. That is not a break here — a `publicId` is an
|
|
43
|
+
* identifier rather than a secret — but the same generator shape gets copied
|
|
44
|
+
* onto things that are, and a biased draw is invisible in every test that does
|
|
45
|
+
* not go looking for it.
|
|
46
|
+
*
|
|
47
|
+
* `Math.random()` is not used anywhere in this package; it is on the umbrella
|
|
48
|
+
* spec's donor-defect list for exactly this family of function.
|
|
49
|
+
*/
|
|
50
|
+
function draw(length) {
|
|
51
|
+
const alphabet = exports.CLIENT_PUBLIC_ID_ALPHABET;
|
|
52
|
+
const limit = Math.floor(256 / alphabet.length) * alphabet.length;
|
|
53
|
+
const out = [];
|
|
54
|
+
while (out.length < length) {
|
|
55
|
+
// Over-read so the common case needs one syscall, not `length` of them.
|
|
56
|
+
const bytes = (0, node_crypto_1.randomBytes)((length - out.length) * 2);
|
|
57
|
+
for (const byte of bytes) {
|
|
58
|
+
if (byte >= limit)
|
|
59
|
+
continue;
|
|
60
|
+
out.push(alphabet[byte % alphabet.length]);
|
|
61
|
+
if (out.length === length)
|
|
62
|
+
break;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return out.join('');
|
|
66
|
+
}
|
|
67
|
+
/** A fresh client `publicId`. Uniqueness is enforced by the column, not here. */
|
|
68
|
+
function generateClientPublicId() {
|
|
69
|
+
return draw(exports.CLIENT_PUBLIC_ID_LENGTH);
|
|
70
|
+
}
|
|
71
|
+
/** Is this a well-formed client `publicId`? */
|
|
72
|
+
function isValidClientPublicId(value) {
|
|
73
|
+
return (value.length === exports.CLIENT_PUBLIC_ID_LENGTH &&
|
|
74
|
+
[...value].every((ch) => exports.CLIENT_PUBLIC_ID_ALPHABET.includes(ch)));
|
|
75
|
+
}
|
|
76
|
+
//# sourceMappingURL=public-id.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"public-id.js","sourceRoot":"","sources":["../src/public-id.ts"],"names":[],"mappings":";;;AAmEA,wDAEC;AAGD,sDAKC;AA7ED,6CAA0C;AAE1C;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAEH,yEAAyE;AAC5D,QAAA,yBAAyB,GAAG,sCAAsC,CAAC;AAEhF,mFAAmF;AACtE,QAAA,uBAAuB,GAAG,EAAE,CAAC;AAE1C;;;;;;;;;;;;GAYG;AACH,SAAS,IAAI,CAAC,MAAc;IAC1B,MAAM,QAAQ,GAAG,iCAAyB,CAAC;IAC3C,MAAM,KAAK,GAAG,IAAI,CAAC,KAAK,CAAC,GAAG,GAAG,QAAQ,CAAC,MAAM,CAAC,GAAG,QAAQ,CAAC,MAAM,CAAC;IAClE,MAAM,GAAG,GAAa,EAAE,CAAC;IAEzB,OAAO,GAAG,CAAC,MAAM,GAAG,MAAM,EAAE,CAAC;QAC3B,wEAAwE;QACxE,MAAM,KAAK,GAAG,IAAA,yBAAW,EAAC,CAAC,MAAM,GAAG,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC;QACrD,KAAK,MAAM,IAAI,IAAI,KAAK,EAAE,CAAC;YACzB,IAAI,IAAI,IAAI,KAAK;gBAAE,SAAS;YAC5B,GAAG,CAAC,IAAI,CAAC,QAAQ,CAAC,IAAI,GAAG,QAAQ,CAAC,MAAM,CAAC,CAAC,CAAC;YAC3C,IAAI,GAAG,CAAC,MAAM,KAAK,MAAM;gBAAE,MAAM;QACnC,CAAC;IACH,CAAC;IAED,OAAO,GAAG,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;AACtB,CAAC;AAED,iFAAiF;AACjF,SAAgB,sBAAsB;IACpC,OAAO,IAAI,CAAC,+BAAuB,CAAC,CAAC;AACvC,CAAC;AAED,+CAA+C;AAC/C,SAAgB,qBAAqB,CAAC,KAAa;IACjD,OAAO,CACL,KAAK,CAAC,MAAM,KAAK,+BAAuB;QACxC,CAAC,GAAG,KAAK,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,EAAE,EAAE,CAAC,iCAAyB,CAAC,QAAQ,CAAC,EAAE,CAAC,CAAC,CACjE,CAAC;AACJ,CAAC"}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { RequestHandler } from 'express';
|
|
2
|
+
/**
|
|
3
|
+
* Per-IP rate limiting for the one endpoint that lets a stranger write a row.
|
|
4
|
+
*
|
|
5
|
+
* `POST /oauth/register` is unauthenticated by design — RFC 7591 open
|
|
6
|
+
* registration has no caller to authenticate — so there is no account to hang a
|
|
7
|
+
* quota on and the source address is the only thing a limiter has to go on.
|
|
8
|
+
* Neither donor has anything like this: paywalls' registration endpoint
|
|
9
|
+
* required an authenticated `service-user`, which is a gate this endpoint
|
|
10
|
+
* cannot have.
|
|
11
|
+
*
|
|
12
|
+
* ## What this is, and what it is not
|
|
13
|
+
*
|
|
14
|
+
* A fixed-window counter in process memory. That means:
|
|
15
|
+
*
|
|
16
|
+
* - it resets when the process restarts, and
|
|
17
|
+
* - each instance of a horizontally-scaled app counts separately.
|
|
18
|
+
*
|
|
19
|
+
* Both are acceptable for what it defends. The row ceiling in the registry
|
|
20
|
+
* (`maxRegisteredClients`) is the hard bound on damage; this limiter exists to
|
|
21
|
+
* make reaching that ceiling slow and noisy rather than instant. An app that
|
|
22
|
+
* wants a shared limiter across instances should mount its own in front of the
|
|
23
|
+
* handler — the router factory takes one — rather than have this one grow a
|
|
24
|
+
* Redis dependency the capability would then require of everybody.
|
|
25
|
+
*
|
|
26
|
+
* A fixed window also lets a caller send `2 × limit` requests across a window
|
|
27
|
+
* boundary. Named here rather than fixed, because the mitigation (a sliding
|
|
28
|
+
* window) buys precision this does not need against a ceiling that is already
|
|
29
|
+
* the real bound.
|
|
30
|
+
*/
|
|
31
|
+
export interface RateLimiterOptions {
|
|
32
|
+
/** Requests allowed per window, per address. */
|
|
33
|
+
limit?: number;
|
|
34
|
+
/** Window length in milliseconds. */
|
|
35
|
+
windowMs?: number;
|
|
36
|
+
/** Injectable clock, so the window can be tested without waiting for it. */
|
|
37
|
+
now?: () => number;
|
|
38
|
+
/**
|
|
39
|
+
* How many distinct addresses to track before the oldest window is dropped.
|
|
40
|
+
* Bounds the memory an attacker can make this hold.
|
|
41
|
+
*/
|
|
42
|
+
maxTrackedAddresses?: number;
|
|
43
|
+
}
|
|
44
|
+
export declare const DEFAULT_REGISTRATION_RATE_LIMIT = 10;
|
|
45
|
+
export declare const DEFAULT_REGISTRATION_RATE_WINDOW_MS: number;
|
|
46
|
+
export interface RegistrationRateLimiter {
|
|
47
|
+
middleware: RequestHandler;
|
|
48
|
+
/** Exposed for tests and for an operator script; not part of the route. */
|
|
49
|
+
check(address: string): {
|
|
50
|
+
allowed: boolean;
|
|
51
|
+
retryAfterSeconds: number;
|
|
52
|
+
};
|
|
53
|
+
reset(): void;
|
|
54
|
+
}
|
|
55
|
+
export declare function createRegistrationRateLimiter(options?: RateLimiterOptions): RegistrationRateLimiter;
|
|
56
|
+
//# sourceMappingURL=rate-limit.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"rate-limit.d.ts","sourceRoot":"","sources":["../src/rate-limit.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAyB,cAAc,EAAY,MAAM,SAAS,CAAC;AAE/E;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AAEH,MAAM,WAAW,kBAAkB;IACjC,gDAAgD;IAChD,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,qCAAqC;IACrC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,4EAA4E;IAC5E,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;IACnB;;;OAGG;IACH,mBAAmB,CAAC,EAAE,MAAM,CAAC;CAC9B;AAED,eAAO,MAAM,+BAA+B,KAAK,CAAC;AAClD,eAAO,MAAM,mCAAmC,QAAiB,CAAC;AAQlE,MAAM,WAAW,uBAAuB;IACtC,UAAU,EAAE,cAAc,CAAC;IAC3B,2EAA2E;IAC3E,KAAK,CAAC,OAAO,EAAE,MAAM,GAAG;QAAE,OAAO,EAAE,OAAO,CAAC;QAAC,iBAAiB,EAAE,MAAM,CAAA;KAAE,CAAC;IACxE,KAAK,IAAI,IAAI,CAAC;CACf;AAED,wBAAgB,6BAA6B,CAC3C,OAAO,GAAE,kBAAuB,GAC/B,uBAAuB,CAmDzB"}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.DEFAULT_REGISTRATION_RATE_WINDOW_MS = exports.DEFAULT_REGISTRATION_RATE_LIMIT = void 0;
|
|
4
|
+
exports.createRegistrationRateLimiter = createRegistrationRateLimiter;
|
|
5
|
+
exports.DEFAULT_REGISTRATION_RATE_LIMIT = 10;
|
|
6
|
+
exports.DEFAULT_REGISTRATION_RATE_WINDOW_MS = 60 * 60 * 1000;
|
|
7
|
+
const DEFAULT_MAX_TRACKED_ADDRESSES = 10000;
|
|
8
|
+
function createRegistrationRateLimiter(options = {}) {
|
|
9
|
+
const limit = options.limit ?? exports.DEFAULT_REGISTRATION_RATE_LIMIT;
|
|
10
|
+
const windowMs = options.windowMs ?? exports.DEFAULT_REGISTRATION_RATE_WINDOW_MS;
|
|
11
|
+
const now = options.now ?? (() => Date.now());
|
|
12
|
+
const maxTracked = options.maxTrackedAddresses ?? DEFAULT_MAX_TRACKED_ADDRESSES;
|
|
13
|
+
const windows = new Map();
|
|
14
|
+
function check(address) {
|
|
15
|
+
const at = now();
|
|
16
|
+
const existing = windows.get(address);
|
|
17
|
+
if (!existing || existing.resetAt <= at) {
|
|
18
|
+
// Map insertion order is insertion order, so the first key is the least
|
|
19
|
+
// recently *started* window — good enough for an eviction whose only job
|
|
20
|
+
// is to bound memory.
|
|
21
|
+
if (windows.size >= maxTracked) {
|
|
22
|
+
const oldest = windows.keys().next();
|
|
23
|
+
if (!oldest.done)
|
|
24
|
+
windows.delete(oldest.value);
|
|
25
|
+
}
|
|
26
|
+
windows.set(address, { count: 1, resetAt: at + windowMs });
|
|
27
|
+
return { allowed: true, retryAfterSeconds: 0 };
|
|
28
|
+
}
|
|
29
|
+
existing.count += 1;
|
|
30
|
+
if (existing.count > limit) {
|
|
31
|
+
return {
|
|
32
|
+
allowed: false,
|
|
33
|
+
retryAfterSeconds: Math.max(1, Math.ceil((existing.resetAt - at) / 1000)),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
return { allowed: true, retryAfterSeconds: 0 };
|
|
37
|
+
}
|
|
38
|
+
const middleware = (req, res, next) => {
|
|
39
|
+
const address = req.ip ?? req.socket?.remoteAddress ?? 'unknown';
|
|
40
|
+
const verdict = check(address);
|
|
41
|
+
if (verdict.allowed)
|
|
42
|
+
return next();
|
|
43
|
+
res.setHeader('Retry-After', String(verdict.retryAfterSeconds));
|
|
44
|
+
res.status(429).json({
|
|
45
|
+
error: 'temporarily_unavailable',
|
|
46
|
+
error_description: 'too many registration requests from this address; try again later',
|
|
47
|
+
});
|
|
48
|
+
};
|
|
49
|
+
return {
|
|
50
|
+
middleware,
|
|
51
|
+
check,
|
|
52
|
+
reset: () => windows.clear(),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
//# sourceMappingURL=rate-limit.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"rate-limit.js","sourceRoot":"","sources":["../src/rate-limit.ts"],"names":[],"mappings":";;;AA8DA,sEAqDC;AArEY,QAAA,+BAA+B,GAAG,EAAE,CAAC;AACrC,QAAA,mCAAmC,GAAG,EAAE,GAAG,EAAE,GAAG,IAAI,CAAC;AAClE,MAAM,6BAA6B,GAAG,KAAM,CAAC;AAc7C,SAAgB,6BAA6B,CAC3C,UAA8B,EAAE;IAEhC,MAAM,KAAK,GAAG,OAAO,CAAC,KAAK,IAAI,uCAA+B,CAAC;IAC/D,MAAM,QAAQ,GAAG,OAAO,CAAC,QAAQ,IAAI,2CAAmC,CAAC;IACzE,MAAM,GAAG,GAAG,OAAO,CAAC,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC,IAAI,CAAC,GAAG,EAAE,CAAC,CAAC;IAC9C,MAAM,UAAU,GAAG,OAAO,CAAC,mBAAmB,IAAI,6BAA6B,CAAC;IAEhF,MAAM,OAAO,GAAG,IAAI,GAAG,EAAkB,CAAC;IAE1C,SAAS,KAAK,CAAC,OAAe;QAC5B,MAAM,EAAE,GAAG,GAAG,EAAE,CAAC;QACjB,MAAM,QAAQ,GAAG,OAAO,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC;QAEtC,IAAI,CAAC,QAAQ,IAAI,QAAQ,CAAC,OAAO,IAAI,EAAE,EAAE,CAAC;YACxC,wEAAwE;YACxE,yEAAyE;YACzE,sBAAsB;YACtB,IAAI,OAAO,CAAC,IAAI,IAAI,UAAU,EAAE,CAAC;gBAC/B,MAAM,MAAM,GAAG,OAAO,CAAC,IAAI,EAAE,CAAC,IAAI,EAAE,CAAC;gBACrC,IAAI,CAAC,MAAM,CAAC,IAAI;oBAAE,OAAO,CAAC,MAAM,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;YACjD,CAAC;YACD,OAAO,CAAC,GAAG,CAAC,OAAO,EAAE,EAAE,KAAK,EAAE,CAAC,EAAE,OAAO,EAAE,EAAE,GAAG,QAAQ,EAAE,CAAC,CAAC;YAC3D,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,iBAAiB,EAAE,CAAC,EAAE,CAAC;QACjD,CAAC;QAED,QAAQ,CAAC,KAAK,IAAI,CAAC,CAAC;QACpB,IAAI,QAAQ,CAAC,KAAK,GAAG,KAAK,EAAE,CAAC;YAC3B,OAAO;gBACL,OAAO,EAAE,KAAK;gBACd,iBAAiB,EAAE,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,IAAI,CAAC,IAAI,CAAC,CAAC,QAAQ,CAAC,OAAO,GAAG,EAAE,CAAC,GAAG,IAAI,CAAC,CAAC;aAC1E,CAAC;QACJ,CAAC;QACD,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,iBAAiB,EAAE,CAAC,EAAE,CAAC;IACjD,CAAC;IAED,MAAM,UAAU,GAAmB,CAAC,GAAY,EAAE,GAAa,EAAE,IAAkB,EAAE,EAAE;QACrF,MAAM,OAAO,GAAG,GAAG,CAAC,EAAE,IAAI,GAAG,CAAC,MAAM,EAAE,aAAa,IAAI,SAAS,CAAC;QACjE,MAAM,OAAO,GAAG,KAAK,CAAC,OAAO,CAAC,CAAC;QAC/B,IAAI,OAAO,CAAC,OAAO;YAAE,OAAO,IAAI,EAAE,CAAC;QAEnC,GAAG,CAAC,SAAS,CAAC,aAAa,EAAE,MAAM,CAAC,OAAO,CAAC,iBAAiB,CAAC,CAAC,CAAC;QAChE,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC;YACnB,KAAK,EAAE,yBAAyB;YAChC,iBAAiB,EAAE,mEAAmE;SACvF,CAAC,CAAC;IACL,CAAC,CAAC;IAEF,OAAO;QACL,UAAU;QACV,KAAK;QACL,KAAK,EAAE,GAAG,EAAE,CAAC,OAAO,CAAC,KAAK,EAAE;KAC7B,CAAC;AACJ,CAAC"}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Redirect-URI policy: what may be registered, and what a registered entry
|
|
3
|
+
* accepts at the authorization endpoint.
|
|
4
|
+
*
|
|
5
|
+
* Both questions are answered from one classifier, `classifyRedirectUri`,
|
|
6
|
+
* because the two halves only work as a pair. A shape registration stores but
|
|
7
|
+
* the matcher will not parse is worse than a shape neither accepts: the client
|
|
8
|
+
* registers, believes it is connected, and then fails at the authorization
|
|
9
|
+
* endpoint with `redirect_uri is not registered`. Keeping the rule in one
|
|
10
|
+
* function is what makes that disagreement impossible rather than unlikely.
|
|
11
|
+
*
|
|
12
|
+
* ## The three shapes
|
|
13
|
+
*
|
|
14
|
+
* RFC 8252 (OAuth 2.0 for Native Apps) names exactly three ways a client may be
|
|
15
|
+
* called back, and this server accepts those three and nothing else:
|
|
16
|
+
*
|
|
17
|
+
* `https` an absolute https URL — §7.2, the claimed-https redirect
|
|
18
|
+
* a hosted connector such as Claude's web app uses.
|
|
19
|
+
* `loopback-http` `http` on `localhost` or `127.0.0.1` — §7.3, because a
|
|
20
|
+
* native app cannot terminate TLS on the loopback interface.
|
|
21
|
+
* `private-use` a hierarchical URI in an application's own scheme, such as
|
|
22
|
+
* `com.example.app:/oauth2redirect/cb` or
|
|
23
|
+
* `claude://oauth/callback` — §7.1, how a desktop or mobile
|
|
24
|
+
* app is handed the response by its operating system.
|
|
25
|
+
*
|
|
26
|
+
* ## The matching rule
|
|
27
|
+
*
|
|
28
|
+
* **Exact string match**, with exactly one exception: a registered
|
|
29
|
+
* `loopback-http` entry matches a presented URI that differs only in port. That
|
|
30
|
+
* is RFC 8252 §7.3, and it is what Claude Code's published Client ID Metadata
|
|
31
|
+
* Document requires — it binds an ephemeral loopback port at connect time, so
|
|
32
|
+
* the port it will present is not knowable at registration.
|
|
33
|
+
*
|
|
34
|
+
* The exception is scoped to `loopback-http` and not merely to a loopback
|
|
35
|
+
* *host*. A private-use scheme has no TCP port to be ephemeral: the operating
|
|
36
|
+
* system routes it by scheme name, so `myapp://localhost/cb` gets the plain
|
|
37
|
+
* exact rule even though its host happens to read as loopback.
|
|
38
|
+
*
|
|
39
|
+
* The exception lives here, in one function with its own test table, rather
|
|
40
|
+
* than being threaded through the authorization controller as a special case.
|
|
41
|
+
* A redirect-URI check that is spread across a controller is how the umbrella
|
|
42
|
+
* spec's "declared-but-unenforced redirect URI lists" defect happens: the list
|
|
43
|
+
* is stored, the comparison drifts, and nothing says so.
|
|
44
|
+
*
|
|
45
|
+
* Nothing here reads a database or a request. It compares two strings.
|
|
46
|
+
*/
|
|
47
|
+
/** Which of the three accepted shapes a redirect URI is. */
|
|
48
|
+
export type RedirectUriShape = 'https' | 'loopback-http' | 'private-use';
|
|
49
|
+
/**
|
|
50
|
+
* The verdict on one redirect URI.
|
|
51
|
+
*
|
|
52
|
+
* `reason` is a sentence fragment completing "this entry …", so a caller can
|
|
53
|
+
* prefix it with whatever names the offending value — the client registry says
|
|
54
|
+
* `redirect_uris entry ${reason}: ${entry}`.
|
|
55
|
+
*/
|
|
56
|
+
export type RedirectUriClassification = {
|
|
57
|
+
accepted: true;
|
|
58
|
+
shape: RedirectUriShape;
|
|
59
|
+
url: URL;
|
|
60
|
+
} | {
|
|
61
|
+
accepted: false;
|
|
62
|
+
reason: string;
|
|
63
|
+
};
|
|
64
|
+
/**
|
|
65
|
+
* Classify one redirect URI, or say why it is refused.
|
|
66
|
+
*
|
|
67
|
+
* The single source of the policy. `validateRedirectUris` in the client
|
|
68
|
+
* registry calls it to decide whether a URI may be **stored**; `matchRedirectUri`
|
|
69
|
+
* below calls it to decide whether a stored or presented URI may be
|
|
70
|
+
* **compared**.
|
|
71
|
+
*/
|
|
72
|
+
export declare function classifyRedirectUri(value: string): RedirectUriClassification;
|
|
73
|
+
/**
|
|
74
|
+
* Does `presented` satisfy the registered entry `registered`?
|
|
75
|
+
*
|
|
76
|
+
* @param registered one entry from the client's `redirect_uris`
|
|
77
|
+
* @param presented the `redirect_uri` the authorization request carried
|
|
78
|
+
*/
|
|
79
|
+
export declare function matchRedirectUri(registered: string, presented: string): boolean;
|
|
80
|
+
/**
|
|
81
|
+
* Does any registered entry accept the presented URI?
|
|
82
|
+
*
|
|
83
|
+
* The authorization controller's actual question, kept next to the rule it is
|
|
84
|
+
* built from so a caller never has to write the loop itself.
|
|
85
|
+
*/
|
|
86
|
+
export declare function matchesAnyRedirectUri(registered: readonly string[], presented: string): boolean;
|
|
87
|
+
/** Is this host one the loopback exception applies to? */
|
|
88
|
+
export declare function isLoopbackHost(host: string): boolean;
|
|
89
|
+
//# sourceMappingURL=redirect-uri.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"redirect-uri.d.ts","sourceRoot":"","sources":["../src/redirect-uri.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6CG;AAsDH,4DAA4D;AAC5D,MAAM,MAAM,gBAAgB,GAAG,OAAO,GAAG,eAAe,GAAG,aAAa,CAAC;AAEzE;;;;;;GAMG;AACH,MAAM,MAAM,yBAAyB,GACjC;IAAE,QAAQ,EAAE,IAAI,CAAC;IAAC,KAAK,EAAE,gBAAgB,CAAC;IAAC,GAAG,EAAE,GAAG,CAAA;CAAE,GACrD;IAAE,QAAQ,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC;AA6BxC;;;;;;;GAOG;AACH,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,MAAM,GAAG,yBAAyB,CAsC5E;AAiBD;;;;;GAKG;AACH,wBAAgB,gBAAgB,CAAC,UAAU,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CA+C/E;AAED;;;;;GAKG;AACH,wBAAgB,qBAAqB,CAAC,UAAU,EAAE,SAAS,MAAM,EAAE,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAE/F;AAED,0DAA0D;AAC1D,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAEpD"}
|