@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
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The two `.well-known` discovery documents.
|
|
3
|
+
*
|
|
4
|
+
* ## Computed, never read off disk
|
|
5
|
+
*
|
|
6
|
+
* The donor controller (`paywalls-site/src/controllers/wellknownController.ts`)
|
|
7
|
+
* serves these by joining `req.params.resource` onto a fixed prefix and
|
|
8
|
+
* compiling whatever file it lands on. Two properties of that are wrong for a
|
|
9
|
+
* shipped package rather than for paywalls, and the findings name both:
|
|
10
|
+
*
|
|
11
|
+
* 1. `req.params.resource` reaches `path.join` without `..` being stripped.
|
|
12
|
+
* Paywalls is saved by there being nothing outside the prefix it would
|
|
13
|
+
* serve, which is a fact about its filesystem, not a property of its code.
|
|
14
|
+
* 2. The prefix is relative to the process working directory, which is fine
|
|
15
|
+
* for an application and fragile for a capability whose files may be
|
|
16
|
+
* scaffolded or may sit in `node_modules`.
|
|
17
|
+
*
|
|
18
|
+
* Neither applies here, because these documents are **computed from
|
|
19
|
+
* configuration** by the functions below. There is no path, so there is nothing
|
|
20
|
+
* to traverse, and there is no working directory to be wrong about.
|
|
21
|
+
*
|
|
22
|
+
* ## And why the values are all fresh
|
|
23
|
+
*
|
|
24
|
+
* The donor's documents do not survive at all. Its authorization-server
|
|
25
|
+
* document advertises `grant_types_supported: ["refresh_token"]` and nothing
|
|
26
|
+
* else, has no `code_challenge_methods_supported`, no
|
|
27
|
+
* `client_id_metadata_document_supported`, and points `authorization_endpoint`
|
|
28
|
+
* at a POST endpoint authenticated by an API key. Its protected-resource
|
|
29
|
+
* document sets `resource` to the bare host rather than to the protected
|
|
30
|
+
* endpoint URL, which is the one thing the Claude ecosystem checklist says must
|
|
31
|
+
* match exactly as clients enter it.
|
|
32
|
+
*/
|
|
33
|
+
export interface DiscoveryConfig {
|
|
34
|
+
/** The authorization server's issuer identifier — an https origin, no path. */
|
|
35
|
+
issuer: string;
|
|
36
|
+
/**
|
|
37
|
+
* The protected resource's URL, as clients enter it.
|
|
38
|
+
*
|
|
39
|
+
* This is *not* the issuer. A client that connects to `https://app/api/mcp`
|
|
40
|
+
* needs to read that exact string back, so this is configured rather than
|
|
41
|
+
* derived, and the derivation the scaffold offers is a default rather than a
|
|
42
|
+
* rule.
|
|
43
|
+
*/
|
|
44
|
+
resource: string;
|
|
45
|
+
/** Scopes this server will issue. `offline_access` earns the refresh token. */
|
|
46
|
+
scopesSupported: string[];
|
|
47
|
+
/**
|
|
48
|
+
* Whether the device-code grant is mounted.
|
|
49
|
+
*
|
|
50
|
+
* When it is off the grant type is **absent** from `grant_types_supported`,
|
|
51
|
+
* rather than advertised against an endpoint that answers 404. Discovery
|
|
52
|
+
* metadata is a contract: a client that read the metadata treats a 404 as a
|
|
53
|
+
* server fault rather than as an unsupported feature. The flag gates the
|
|
54
|
+
* mount and this field from one value, so the two cannot drift.
|
|
55
|
+
*
|
|
56
|
+
* Slice 1 has no device endpoints, so the scaffold passes `false`.
|
|
57
|
+
*/
|
|
58
|
+
deviceCodeEnabled?: boolean;
|
|
59
|
+
/** Optional human-facing documentation URL, echoed into both documents. */
|
|
60
|
+
documentationUri?: string;
|
|
61
|
+
}
|
|
62
|
+
/** Only S256. `plain` is not a PKCE method, it is the absence of one. */
|
|
63
|
+
export declare const CODE_CHALLENGE_METHODS_SUPPORTED: readonly ["S256"];
|
|
64
|
+
/** RFC 8628's grant type, named once so the metadata and the mount agree. */
|
|
65
|
+
export declare const DEVICE_CODE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code";
|
|
66
|
+
/** What a boop app can meaningfully scope, before an operator narrows it. */
|
|
67
|
+
export declare const DEFAULT_SCOPES_SUPPORTED: string[];
|
|
68
|
+
/** One hour, matching the donor's `Cache-Control` and the ecosystem's habit. */
|
|
69
|
+
export declare const DISCOVERY_MAX_AGE_SECONDS = 3600;
|
|
70
|
+
export interface AuthorizationServerMetadata {
|
|
71
|
+
issuer: string;
|
|
72
|
+
authorization_endpoint: string;
|
|
73
|
+
token_endpoint: string;
|
|
74
|
+
registration_endpoint: string;
|
|
75
|
+
revocation_endpoint: string;
|
|
76
|
+
response_types_supported: string[];
|
|
77
|
+
grant_types_supported: string[];
|
|
78
|
+
code_challenge_methods_supported: string[];
|
|
79
|
+
token_endpoint_auth_methods_supported: string[];
|
|
80
|
+
scopes_supported: string[];
|
|
81
|
+
client_id_metadata_document_supported: boolean;
|
|
82
|
+
service_documentation?: string;
|
|
83
|
+
}
|
|
84
|
+
/** RFC 8414, plus the CIMD field the Claude ecosystem reads. */
|
|
85
|
+
export declare function buildAuthorizationServerMetadata(config: DiscoveryConfig): AuthorizationServerMetadata;
|
|
86
|
+
export interface ProtectedResourceMetadata {
|
|
87
|
+
resource: string;
|
|
88
|
+
authorization_servers: string[];
|
|
89
|
+
scopes_supported: string[];
|
|
90
|
+
bearer_methods_supported: string[];
|
|
91
|
+
resource_documentation?: string;
|
|
92
|
+
}
|
|
93
|
+
/** RFC 9728. */
|
|
94
|
+
export declare function buildProtectedResourceMetadata(config: DiscoveryConfig): ProtectedResourceMetadata;
|
|
95
|
+
//# sourceMappingURL=discovery.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"discovery.d.ts","sourceRoot":"","sources":["../src/discovery.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AAEH,MAAM,WAAW,eAAe;IAC9B,+EAA+E;IAC/E,MAAM,EAAE,MAAM,CAAC;IACf;;;;;;;OAOG;IACH,QAAQ,EAAE,MAAM,CAAC;IACjB,+EAA+E;IAC/E,eAAe,EAAE,MAAM,EAAE,CAAC;IAC1B;;;;;;;;;;OAUG;IACH,iBAAiB,CAAC,EAAE,OAAO,CAAC;IAC5B,2EAA2E;IAC3E,gBAAgB,CAAC,EAAE,MAAM,CAAC;CAC3B;AAED,yEAAyE;AACzE,eAAO,MAAM,gCAAgC,mBAAoB,CAAC;AAElE,6EAA6E;AAC7E,eAAO,MAAM,sBAAsB,iDAAiD,CAAC;AAErF,6EAA6E;AAC7E,eAAO,MAAM,wBAAwB,UAAyC,CAAC;AAE/E,gFAAgF;AAChF,eAAO,MAAM,yBAAyB,OAAO,CAAC;AAO9C,MAAM,WAAW,2BAA2B;IAC1C,MAAM,EAAE,MAAM,CAAC;IACf,sBAAsB,EAAE,MAAM,CAAC;IAC/B,cAAc,EAAE,MAAM,CAAC;IACvB,qBAAqB,EAAE,MAAM,CAAC;IAC9B,mBAAmB,EAAE,MAAM,CAAC;IAC5B,wBAAwB,EAAE,MAAM,EAAE,CAAC;IACnC,qBAAqB,EAAE,MAAM,EAAE,CAAC;IAChC,gCAAgC,EAAE,MAAM,EAAE,CAAC;IAC3C,qCAAqC,EAAE,MAAM,EAAE,CAAC;IAChD,gBAAgB,EAAE,MAAM,EAAE,CAAC;IAC3B,qCAAqC,EAAE,OAAO,CAAC;IAC/C,qBAAqB,CAAC,EAAE,MAAM,CAAC;CAChC;AAED,gEAAgE;AAChE,wBAAgB,gCAAgC,CAC9C,MAAM,EAAE,eAAe,GACtB,2BAA2B,CAiC7B;AAED,MAAM,WAAW,yBAAyB;IACxC,QAAQ,EAAE,MAAM,CAAC;IACjB,qBAAqB,EAAE,MAAM,EAAE,CAAC;IAChC,gBAAgB,EAAE,MAAM,EAAE,CAAC;IAC3B,wBAAwB,EAAE,MAAM,EAAE,CAAC;IACnC,sBAAsB,CAAC,EAAE,MAAM,CAAC;CACjC;AAED,gBAAgB;AAChB,wBAAgB,8BAA8B,CAC5C,MAAM,EAAE,eAAe,GACtB,yBAAyB,CAe3B"}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* The two `.well-known` discovery documents.
|
|
4
|
+
*
|
|
5
|
+
* ## Computed, never read off disk
|
|
6
|
+
*
|
|
7
|
+
* The donor controller (`paywalls-site/src/controllers/wellknownController.ts`)
|
|
8
|
+
* serves these by joining `req.params.resource` onto a fixed prefix and
|
|
9
|
+
* compiling whatever file it lands on. Two properties of that are wrong for a
|
|
10
|
+
* shipped package rather than for paywalls, and the findings name both:
|
|
11
|
+
*
|
|
12
|
+
* 1. `req.params.resource` reaches `path.join` without `..` being stripped.
|
|
13
|
+
* Paywalls is saved by there being nothing outside the prefix it would
|
|
14
|
+
* serve, which is a fact about its filesystem, not a property of its code.
|
|
15
|
+
* 2. The prefix is relative to the process working directory, which is fine
|
|
16
|
+
* for an application and fragile for a capability whose files may be
|
|
17
|
+
* scaffolded or may sit in `node_modules`.
|
|
18
|
+
*
|
|
19
|
+
* Neither applies here, because these documents are **computed from
|
|
20
|
+
* configuration** by the functions below. There is no path, so there is nothing
|
|
21
|
+
* to traverse, and there is no working directory to be wrong about.
|
|
22
|
+
*
|
|
23
|
+
* ## And why the values are all fresh
|
|
24
|
+
*
|
|
25
|
+
* The donor's documents do not survive at all. Its authorization-server
|
|
26
|
+
* document advertises `grant_types_supported: ["refresh_token"]` and nothing
|
|
27
|
+
* else, has no `code_challenge_methods_supported`, no
|
|
28
|
+
* `client_id_metadata_document_supported`, and points `authorization_endpoint`
|
|
29
|
+
* at a POST endpoint authenticated by an API key. Its protected-resource
|
|
30
|
+
* document sets `resource` to the bare host rather than to the protected
|
|
31
|
+
* endpoint URL, which is the one thing the Claude ecosystem checklist says must
|
|
32
|
+
* match exactly as clients enter it.
|
|
33
|
+
*/
|
|
34
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
35
|
+
exports.DISCOVERY_MAX_AGE_SECONDS = exports.DEFAULT_SCOPES_SUPPORTED = exports.DEVICE_CODE_GRANT_TYPE = exports.CODE_CHALLENGE_METHODS_SUPPORTED = void 0;
|
|
36
|
+
exports.buildAuthorizationServerMetadata = buildAuthorizationServerMetadata;
|
|
37
|
+
exports.buildProtectedResourceMetadata = buildProtectedResourceMetadata;
|
|
38
|
+
/** Only S256. `plain` is not a PKCE method, it is the absence of one. */
|
|
39
|
+
exports.CODE_CHALLENGE_METHODS_SUPPORTED = ['S256'];
|
|
40
|
+
/** RFC 8628's grant type, named once so the metadata and the mount agree. */
|
|
41
|
+
exports.DEVICE_CODE_GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:device_code';
|
|
42
|
+
/** What a boop app can meaningfully scope, before an operator narrows it. */
|
|
43
|
+
exports.DEFAULT_SCOPES_SUPPORTED = ['profile', 'email', 'offline_access'];
|
|
44
|
+
/** One hour, matching the donor's `Cache-Control` and the ecosystem's habit. */
|
|
45
|
+
exports.DISCOVERY_MAX_AGE_SECONDS = 3600;
|
|
46
|
+
/** Trim one trailing slash, so `https://app/` and `https://app` agree. */
|
|
47
|
+
function origin(value) {
|
|
48
|
+
return value.endsWith('/') ? value.slice(0, -1) : value;
|
|
49
|
+
}
|
|
50
|
+
/** RFC 8414, plus the CIMD field the Claude ecosystem reads. */
|
|
51
|
+
function buildAuthorizationServerMetadata(config) {
|
|
52
|
+
const base = origin(config.issuer);
|
|
53
|
+
const grantTypes = ['authorization_code', 'refresh_token'];
|
|
54
|
+
if (config.deviceCodeEnabled)
|
|
55
|
+
grantTypes.push(exports.DEVICE_CODE_GRANT_TYPE);
|
|
56
|
+
const metadata = {
|
|
57
|
+
issuer: base,
|
|
58
|
+
authorization_endpoint: `${base}/oauth/authorize`,
|
|
59
|
+
token_endpoint: `${base}/oauth/token`,
|
|
60
|
+
// Honest only because Slice 1 ships the endpoint. Advertising a
|
|
61
|
+
// registration endpoint that does not exist is the same class of lie as
|
|
62
|
+
// advertising the device grant with the flag off.
|
|
63
|
+
registration_endpoint: `${base}/oauth/register`,
|
|
64
|
+
// Honest for the same reason: `createRevocationRouter` mounts it on the
|
|
65
|
+
// same router the token endpoint is on. It was the one field in this
|
|
66
|
+
// document that broke the rule above, until it did.
|
|
67
|
+
revocation_endpoint: `${base}/oauth/revoke`,
|
|
68
|
+
response_types_supported: ['code'],
|
|
69
|
+
grant_types_supported: grantTypes,
|
|
70
|
+
// PKCE is not optional here. OAuth 2.1 requires it for public clients, and
|
|
71
|
+
// every client this server will see is public.
|
|
72
|
+
code_challenge_methods_supported: [...exports.CODE_CHALLENGE_METHODS_SUPPORTED],
|
|
73
|
+
// `none` is what a public client authenticates with: nothing. It has to be
|
|
74
|
+
// advertised, because a client that finds only confidential methods here
|
|
75
|
+
// concludes it cannot use this server at all.
|
|
76
|
+
token_endpoint_auth_methods_supported: ['none'],
|
|
77
|
+
scopes_supported: [...config.scopesSupported],
|
|
78
|
+
client_id_metadata_document_supported: true,
|
|
79
|
+
};
|
|
80
|
+
if (config.documentationUri)
|
|
81
|
+
metadata.service_documentation = config.documentationUri;
|
|
82
|
+
return metadata;
|
|
83
|
+
}
|
|
84
|
+
/** RFC 9728. */
|
|
85
|
+
function buildProtectedResourceMetadata(config) {
|
|
86
|
+
const metadata = {
|
|
87
|
+
// As clients enter it — see the field's note on DiscoveryConfig.
|
|
88
|
+
resource: origin(config.resource),
|
|
89
|
+
// One entry, and Claude reads only the first. A second entry would not be
|
|
90
|
+
// an error, but it would be decoration.
|
|
91
|
+
authorization_servers: [origin(config.issuer)],
|
|
92
|
+
scopes_supported: [...config.scopesSupported],
|
|
93
|
+
// The header, and only the header. A bearer token in a query string lands
|
|
94
|
+
// in access logs and `Referer`.
|
|
95
|
+
bearer_methods_supported: ['header'],
|
|
96
|
+
};
|
|
97
|
+
if (config.documentationUri)
|
|
98
|
+
metadata.resource_documentation = config.documentationUri;
|
|
99
|
+
return metadata;
|
|
100
|
+
}
|
|
101
|
+
//# sourceMappingURL=discovery.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"discovery.js","sourceRoot":"","sources":["../src/discovery.ts"],"names":[],"mappings":";AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;;;AAiEH,4EAmCC;AAWD,wEAiBC;AAhGD,yEAAyE;AAC5D,QAAA,gCAAgC,GAAG,CAAC,MAAM,CAAU,CAAC;AAElE,6EAA6E;AAChE,QAAA,sBAAsB,GAAG,8CAA8C,CAAC;AAErF,6EAA6E;AAChE,QAAA,wBAAwB,GAAG,CAAC,SAAS,EAAE,OAAO,EAAE,gBAAgB,CAAC,CAAC;AAE/E,gFAAgF;AACnE,QAAA,yBAAyB,GAAG,IAAI,CAAC;AAE9C,0EAA0E;AAC1E,SAAS,MAAM,CAAC,KAAa;IAC3B,OAAO,KAAK,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC;AAC1D,CAAC;AAiBD,gEAAgE;AAChE,SAAgB,gCAAgC,CAC9C,MAAuB;IAEvB,MAAM,IAAI,GAAG,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC;IAEnC,MAAM,UAAU,GAAG,CAAC,oBAAoB,EAAE,eAAe,CAAC,CAAC;IAC3D,IAAI,MAAM,CAAC,iBAAiB;QAAE,UAAU,CAAC,IAAI,CAAC,8BAAsB,CAAC,CAAC;IAEtE,MAAM,QAAQ,GAAgC;QAC5C,MAAM,EAAE,IAAI;QACZ,sBAAsB,EAAE,GAAG,IAAI,kBAAkB;QACjD,cAAc,EAAE,GAAG,IAAI,cAAc;QACrC,gEAAgE;QAChE,wEAAwE;QACxE,kDAAkD;QAClD,qBAAqB,EAAE,GAAG,IAAI,iBAAiB;QAC/C,wEAAwE;QACxE,qEAAqE;QACrE,oDAAoD;QACpD,mBAAmB,EAAE,GAAG,IAAI,eAAe;QAC3C,wBAAwB,EAAE,CAAC,MAAM,CAAC;QAClC,qBAAqB,EAAE,UAAU;QACjC,2EAA2E;QAC3E,+CAA+C;QAC/C,gCAAgC,EAAE,CAAC,GAAG,wCAAgC,CAAC;QACvE,2EAA2E;QAC3E,yEAAyE;QACzE,8CAA8C;QAC9C,qCAAqC,EAAE,CAAC,MAAM,CAAC;QAC/C,gBAAgB,EAAE,CAAC,GAAG,MAAM,CAAC,eAAe,CAAC;QAC7C,qCAAqC,EAAE,IAAI;KAC5C,CAAC;IAEF,IAAI,MAAM,CAAC,gBAAgB;QAAE,QAAQ,CAAC,qBAAqB,GAAG,MAAM,CAAC,gBAAgB,CAAC;IACtF,OAAO,QAAQ,CAAC;AAClB,CAAC;AAUD,gBAAgB;AAChB,SAAgB,8BAA8B,CAC5C,MAAuB;IAEvB,MAAM,QAAQ,GAA8B;QAC1C,iEAAiE;QACjE,QAAQ,EAAE,MAAM,CAAC,MAAM,CAAC,QAAQ,CAAC;QACjC,0EAA0E;QAC1E,wCAAwC;QACxC,qBAAqB,EAAE,CAAC,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC;QAC9C,gBAAgB,EAAE,CAAC,GAAG,MAAM,CAAC,eAAe,CAAC;QAC7C,0EAA0E;QAC1E,gCAAgC;QAChC,wBAAwB,EAAE,CAAC,QAAQ,CAAC;KACrC,CAAC;IAEF,IAAI,MAAM,CAAC,gBAAgB;QAAE,QAAQ,CAAC,sBAAsB,GAAG,MAAM,CAAC,gBAAgB,CAAC;IACvF,OAAO,QAAQ,CAAC;AAClB,CAAC"}
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The registry's refusals, as typed errors carrying an RFC-shaped code.
|
|
3
|
+
*
|
|
4
|
+
* The code is what the controller writes into an OAuth error body, so it is
|
|
5
|
+
* chosen from the specification rather than invented: RFC 7591 §3.2.2 defines
|
|
6
|
+
* `invalid_client_metadata` and `invalid_redirect_uri` for registration, and
|
|
7
|
+
* RFC 6749 §5.2 defines `invalid_client` and `invalid_request`. Everything the
|
|
8
|
+
* specification has no word for gets a code prefixed `boop_`, so a reader can
|
|
9
|
+
* tell at a glance which half of the vocabulary they are looking at.
|
|
10
|
+
*/
|
|
11
|
+
/** Codes a registration or lookup refusal can carry. */
|
|
12
|
+
export type ClientRegistryErrorCode = 'invalid_client_metadata' | 'invalid_redirect_uri' | 'invalid_client' | 'invalid_request'
|
|
13
|
+
/** The row exists and is `static`; the registration surface may not touch it. */
|
|
14
|
+
| 'boop_static_client_immutable'
|
|
15
|
+
/** The registry has reached its configured row ceiling. */
|
|
16
|
+
| 'boop_registration_capacity'
|
|
17
|
+
/** A Client ID Metadata Document could not be fetched, parsed or trusted. */
|
|
18
|
+
| 'boop_metadata_document_unusable';
|
|
19
|
+
export declare class ClientRegistryError extends Error {
|
|
20
|
+
readonly code: ClientRegistryErrorCode;
|
|
21
|
+
readonly status: number;
|
|
22
|
+
constructor(code: ClientRegistryErrorCode, message: string);
|
|
23
|
+
}
|
|
24
|
+
/** Is this one of ours? Narrower than `instanceof` across module instances. */
|
|
25
|
+
export declare function isClientRegistryError(err: unknown): err is ClientRegistryError;
|
|
26
|
+
//# sourceMappingURL=errors.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,wDAAwD;AACxD,MAAM,MAAM,uBAAuB,GAC/B,yBAAyB,GACzB,sBAAsB,GACtB,gBAAgB,GAChB,iBAAiB;AACnB,iFAAiF;GAC/E,8BAA8B;AAChC,2DAA2D;GACzD,4BAA4B;AAC9B,6EAA6E;GAC3E,iCAAiC,CAAC;AAatC,qBAAa,mBAAoB,SAAQ,KAAK;IAC5C,QAAQ,CAAC,IAAI,EAAE,uBAAuB,CAAC;IACvC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;gBAEZ,IAAI,EAAE,uBAAuB,EAAE,OAAO,EAAE,MAAM;CAM3D;AAED,+EAA+E;AAC/E,wBAAgB,qBAAqB,CAAC,GAAG,EAAE,OAAO,GAAG,GAAG,IAAI,mBAAmB,CAO9E"}
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* The registry's refusals, as typed errors carrying an RFC-shaped code.
|
|
4
|
+
*
|
|
5
|
+
* The code is what the controller writes into an OAuth error body, so it is
|
|
6
|
+
* chosen from the specification rather than invented: RFC 7591 §3.2.2 defines
|
|
7
|
+
* `invalid_client_metadata` and `invalid_redirect_uri` for registration, and
|
|
8
|
+
* RFC 6749 §5.2 defines `invalid_client` and `invalid_request`. Everything the
|
|
9
|
+
* specification has no word for gets a code prefixed `boop_`, so a reader can
|
|
10
|
+
* tell at a glance which half of the vocabulary they are looking at.
|
|
11
|
+
*/
|
|
12
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
13
|
+
exports.ClientRegistryError = void 0;
|
|
14
|
+
exports.isClientRegistryError = isClientRegistryError;
|
|
15
|
+
/** HTTP status a controller should answer a given code with. */
|
|
16
|
+
const STATUS_BY_CODE = {
|
|
17
|
+
invalid_client_metadata: 400,
|
|
18
|
+
invalid_redirect_uri: 400,
|
|
19
|
+
invalid_client: 400,
|
|
20
|
+
invalid_request: 400,
|
|
21
|
+
boop_static_client_immutable: 403,
|
|
22
|
+
boop_registration_capacity: 503,
|
|
23
|
+
boop_metadata_document_unusable: 400,
|
|
24
|
+
};
|
|
25
|
+
class ClientRegistryError extends Error {
|
|
26
|
+
constructor(code, message) {
|
|
27
|
+
super(message);
|
|
28
|
+
this.name = 'ClientRegistryError';
|
|
29
|
+
this.code = code;
|
|
30
|
+
this.status = STATUS_BY_CODE[code];
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
exports.ClientRegistryError = ClientRegistryError;
|
|
34
|
+
/** Is this one of ours? Narrower than `instanceof` across module instances. */
|
|
35
|
+
function isClientRegistryError(err) {
|
|
36
|
+
return (typeof err === 'object' &&
|
|
37
|
+
err !== null &&
|
|
38
|
+
err.name === 'ClientRegistryError' &&
|
|
39
|
+
typeof err.code === 'string');
|
|
40
|
+
}
|
|
41
|
+
//# sourceMappingURL=errors.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"errors.js","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":";AAAA;;;;;;;;;GASG;;;AAuCH,sDAOC;AA/BD,gEAAgE;AAChE,MAAM,cAAc,GAA4C;IAC9D,uBAAuB,EAAE,GAAG;IAC5B,oBAAoB,EAAE,GAAG;IACzB,cAAc,EAAE,GAAG;IACnB,eAAe,EAAE,GAAG;IACpB,4BAA4B,EAAE,GAAG;IACjC,0BAA0B,EAAE,GAAG;IAC/B,+BAA+B,EAAE,GAAG;CACrC,CAAC;AAEF,MAAa,mBAAoB,SAAQ,KAAK;IAI5C,YAAY,IAA6B,EAAE,OAAe;QACxD,KAAK,CAAC,OAAO,CAAC,CAAC;QACf,IAAI,CAAC,IAAI,GAAG,qBAAqB,CAAC;QAClC,IAAI,CAAC,IAAI,GAAG,IAAI,CAAC;QACjB,IAAI,CAAC,MAAM,GAAG,cAAc,CAAC,IAAI,CAAC,CAAC;IACrC,CAAC;CACF;AAVD,kDAUC;AAED,+EAA+E;AAC/E,SAAgB,qBAAqB,CAAC,GAAY;IAChD,OAAO,CACL,OAAO,GAAG,KAAK,QAAQ;QACvB,GAAG,KAAK,IAAI;QACX,GAA0B,CAAC,IAAI,KAAK,qBAAqB;QAC1D,OAAQ,GAA0B,CAAC,IAAI,KAAK,QAAQ,CACrD,CAAC;AACJ,CAAC"}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { OAuthGrantRecord } from './grant-service.js';
|
|
2
|
+
/**
|
|
3
|
+
* The Express request fields this capability reads and writes.
|
|
4
|
+
*
|
|
5
|
+
* Declared here rather than in the file that writes them so that both the
|
|
6
|
+
* consent controller (which reads `req.user`) and the bearer middleware (which
|
|
7
|
+
* writes `req.oauthGrant`) get the same declarations without importing each
|
|
8
|
+
* other, and so a consuming application picks them up by importing the package.
|
|
9
|
+
*
|
|
10
|
+
* ## Why each of these is a *re*-declaration, and why that is safe
|
|
11
|
+
*
|
|
12
|
+
* cap-users declares `user`, `sessionToken` and `authStrategy` in its
|
|
13
|
+
* scaffolded `src/types/express.ts`, which exists only inside a generated
|
|
14
|
+
* application — this package has to type-check without one. Global interface
|
|
15
|
+
* declarations merge, so both files can describe the same field as long as they
|
|
16
|
+
* describe it **identically**; TypeScript reports TS2717 the moment two
|
|
17
|
+
* declarations of one property disagree about its type.
|
|
18
|
+
*
|
|
19
|
+
* So: `authStrategy` is `string` here, exactly as cap-users widened it in Slice
|
|
20
|
+
* 0 — not the `'api-key'` literal it used to be, which is the whole point of
|
|
21
|
+
* the widening. And `user` hangs off `Express.User` rather than naming a
|
|
22
|
+
* concrete type, for the reason cap-users' own file explains at length:
|
|
23
|
+
* `@types/passport` types `Request['user']` as `Express.User`, so any capability
|
|
24
|
+
* that named a different type would collide with it and break every handler in
|
|
25
|
+
* the application. Extending the empty interface is what lets three packages
|
|
26
|
+
* describe one field and still agree.
|
|
27
|
+
*/
|
|
28
|
+
declare global {
|
|
29
|
+
namespace Express {
|
|
30
|
+
interface User {
|
|
31
|
+
}
|
|
32
|
+
interface Request {
|
|
33
|
+
/** Set by cap-users' cookie resolver or by this package's bearer path. */
|
|
34
|
+
user?: User;
|
|
35
|
+
/** The raw credential this request presented. cap-users' field. */
|
|
36
|
+
sessionToken?: string;
|
|
37
|
+
/**
|
|
38
|
+
* Which door this request came through. `'oauth'` for this package's
|
|
39
|
+
* bearer path — never `'api-key'`, which is the literal `requireApiKey`
|
|
40
|
+
* gates on, and which an OAuth access token must fail by construction.
|
|
41
|
+
*/
|
|
42
|
+
authStrategy?: string;
|
|
43
|
+
/**
|
|
44
|
+
* The grant behind this request's OAuth access token — written by
|
|
45
|
+
* `createOAuthBearerMiddleware` and by nothing else.
|
|
46
|
+
*
|
|
47
|
+
* It is the **scope authority**: the token carries no scope copy, so a
|
|
48
|
+
* grant narrowed a moment ago binds this request. Present only when
|
|
49
|
+
* `authStrategy === 'oauth'`.
|
|
50
|
+
*/
|
|
51
|
+
oauthGrant?: OAuthGrantRecord;
|
|
52
|
+
/**
|
|
53
|
+
* The `WWW-Authenticate` challenge this deployment sends on a 401 —
|
|
54
|
+
* written by `createOAuthBearerMiddleware` and read by `requireOAuth`.
|
|
55
|
+
*
|
|
56
|
+
* It exists so the resolver and the gate cannot be configured with
|
|
57
|
+
* different challenges. Set on every request the resolver sees, including
|
|
58
|
+
* the ones it lets through unauthenticated, because those are exactly the
|
|
59
|
+
* ones the gate has to refuse.
|
|
60
|
+
*/
|
|
61
|
+
oauthChallenge?: string;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
export {};
|
|
66
|
+
//# sourceMappingURL=express.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"express.d.ts","sourceRoot":"","sources":["../src/express.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,oBAAoB,CAAC;AAE3D;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,OAAO,CAAC,MAAM,CAAC;IAEb,UAAU,OAAO,CAAC;QAKhB,UAAU,IAAI;SAAG;QAEjB,UAAU,OAAO;YACf,0EAA0E;YAC1E,IAAI,CAAC,EAAE,IAAI,CAAC;YACZ,mEAAmE;YACnE,YAAY,CAAC,EAAE,MAAM,CAAC;YACtB;;;;eAIG;YACH,YAAY,CAAC,EAAE,MAAM,CAAC;YACtB;;;;;;;eAOG;YACH,UAAU,CAAC,EAAE,gBAAgB,CAAC;YAC9B;;;;;;;;eAQG;YACH,cAAc,CAAC,EAAE,MAAM,CAAC;SACzB;KACF;CACF;AAED,OAAO,EAAE,CAAC"}
|
package/dist/express.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"express.js","sourceRoot":"","sources":["../src/express.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import type { PropertyStore } from './user-store.js';
|
|
2
|
+
/**
|
|
3
|
+
* `oauth-grants` — the record of what a person approved, for which client.
|
|
4
|
+
*
|
|
5
|
+
* ## The grant is the scope authority, and the token carries no copy
|
|
6
|
+
*
|
|
7
|
+
* The spec says a minted token "inherits" the code's approved scopes, and
|
|
8
|
+
* separately that the resource server reads scopes from `oauth-grants`. If both
|
|
9
|
+
* were true there would be two answers and they would eventually disagree. The
|
|
10
|
+
* plan settles it: the code's `scopes` are written **into the grant** at
|
|
11
|
+
* redemption, and the guard reads the grant on every request. A token minted
|
|
12
|
+
* under a wider set is bound by the grant's current, narrower one on its very
|
|
13
|
+
* next call — no revocation, no new mechanism, and a test says so.
|
|
14
|
+
*
|
|
15
|
+
* RFC 8707 `resource` is the exception and stays on the token: an audience is a
|
|
16
|
+
* property of a credential, not of a relationship, which is exactly why a
|
|
17
|
+
* cautious client can ask for a narrow token against an unchanged grant.
|
|
18
|
+
*
|
|
19
|
+
* ## Re-consent replaces, it does not union
|
|
20
|
+
*
|
|
21
|
+
* The screen showed the user a list and they approved that list. A union would
|
|
22
|
+
* grant scopes the screen never showed, which is the one thing a consent screen
|
|
23
|
+
* must never do. Replacement also gives a downgrade path, which a union has no
|
|
24
|
+
* way to express.
|
|
25
|
+
*
|
|
26
|
+
* ## Nothing in here is a credential
|
|
27
|
+
*
|
|
28
|
+
* Only `credentials` is on cap-users' sensitive-property list, so this property
|
|
29
|
+
* is readable by default. Client ids, scopes and timestamps belong here. Token
|
|
30
|
+
* values, raw codes and PKCE verifiers do not, and stay in identity rows as
|
|
31
|
+
* hashes. `refresh_key` is the one field that looks like an exception and is
|
|
32
|
+
* not: it is the *stored* identity key, which is already a SHA-256 digest, and
|
|
33
|
+
* it is the same value `user_identities.key` holds. The credentials-at-rest
|
|
34
|
+
* test asserts against the raw values, and this passes it.
|
|
35
|
+
*/
|
|
36
|
+
/** The property name. One place, because two spellings is one bug. */
|
|
37
|
+
export declare const OAUTH_GRANTS_PROPERTY = "oauth-grants";
|
|
38
|
+
/** Tags the blob's shape, the way cap-users tags its own property schemas. */
|
|
39
|
+
export declare const OAUTH_GRANTS_INFO_SCHEMA = "boop.oauth-server.grants.v1";
|
|
40
|
+
export interface OAuthGrantRecord {
|
|
41
|
+
/** Stable, non-secret handle. What the connected-clients screen links to. */
|
|
42
|
+
grant_id: string;
|
|
43
|
+
/** What the client presents as `client_id` — a URL, for a CIMD client. */
|
|
44
|
+
client_id: string;
|
|
45
|
+
/** The token prefix. The join between a presented token and this record. */
|
|
46
|
+
client_public_id: string;
|
|
47
|
+
/** The current approved set. Replaced wholesale on re-consent. */
|
|
48
|
+
scopes: string[];
|
|
49
|
+
/** RFC 8707, as last requested. Informational here; the token is authority. */
|
|
50
|
+
resource: string | null;
|
|
51
|
+
/**
|
|
52
|
+
* The identity key of the grant's **current** refresh token, or `null`.
|
|
53
|
+
*
|
|
54
|
+
* This is what makes reuse of a rotated refresh token detectable. A rotated
|
|
55
|
+
* token's identity row is deliberately left *active* so that presenting it
|
|
56
|
+
* still resolves to a user and therefore to this grant; the row grants
|
|
57
|
+
* nothing, because the key no longer matches this field. See
|
|
58
|
+
* `token-controller.ts` for the whole argument.
|
|
59
|
+
*/
|
|
60
|
+
refresh_key: string | null;
|
|
61
|
+
created_at: string;
|
|
62
|
+
updated_at: string;
|
|
63
|
+
/** Soft revocation. A grant is never deleted; the history is the point. */
|
|
64
|
+
ended_at: string | null;
|
|
65
|
+
ended_reason: string | null;
|
|
66
|
+
}
|
|
67
|
+
export interface ApproveGrantInput {
|
|
68
|
+
clientId: string;
|
|
69
|
+
clientPublicId: string;
|
|
70
|
+
scopes: string[];
|
|
71
|
+
resource?: string | null;
|
|
72
|
+
}
|
|
73
|
+
export interface GrantsSnapshot {
|
|
74
|
+
grants: OAuthGrantRecord[];
|
|
75
|
+
revision: number | undefined;
|
|
76
|
+
}
|
|
77
|
+
export interface GrantService {
|
|
78
|
+
/** The whole property, with the revision a write has to quote back. */
|
|
79
|
+
read(userId: number): Promise<GrantsSnapshot>;
|
|
80
|
+
/** Write the whole array under `expectedRevision`. Refused if stale. */
|
|
81
|
+
write(userId: number, grants: OAuthGrantRecord[], revision: number | undefined): Promise<void>;
|
|
82
|
+
/** Every grant, ended ones included — the connected-clients screen's read. */
|
|
83
|
+
list(userId: number): Promise<OAuthGrantRecord[]>;
|
|
84
|
+
/** The live grant for this client, if there is one. */
|
|
85
|
+
findActive(userId: number, clientPublicId: string): Promise<OAuthGrantRecord | null>;
|
|
86
|
+
findById(userId: number, grantId: string): Promise<OAuthGrantRecord | null>;
|
|
87
|
+
/** Create or re-consent. Replaces the scope set; never unions it. */
|
|
88
|
+
approve(userId: number, input: ApproveGrantInput): Promise<OAuthGrantRecord>;
|
|
89
|
+
/** Record the rotation. The old key stops being the grant's current one. */
|
|
90
|
+
setRefreshKey(userId: number, grantId: string, refreshKey: string | null): Promise<void>;
|
|
91
|
+
/** Soft revocation, with the reason that will be read back months later. */
|
|
92
|
+
end(userId: number, grantId: string, reason: string): Promise<OAuthGrantRecord | null>;
|
|
93
|
+
}
|
|
94
|
+
export interface GrantServiceOptions {
|
|
95
|
+
propertyStore: PropertyStore;
|
|
96
|
+
/** Injectable clock, so a test can pin `created_at`. */
|
|
97
|
+
now?: () => Date;
|
|
98
|
+
}
|
|
99
|
+
/** Is this grant live? One predicate, so the guard and the screen agree. */
|
|
100
|
+
export declare function isGrantActive(grant: OAuthGrantRecord): boolean;
|
|
101
|
+
export declare function createGrantService(options: GrantServiceOptions): GrantService;
|
|
102
|
+
//# sourceMappingURL=grant-service.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"grant-service.d.ts","sourceRoot":"","sources":["../src/grant-service.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC;AAErD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiCG;AAEH,sEAAsE;AACtE,eAAO,MAAM,qBAAqB,iBAAiB,CAAC;AAEpD,8EAA8E;AAC9E,eAAO,MAAM,wBAAwB,gCAAgC,CAAC;AAEtE,MAAM,WAAW,gBAAgB;IAC/B,6EAA6E;IAC7E,QAAQ,EAAE,MAAM,CAAC;IACjB,0EAA0E;IAC1E,SAAS,EAAE,MAAM,CAAC;IAClB,4EAA4E;IAC5E,gBAAgB,EAAE,MAAM,CAAC;IACzB,kEAAkE;IAClE,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,+EAA+E;IAC/E,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB;;;;;;;;OAQG;IACH,WAAW,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,UAAU,EAAE,MAAM,CAAC;IACnB,UAAU,EAAE,MAAM,CAAC;IACnB,2EAA2E;IAC3E,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC;CAC7B;AAED,MAAM,WAAW,iBAAiB;IAChC,QAAQ,EAAE,MAAM,CAAC;IACjB,cAAc,EAAE,MAAM,CAAC;IACvB,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CAC1B;AAED,MAAM,WAAW,cAAc;IAC7B,MAAM,EAAE,gBAAgB,EAAE,CAAC;IAC3B,QAAQ,EAAE,MAAM,GAAG,SAAS,CAAC;CAC9B;AAED,MAAM,WAAW,YAAY;IAC3B,uEAAuE;IACvE,IAAI,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,cAAc,CAAC,CAAC;IAC9C,wEAAwE;IACxE,KAAK,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,gBAAgB,EAAE,EAAE,QAAQ,EAAE,MAAM,GAAG,SAAS,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC/F,8EAA8E;IAC9E,IAAI,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,gBAAgB,EAAE,CAAC,CAAC;IAClD,uDAAuD;IACvD,UAAU,CAAC,MAAM,EAAE,MAAM,EAAE,cAAc,EAAE,MAAM,GAAG,OAAO,CAAC,gBAAgB,GAAG,IAAI,CAAC,CAAC;IACrF,QAAQ,CAAC,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,gBAAgB,GAAG,IAAI,CAAC,CAAC;IAC5E,qEAAqE;IACrE,OAAO,CAAC,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,iBAAiB,GAAG,OAAO,CAAC,gBAAgB,CAAC,CAAC;IAC7E,4EAA4E;IAC5E,aAAa,CAAC,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACzF,4EAA4E;IAC5E,GAAG,CAAC,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,gBAAgB,GAAG,IAAI,CAAC,CAAC;CACxF;AAED,MAAM,WAAW,mBAAmB;IAClC,aAAa,EAAE,aAAa,CAAC;IAC7B,wDAAwD;IACxD,GAAG,CAAC,EAAE,MAAM,IAAI,CAAC;CAClB;AAED,4EAA4E;AAC5E,wBAAgB,aAAa,CAAC,KAAK,EAAE,gBAAgB,GAAG,OAAO,CAE9D;AAED,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,mBAAmB,GAAG,YAAY,CAoH7E"}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.OAUTH_GRANTS_INFO_SCHEMA = exports.OAUTH_GRANTS_PROPERTY = void 0;
|
|
4
|
+
exports.isGrantActive = isGrantActive;
|
|
5
|
+
exports.createGrantService = createGrantService;
|
|
6
|
+
const node_crypto_1 = require("node:crypto");
|
|
7
|
+
const property_array_js_1 = require("./property-array.js");
|
|
8
|
+
/**
|
|
9
|
+
* `oauth-grants` — the record of what a person approved, for which client.
|
|
10
|
+
*
|
|
11
|
+
* ## The grant is the scope authority, and the token carries no copy
|
|
12
|
+
*
|
|
13
|
+
* The spec says a minted token "inherits" the code's approved scopes, and
|
|
14
|
+
* separately that the resource server reads scopes from `oauth-grants`. If both
|
|
15
|
+
* were true there would be two answers and they would eventually disagree. The
|
|
16
|
+
* plan settles it: the code's `scopes` are written **into the grant** at
|
|
17
|
+
* redemption, and the guard reads the grant on every request. A token minted
|
|
18
|
+
* under a wider set is bound by the grant's current, narrower one on its very
|
|
19
|
+
* next call — no revocation, no new mechanism, and a test says so.
|
|
20
|
+
*
|
|
21
|
+
* RFC 8707 `resource` is the exception and stays on the token: an audience is a
|
|
22
|
+
* property of a credential, not of a relationship, which is exactly why a
|
|
23
|
+
* cautious client can ask for a narrow token against an unchanged grant.
|
|
24
|
+
*
|
|
25
|
+
* ## Re-consent replaces, it does not union
|
|
26
|
+
*
|
|
27
|
+
* The screen showed the user a list and they approved that list. A union would
|
|
28
|
+
* grant scopes the screen never showed, which is the one thing a consent screen
|
|
29
|
+
* must never do. Replacement also gives a downgrade path, which a union has no
|
|
30
|
+
* way to express.
|
|
31
|
+
*
|
|
32
|
+
* ## Nothing in here is a credential
|
|
33
|
+
*
|
|
34
|
+
* Only `credentials` is on cap-users' sensitive-property list, so this property
|
|
35
|
+
* is readable by default. Client ids, scopes and timestamps belong here. Token
|
|
36
|
+
* values, raw codes and PKCE verifiers do not, and stay in identity rows as
|
|
37
|
+
* hashes. `refresh_key` is the one field that looks like an exception and is
|
|
38
|
+
* not: it is the *stored* identity key, which is already a SHA-256 digest, and
|
|
39
|
+
* it is the same value `user_identities.key` holds. The credentials-at-rest
|
|
40
|
+
* test asserts against the raw values, and this passes it.
|
|
41
|
+
*/
|
|
42
|
+
/** The property name. One place, because two spellings is one bug. */
|
|
43
|
+
exports.OAUTH_GRANTS_PROPERTY = 'oauth-grants';
|
|
44
|
+
/** Tags the blob's shape, the way cap-users tags its own property schemas. */
|
|
45
|
+
exports.OAUTH_GRANTS_INFO_SCHEMA = 'boop.oauth-server.grants.v1';
|
|
46
|
+
/** Is this grant live? One predicate, so the guard and the screen agree. */
|
|
47
|
+
function isGrantActive(grant) {
|
|
48
|
+
return grant.ended_at === null;
|
|
49
|
+
}
|
|
50
|
+
function createGrantService(options) {
|
|
51
|
+
const { propertyStore } = options;
|
|
52
|
+
const now = options.now ?? (() => new Date());
|
|
53
|
+
async function read(userId) {
|
|
54
|
+
const snapshot = await (0, property_array_js_1.readArray)(propertyStore, userId, exports.OAUTH_GRANTS_PROPERTY);
|
|
55
|
+
return { grants: snapshot.entries, revision: snapshot.revision };
|
|
56
|
+
}
|
|
57
|
+
async function write(userId, grants, revision) {
|
|
58
|
+
await (0, property_array_js_1.writeArray)(propertyStore, userId, exports.OAUTH_GRANTS_PROPERTY, grants, { revision }, {
|
|
59
|
+
infoSchema: exports.OAUTH_GRANTS_INFO_SCHEMA,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
return {
|
|
63
|
+
read,
|
|
64
|
+
write,
|
|
65
|
+
async list(userId) {
|
|
66
|
+
return (await read(userId)).grants;
|
|
67
|
+
},
|
|
68
|
+
async findActive(userId, clientPublicId) {
|
|
69
|
+
const { grants } = await read(userId);
|
|
70
|
+
return (grants.find((g) => g.client_public_id === clientPublicId && isGrantActive(g)) ?? null);
|
|
71
|
+
},
|
|
72
|
+
async findById(userId, grantId) {
|
|
73
|
+
const { grants } = await read(userId);
|
|
74
|
+
return grants.find((g) => g.grant_id === grantId) ?? null;
|
|
75
|
+
},
|
|
76
|
+
async approve(userId, input) {
|
|
77
|
+
const { grants, revision } = await read(userId);
|
|
78
|
+
const timestamp = now().toISOString();
|
|
79
|
+
const index = grants.findIndex((g) => g.client_public_id === input.clientPublicId && isGrantActive(g));
|
|
80
|
+
// Re-consent REPLACES the scope set. See the header: a union would grant
|
|
81
|
+
// scopes the screen the user just read never showed them.
|
|
82
|
+
const grant = index >= 0
|
|
83
|
+
? {
|
|
84
|
+
...grants[index],
|
|
85
|
+
client_id: input.clientId,
|
|
86
|
+
scopes: [...input.scopes],
|
|
87
|
+
resource: input.resource ?? null,
|
|
88
|
+
updated_at: timestamp,
|
|
89
|
+
}
|
|
90
|
+
: {
|
|
91
|
+
grant_id: (0, node_crypto_1.randomUUID)(),
|
|
92
|
+
client_id: input.clientId,
|
|
93
|
+
client_public_id: input.clientPublicId,
|
|
94
|
+
scopes: [...input.scopes],
|
|
95
|
+
resource: input.resource ?? null,
|
|
96
|
+
refresh_key: null,
|
|
97
|
+
created_at: timestamp,
|
|
98
|
+
updated_at: timestamp,
|
|
99
|
+
ended_at: null,
|
|
100
|
+
ended_reason: null,
|
|
101
|
+
};
|
|
102
|
+
const next = index >= 0 ? grants.map((g, i) => (i === index ? grant : g)) : [...grants, grant];
|
|
103
|
+
await write(userId, next, revision);
|
|
104
|
+
return grant;
|
|
105
|
+
},
|
|
106
|
+
async setRefreshKey(userId, grantId, refreshKey) {
|
|
107
|
+
const { grants, revision } = await read(userId);
|
|
108
|
+
const timestamp = now().toISOString();
|
|
109
|
+
const next = grants.map((g) => g.grant_id === grantId ? { ...g, refresh_key: refreshKey, updated_at: timestamp } : g);
|
|
110
|
+
await write(userId, next, revision);
|
|
111
|
+
},
|
|
112
|
+
async end(userId, grantId, reason) {
|
|
113
|
+
const { grants, revision } = await read(userId);
|
|
114
|
+
const target = grants.find((g) => g.grant_id === grantId);
|
|
115
|
+
if (!target)
|
|
116
|
+
return null;
|
|
117
|
+
// Already ended? Keep the first reason and the first timestamp: the
|
|
118
|
+
// interesting fact is why it ended, and a second revoke overwriting
|
|
119
|
+
// "refresh_reuse" with "user_revoked" would erase the theft signal.
|
|
120
|
+
if (!isGrantActive(target))
|
|
121
|
+
return target;
|
|
122
|
+
const timestamp = now().toISOString();
|
|
123
|
+
const ended = {
|
|
124
|
+
...target,
|
|
125
|
+
// A revoked grant's refresh token is gone, and leaving the key here
|
|
126
|
+
// would make a later reuse read as a rotation rather than as reuse.
|
|
127
|
+
refresh_key: null,
|
|
128
|
+
ended_at: timestamp,
|
|
129
|
+
ended_reason: reason,
|
|
130
|
+
updated_at: timestamp,
|
|
131
|
+
};
|
|
132
|
+
await write(userId, grants.map((g) => (g.grant_id === grantId ? ended : g)), revision);
|
|
133
|
+
return ended;
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
//# sourceMappingURL=grant-service.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"grant-service.js","sourceRoot":"","sources":["../src/grant-service.ts"],"names":[],"mappings":"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"}
|