@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
package/capability.json
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
"src/services/oauth-server.service.ts",
|
|
14
14
|
"src/middleware/oauth-server.ts",
|
|
15
15
|
"src/scripts/seed-oauth-clients.ts",
|
|
16
|
+
"src/scripts/prune-oauth-clients.ts",
|
|
16
17
|
"views/oauth-consent.hbs",
|
|
17
18
|
"views/oauth-consent-error.hbs",
|
|
18
19
|
"views/oauth-connected-clients.hbs",
|
|
@@ -32,7 +33,7 @@
|
|
|
32
33
|
"phase": "preRoutes",
|
|
33
34
|
"order": 13,
|
|
34
35
|
"code": "app.use(oauthDiscoveryRoutes);",
|
|
35
|
-
"description": "serve /.well-known/oauth-authorization-server and /.well-known/oauth-protected-resource \u2014 mounted outside /api, and first among this app's capability mounts (the free slot between cap-web's requestLogger at 12 and cap-users' cookieParser at 14), because a client reads these before it holds any credential and must never meet a 401 here. The order sequences capability mounts against each other; an application whose own middleware sits above the @boopkit:preRoutes marker still runs that middleware first, so check that these two answer 200 to a caller holding nothing \u2014 which is what the verify step below does"
|
|
36
|
+
"description": "serve /.well-known/oauth-authorization-server and /.well-known/oauth-protected-resource (also at the RFC 9728 path-suffixed location, e.g. /.well-known/oauth-protected-resource/api/mcp, when OAUTH_RESOURCE_URL has a path) \u2014 mounted outside /api, and first among this app's capability mounts (the free slot between cap-web's requestLogger at 12 and cap-users' cookieParser at 14), because a client reads these before it holds any credential and must never meet a 401 here. The order sequences capability mounts against each other; an application whose own middleware sits above the @boopkit:preRoutes marker still runs that middleware first, so check that these two answer 200 to a caller holding nothing \u2014 which is what the verify step below does"
|
|
36
37
|
},
|
|
37
38
|
{
|
|
38
39
|
"phase": "preRoutes",
|
|
@@ -45,6 +46,10 @@
|
|
|
45
46
|
"oauth:seed-clients": {
|
|
46
47
|
"cmd": "tsx src/scripts/seed-oauth-clients.ts",
|
|
47
48
|
"description": "Register the static OAuth clients this deployment trusts. Idempotent."
|
|
49
|
+
},
|
|
50
|
+
"oauth:prune-clients": {
|
|
51
|
+
"cmd": "tsx src/scripts/prune-oauth-clients.ts",
|
|
52
|
+
"description": "Delete abandoned client registrations \u2014 unauthenticated rows over 30 days old that nobody has connected. Schedule it daily."
|
|
48
53
|
}
|
|
49
54
|
},
|
|
50
55
|
"env": [
|
|
@@ -78,7 +83,7 @@
|
|
|
78
83
|
},
|
|
79
84
|
{
|
|
80
85
|
"name": "OAUTH_MAX_REGISTERED_CLIENTS",
|
|
81
|
-
"description": "Ceiling on rows created by open dynamic registration (default: 1000). /oauth/register is unauthenticated by design, so this is the hard bound on what an abusive caller can write; the per-IP rate limit only makes reaching it slow.",
|
|
86
|
+
"description": "Ceiling on rows created by open dynamic registration (default: 1000). /oauth/register is unauthenticated by design, so this is the hard bound on what an abusive caller can write; the per-IP rate limit only makes reaching it slow. It is a bound, not a collector \u2014 schedule npm run oauth:prune-clients, or rows nobody ever connected hold their slots forever and the ceiling eventually refuses legitimate registrations.",
|
|
82
87
|
"default": "1000",
|
|
83
88
|
"required": "runtime"
|
|
84
89
|
},
|
|
@@ -88,6 +93,12 @@
|
|
|
88
93
|
"default": "10",
|
|
89
94
|
"required": "runtime"
|
|
90
95
|
},
|
|
96
|
+
{
|
|
97
|
+
"name": "OAUTH_METADATA_RESOLUTION_RATE_LIMIT",
|
|
98
|
+
"description": "First-sight Client ID Metadata Document resolutions allowed per hour, per source address (default: 10). GET /oauth/authorize registers a CIMD client the first time it sees one, which is an unauthenticated write and an outbound fetch, so it carries the same per-IP limit /oauth/register does. Only a client_id with no row yet is charged, so an ordinary visitor to a connector this server already knows never spends any of it.",
|
|
99
|
+
"default": "10",
|
|
100
|
+
"required": "runtime"
|
|
101
|
+
},
|
|
91
102
|
{
|
|
92
103
|
"name": "OAUTH_DEVICE_CODE_ENABLED",
|
|
93
104
|
"description": "Set to the string \"true\" to enable the RFC 8628 device-code grant. Off by default. When off the endpoints are not mounted at all and the grant type is absent from the discovery metadata, rather than advertised against an endpoint that answers 404 \u2014 a client that read the metadata would treat that 404 as a server fault rather than as an unsupported feature. Not yet implemented; declared here so the flag has one name.",
|
|
@@ -112,6 +123,7 @@
|
|
|
112
123
|
"Add styles/oauth-server.css to your stylesheet entry point and rebuild Tailwind, or the consent screen and the connected-clients screen render unstyled.",
|
|
113
124
|
"Protect the surface you are exposing to connectors: app.use('/api/mcp', resolveOAuthBearer, requireOAuth('profile'), yourRoutes). Both are exported from src/middleware/oauth-server.ts, which also mounts GET /api/oauth/whoami as a worked example you can curl with a fresh access token. Do not mount resolveOAuthBearer on a router that already carries cap-users' resolveBearer \u2014 pick the door that router's callers use.",
|
|
114
125
|
"Edit src/scripts/seed-oauth-clients.ts with the clients you want to trust by name, then run npm run oauth:seed-clients. Clients that register themselves \u2014 anything supporting RFC 7591 or publishing a Client ID Metadata Document \u2014 need no entry there.",
|
|
126
|
+
"Schedule npm run oauth:prune-clients to run daily \u2014 Heroku Scheduler, cron, or a cap-background-jobs consumer. It deletes unauthenticated registrations over 30 days old that nobody has connected. Unscheduled, OAUTH_MAX_REGISTERED_CLIENTS is the only bound on registry growth, and a deployment that reaches the ceiling starts refusing legitimate registrations.",
|
|
115
127
|
"Decide whether open registration suits you. POST /oauth/register is unauthenticated by design, which is what the Claude ecosystem expects; if you would rather only vouch for clients yourself, remove the preRoutes hook that mounts oauthServerRoutes and keep the seed script.",
|
|
116
128
|
"Link to /connections from wherever your account settings live. The screen lists what a user has connected, states that a connected application reaches every account they belong to, and sends a disconnect through its own confirmation page. It is mounted already and gated by requireAuth; what it needs from you is a way in."
|
|
117
129
|
],
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type TokenService } from './token-service.js';
|
|
2
|
-
import type
|
|
2
|
+
import { type PropertyStore } from './user-store.js';
|
|
3
3
|
/**
|
|
4
4
|
* The authorization code: one identity row, one property entry.
|
|
5
5
|
*
|
|
@@ -14,14 +14,34 @@ import type { PropertyStore } from './user-store.js';
|
|
|
14
14
|
* is a SHA-256 digest, which is what the identity row already stores; the raw
|
|
15
15
|
* code exists only in the redirect the browser followed, and nowhere at rest.
|
|
16
16
|
*
|
|
17
|
-
* ## The entry survives redemption, and
|
|
17
|
+
* ## The entry survives redemption, and the identity row stays live
|
|
18
18
|
*
|
|
19
|
-
* Redeeming stamps `redeemed_at` on the entry
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
19
|
+
* Redeeming stamps `redeemed_at` on the entry. It does not delete the entry,
|
|
20
|
+
* and — since boop-hnbv — it does not end-date the identity row either. Both
|
|
21
|
+
* halves of that are one decision, and the second half is the surprising one.
|
|
22
|
+
*
|
|
23
|
+
* `redeemed_at` is the single-use gate. The row is what carries the **user id**,
|
|
24
|
+
* and cap-users' `(domain, key)` lookup is active-only, so end-dating the row at
|
|
25
|
+
* redemption threw the owner away: a replay arriving a second later resolved to
|
|
26
|
+
* nothing and could only be answered `unknown_code`. The retained entry was
|
|
27
|
+
* never read, because there was no user to read it under — which made the
|
|
28
|
+
* replay-detection apparatus this data model was shaped around dead code.
|
|
29
|
+
*
|
|
30
|
+
* Leaving the row live until its own 120-second expiry keeps the owner
|
|
31
|
+
* reachable, so a replay resolves, finds the stamped entry, and is answered
|
|
32
|
+
* `already_redeemed` **with the user id and the client it was issued to** — which
|
|
33
|
+
* is what the token endpoint needs to do what RFC 6749 §4.1.2 and §10.5 require:
|
|
34
|
+
* revoke the tokens the first redemption minted. Single-use is unchanged; it is
|
|
35
|
+
* enforced by `redeemed_at` now rather than by the tombstone.
|
|
36
|
+
*
|
|
37
|
+
* The live row grants nothing on its own. Every path to it goes through this
|
|
38
|
+
* service, and this service refuses a stamped entry. It is the same shape the
|
|
39
|
+
* refresh-token rotation uses one domain over, and for the same reason: a
|
|
40
|
+
* credential whose row is gone is indistinguishable from one that never existed,
|
|
41
|
+
* and telling those apart is the entire point.
|
|
42
|
+
*
|
|
43
|
+
* The entry is dropped once it has expired, on the next write that passes over
|
|
44
|
+
* it, and `resolve` end-dates the row the next time it reads it past expiry.
|
|
25
45
|
*
|
|
26
46
|
* ## Expiry here is `expires_at`, not the identity-row rule
|
|
27
47
|
*
|
|
@@ -45,7 +65,7 @@ export interface AuthorizationCodeRecord {
|
|
|
45
65
|
resource: string | null;
|
|
46
66
|
issued_at: string;
|
|
47
67
|
expires_at: string;
|
|
48
|
-
/** Set on redemption
|
|
68
|
+
/** Set on redemption. The single-use gate, and the record of the replay. */
|
|
49
69
|
redeemed_at: string | null;
|
|
50
70
|
}
|
|
51
71
|
/** What redemption needs to know, beyond the code itself. */
|
|
@@ -64,7 +84,15 @@ export type RedeemResult = {
|
|
|
64
84
|
} | {
|
|
65
85
|
ok: false;
|
|
66
86
|
reason: RedeemFailure;
|
|
87
|
+
/** Present whenever the code resolved to somebody — see `redeem`. */
|
|
67
88
|
userId?: number;
|
|
89
|
+
/**
|
|
90
|
+
* The client the code was ISSUED to, which is not always the one that
|
|
91
|
+
* presented it. A replay answered on a `client_id` of the attacker's
|
|
92
|
+
* choosing must still revoke the grant the code actually belongs to,
|
|
93
|
+
* and never a bystander's.
|
|
94
|
+
*/
|
|
95
|
+
clientPublicId?: string;
|
|
68
96
|
};
|
|
69
97
|
export interface IssueCodeInput {
|
|
70
98
|
userId: number;
|
|
@@ -83,8 +111,9 @@ export interface AuthorizationCodeService {
|
|
|
83
111
|
record: AuthorizationCodeRecord;
|
|
84
112
|
}>;
|
|
85
113
|
/**
|
|
86
|
-
* Redeem once.
|
|
87
|
-
* call answers `already_redeemed`
|
|
114
|
+
* Redeem once. Stamps `redeemed_at` and leaves the identity row live, so a
|
|
115
|
+
* second call answers `already_redeemed` — carrying the owner and the client,
|
|
116
|
+
* so the caller can revoke what the first redemption issued.
|
|
88
117
|
*/
|
|
89
118
|
redeem(input: RedeemCodeInput): Promise<RedeemResult>;
|
|
90
119
|
/** The entries a user currently holds. Used by the credentials-at-rest test. */
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"authorization-code-service.d.ts","sourceRoot":"","sources":["../src/authorization-code-service.ts"],"names":[],"mappings":"AACA,OAAO,EAAqB,KAAK,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAC1E,OAAO,KAAK,
|
|
1
|
+
{"version":3,"file":"authorization-code-service.d.ts","sourceRoot":"","sources":["../src/authorization-code-service.ts"],"names":[],"mappings":"AACA,OAAO,EAAqB,KAAK,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAC1E,OAAO,EAAqB,KAAK,aAAa,EAAE,MAAM,iBAAiB,CAAC;AAExE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiDG;AAEH,eAAO,MAAM,oBAAoB,gBAAgB,CAAC;AAClD,eAAO,MAAM,uBAAuB,+BAA+B,CAAC;AAEpE,MAAM,WAAW,uBAAuB;IACtC,8EAA8E;IAC9E,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;IAClB,gBAAgB,EAAE,MAAM,CAAC;IACzB,YAAY,EAAE,MAAM,CAAC;IACrB,8EAA8E;IAC9E,cAAc,EAAE,MAAM,CAAC;IACvB,qBAAqB,EAAE,MAAM,CAAC;IAC9B,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,SAAS,EAAE,MAAM,CAAC;IAClB,UAAU,EAAE,MAAM,CAAC;IACnB,4EAA4E;IAC5E,WAAW,EAAE,MAAM,GAAG,IAAI,CAAC;CAC5B;AAED,6DAA6D;AAC7D,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,MAAM,CAAC;IACjB,cAAc,EAAE,MAAM,CAAC;IACvB,WAAW,EAAE,MAAM,CAAC;CACrB;AAED,4EAA4E;AAC5E,MAAM,MAAM,aAAa,GACrB,cAAc,GACd,kBAAkB,GAClB,SAAS,GACT,iBAAiB,GACjB,uBAAuB,CAAC;AAE5B,MAAM,MAAM,YAAY,GACpB;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,uBAAuB,CAAA;CAAE,GAC7D;IACE,EAAE,EAAE,KAAK,CAAC;IACV,MAAM,EAAE,aAAa,CAAC;IACtB,qEAAqE;IACrE,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;;;;;OAKG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;CACzB,CAAC;AAEN,MAAM,WAAW,cAAc;IAC7B,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;IACjB,cAAc,EAAE,MAAM,CAAC;IACvB,WAAW,EAAE,MAAM,CAAC;IACpB,aAAa,EAAE,MAAM,CAAC;IACtB,mBAAmB,EAAE,MAAM,CAAC;IAC5B,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CAC1B;AAED,MAAM,WAAW,wBAAwB;IACvC,4EAA4E;IAC5E,KAAK,CAAC,KAAK,EAAE,cAAc,GAAG,OAAO,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,uBAAuB,CAAA;KAAE,CAAC,CAAC;IACzF;;;;OAIG;IACH,MAAM,CAAC,KAAK,EAAE,eAAe,GAAG,OAAO,CAAC,YAAY,CAAC,CAAC;IACtD,gFAAgF;IAChF,IAAI,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,uBAAuB,EAAE,CAAC,CAAC;CAC1D;AAED,MAAM,WAAW,+BAA+B;IAC9C,YAAY,EAAE,YAAY,CAAC;IAC3B,aAAa,EAAE,aAAa,CAAC;IAC7B;;;;OAIG;IACH,gBAAgB,CAAC,UAAU,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC;IACjE,GAAG,CAAC,EAAE,MAAM,IAAI,CAAC;CAClB;AAED,wBAAgB,8BAA8B,CAC5C,OAAO,EAAE,+BAA+B,GACvC,wBAAwB,CAuK1B"}
|
|
@@ -4,6 +4,7 @@ exports.OAUTH_CODES_INFO_SCHEMA = exports.OAUTH_CODES_PROPERTY = void 0;
|
|
|
4
4
|
exports.createAuthorizationCodeService = createAuthorizationCodeService;
|
|
5
5
|
const property_array_js_1 = require("./property-array.js");
|
|
6
6
|
const token_service_js_1 = require("./token-service.js");
|
|
7
|
+
const user_store_js_1 = require("./user-store.js");
|
|
7
8
|
/**
|
|
8
9
|
* The authorization code: one identity row, one property entry.
|
|
9
10
|
*
|
|
@@ -18,14 +19,34 @@ const token_service_js_1 = require("./token-service.js");
|
|
|
18
19
|
* is a SHA-256 digest, which is what the identity row already stores; the raw
|
|
19
20
|
* code exists only in the redirect the browser followed, and nowhere at rest.
|
|
20
21
|
*
|
|
21
|
-
* ## The entry survives redemption, and
|
|
22
|
+
* ## The entry survives redemption, and the identity row stays live
|
|
22
23
|
*
|
|
23
|
-
* Redeeming stamps `redeemed_at` on the entry
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
24
|
+
* Redeeming stamps `redeemed_at` on the entry. It does not delete the entry,
|
|
25
|
+
* and — since boop-hnbv — it does not end-date the identity row either. Both
|
|
26
|
+
* halves of that are one decision, and the second half is the surprising one.
|
|
27
|
+
*
|
|
28
|
+
* `redeemed_at` is the single-use gate. The row is what carries the **user id**,
|
|
29
|
+
* and cap-users' `(domain, key)` lookup is active-only, so end-dating the row at
|
|
30
|
+
* redemption threw the owner away: a replay arriving a second later resolved to
|
|
31
|
+
* nothing and could only be answered `unknown_code`. The retained entry was
|
|
32
|
+
* never read, because there was no user to read it under — which made the
|
|
33
|
+
* replay-detection apparatus this data model was shaped around dead code.
|
|
34
|
+
*
|
|
35
|
+
* Leaving the row live until its own 120-second expiry keeps the owner
|
|
36
|
+
* reachable, so a replay resolves, finds the stamped entry, and is answered
|
|
37
|
+
* `already_redeemed` **with the user id and the client it was issued to** — which
|
|
38
|
+
* is what the token endpoint needs to do what RFC 6749 §4.1.2 and §10.5 require:
|
|
39
|
+
* revoke the tokens the first redemption minted. Single-use is unchanged; it is
|
|
40
|
+
* enforced by `redeemed_at` now rather than by the tombstone.
|
|
41
|
+
*
|
|
42
|
+
* The live row grants nothing on its own. Every path to it goes through this
|
|
43
|
+
* service, and this service refuses a stamped entry. It is the same shape the
|
|
44
|
+
* refresh-token rotation uses one domain over, and for the same reason: a
|
|
45
|
+
* credential whose row is gone is indistinguishable from one that never existed,
|
|
46
|
+
* and telling those apart is the entire point.
|
|
47
|
+
*
|
|
48
|
+
* The entry is dropped once it has expired, on the next write that passes over
|
|
49
|
+
* it, and `resolve` end-dates the row the next time it reads it past expiry.
|
|
29
50
|
*
|
|
30
51
|
* ## Expiry here is `expires_at`, not the identity-row rule
|
|
31
52
|
*
|
|
@@ -71,26 +92,23 @@ function createAuthorizationCodeService(options) {
|
|
|
71
92
|
if (!identityKey)
|
|
72
93
|
return { ok: false, reason: 'unknown_code' };
|
|
73
94
|
// Resolving through the token service applies the identity row's
|
|
74
|
-
// active-only lookup
|
|
75
|
-
//
|
|
76
|
-
//
|
|
95
|
+
// active-only lookup. Redemption no longer end-dates the row, so a code
|
|
96
|
+
// redeemed a moment ago still resolves and reaches the `redeemed_at`
|
|
97
|
+
// check below — which is the whole point of the change (see the header).
|
|
77
98
|
const resolved = await tokenService.resolve(token_service_js_1.OAUTH_CODE_DOMAIN, input.code);
|
|
78
99
|
const at = now();
|
|
79
100
|
if (!resolved) {
|
|
80
|
-
// The row is gone: never issued,
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
//
|
|
84
|
-
// owner up by — cap-users' lookup is active-only and the identity
|
|
85
|
-
// boundary forbids reaching past it. RFC 6749 gives all three the same
|
|
86
|
-
// wire answer (`invalid_grant`) for the same reason: telling a caller
|
|
87
|
-
// which one it hit tells it whether the code was ever real.
|
|
101
|
+
// The row is gone: never issued, malformed, or past its 120 seconds and
|
|
102
|
+
// swept. All of them are `unknown_code`, and RFC 6749 gives all of them
|
|
103
|
+
// the same wire answer (`invalid_grant`) for the same reason — telling a
|
|
104
|
+
// caller which one it hit tells it whether the code was ever real.
|
|
88
105
|
//
|
|
89
|
-
//
|
|
90
|
-
//
|
|
91
|
-
//
|
|
92
|
-
//
|
|
93
|
-
//
|
|
106
|
+
// A replay is NOT in that list any more. Once the code's own window has
|
|
107
|
+
// closed there is no user id left to look the owner up by, so there is
|
|
108
|
+
// nothing this branch could revoke even if it wanted to; but by then the
|
|
109
|
+
// tokens the first redemption minted are the attacker's problem, not an
|
|
110
|
+
// undetected replay — a code two minutes stale is an expiry, and every
|
|
111
|
+
// replay worth catching arrives inside the window.
|
|
94
112
|
return { ok: false, reason: 'unknown_code' };
|
|
95
113
|
}
|
|
96
114
|
const snapshot = await (0, property_array_js_1.readArray)(propertyStore, resolved.userId, exports.OAUTH_CODES_PROPERTY);
|
|
@@ -100,12 +118,22 @@ function createAuthorizationCodeService(options) {
|
|
|
100
118
|
// scope set, so there is nothing to check the request against.
|
|
101
119
|
if (!record)
|
|
102
120
|
return { ok: false, reason: 'unknown_code', userId: resolved.userId };
|
|
103
|
-
//
|
|
104
|
-
//
|
|
105
|
-
//
|
|
106
|
-
//
|
|
121
|
+
// The replay branch, and the reason the row is left live. It answers every
|
|
122
|
+
// second presentation inside the code's window, whether it arrives a
|
|
123
|
+
// millisecond or a minute after the first.
|
|
124
|
+
//
|
|
125
|
+
// It is checked BEFORE the client and redirect-URI comparisons on purpose:
|
|
126
|
+
// a code that has already been spent is spent whatever the replay claims
|
|
127
|
+
// about itself, and an attacker must not be able to dodge the revocation
|
|
128
|
+
// by presenting a stolen code under some other client's name. The client
|
|
129
|
+
// reported back is the one the code was ISSUED to, for the same reason.
|
|
107
130
|
if (record.redeemed_at !== null) {
|
|
108
|
-
return {
|
|
131
|
+
return {
|
|
132
|
+
ok: false,
|
|
133
|
+
reason: 'already_redeemed',
|
|
134
|
+
userId: resolved.userId,
|
|
135
|
+
clientPublicId: record.client_public_id,
|
|
136
|
+
};
|
|
109
137
|
}
|
|
110
138
|
if (Date.parse(record.expires_at) <= at.getTime()) {
|
|
111
139
|
return { ok: false, reason: 'expired', userId: resolved.userId };
|
|
@@ -116,21 +144,45 @@ function createAuthorizationCodeService(options) {
|
|
|
116
144
|
if (!matchRedirectUri(record.redirect_uri, input.redirectUri)) {
|
|
117
145
|
return { ok: false, reason: 'redirect_uri_mismatch', userId: resolved.userId };
|
|
118
146
|
}
|
|
119
|
-
//
|
|
147
|
+
// The single-use claim, and the only one there is: `redeemed_at` on the
|
|
148
|
+
// entry, written under `expectedRevision` so a second redemption that read
|
|
149
|
+
// the same snapshot is refused rather than silently applied.
|
|
120
150
|
//
|
|
121
|
-
//
|
|
122
|
-
//
|
|
123
|
-
//
|
|
124
|
-
//
|
|
125
|
-
//
|
|
151
|
+
// With one honest caveat, since a comment here used to call this atomic
|
|
152
|
+
// without qualification. cap-services' `put` compares the revision and
|
|
153
|
+
// then updates in a separate statement, so two writers that interleave
|
|
154
|
+
// between the two can both land (boop-8fcd). That is a defect one layer
|
|
155
|
+
// down and it is not specific to codes — every property write in every
|
|
156
|
+
// capability shares it. What this code can promise is the sequential case,
|
|
157
|
+
// which is the replay an attacker can actually aim: present the code
|
|
158
|
+
// again, any time inside its window, and it is refused and the grant is
|
|
159
|
+
// revoked.
|
|
126
160
|
const redeemed = { ...record, redeemed_at: at.toISOString() };
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
161
|
+
try {
|
|
162
|
+
await (0, property_array_js_1.writeArray)(propertyStore, resolved.userId, exports.OAUTH_CODES_PROPERTY,
|
|
163
|
+
// The redeemed entry is kept until its own window closes; `live` drops
|
|
164
|
+
// only entries that have actually expired.
|
|
165
|
+
[...live(snapshot.entries, at).filter((e) => e.code_key !== identityKey), redeemed], snapshot, { infoSchema: exports.OAUTH_CODES_INFO_SCHEMA });
|
|
166
|
+
}
|
|
167
|
+
catch (err) {
|
|
168
|
+
if (!(0, user_store_js_1.isStaleWriteError)(err))
|
|
169
|
+
throw err;
|
|
170
|
+
// The dead heat: two redemptions of one code both read the entry before
|
|
171
|
+
// either wrote it, and this one lost the revision check. That is a
|
|
172
|
+
// replay like any other — the only difference is that it was detected by
|
|
173
|
+
// the write rather than by the read — so it is answered the same way,
|
|
174
|
+
// with the owner and the client, rather than thrown for the caller to
|
|
175
|
+
// translate into a bare `invalid_grant` it can do nothing about.
|
|
176
|
+
return {
|
|
177
|
+
ok: false,
|
|
178
|
+
reason: 'already_redeemed',
|
|
179
|
+
userId: resolved.userId,
|
|
180
|
+
clientPublicId: record.client_public_id,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
// No tombstone on the row. `redeemed_at` above is the single-use gate now,
|
|
184
|
+
// and the row stays live so the owner is still reachable when the replay
|
|
185
|
+
// comes — see the header.
|
|
134
186
|
return { ok: true, userId: resolved.userId, record: redeemed };
|
|
135
187
|
},
|
|
136
188
|
async list(userId) {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"authorization-code-service.js","sourceRoot":"","sources":["../src/authorization-code-service.ts"],"names":[],"mappings":";;;
|
|
1
|
+
{"version":3,"file":"authorization-code-service.js","sourceRoot":"","sources":["../src/authorization-code-service.ts"],"names":[],"mappings":";;;AA+IA,wEAyKC;AAxTD,2DAA4D;AAC5D,yDAA0E;AAC1E,mDAAwE;AAExE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiDG;AAEU,QAAA,oBAAoB,GAAG,aAAa,CAAC;AACrC,QAAA,uBAAuB,GAAG,4BAA4B,CAAC;AAuFpE,SAAgB,8BAA8B,CAC5C,OAAwC;IAExC,MAAM,EAAE,YAAY,EAAE,aAAa,EAAE,gBAAgB,EAAE,GAAG,OAAO,CAAC;IAClE,MAAM,GAAG,GAAG,OAAO,CAAC,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC,IAAI,IAAI,EAAE,CAAC,CAAC;IAE9C,wEAAwE;IACxE,SAAS,IAAI,CAAC,OAAkC,EAAE,EAAQ;QACxD,OAAO,OAAO,CAAC,MAAM,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,IAAI,CAAC,KAAK,CAAC,KAAK,CAAC,UAAU,CAAC,GAAG,EAAE,CAAC,OAAO,EAAE,CAAC,CAAC;IAChF,CAAC;IAED,OAAO;QACL,KAAK,CAAC,KAAK,CAAC,KAAK;YACf,MAAM,EAAE,GAAG,GAAG,EAAE,CAAC;YACjB,MAAM,MAAM,GAAG,MAAM,YAAY,CAAC,IAAI,CAAC,KAAK,CAAC,MAAM,EAAE,oCAAiB,EAAE,KAAK,CAAC,cAAc,CAAC,CAAC;YAE9F,MAAM,MAAM,GAA4B;gBACtC,QAAQ,EAAE,MAAM,CAAC,WAAW;gBAC5B,SAAS,EAAE,KAAK,CAAC,QAAQ;gBACzB,gBAAgB,EAAE,KAAK,CAAC,cAAc;gBACtC,YAAY,EAAE,KAAK,CAAC,WAAW;gBAC/B,cAAc,EAAE,KAAK,CAAC,aAAa;gBACnC,qBAAqB,EAAE,KAAK,CAAC,mBAAmB;gBAChD,MAAM,EAAE,CAAC,GAAG,KAAK,CAAC,MAAM,CAAC;gBACzB,QAAQ,EAAE,KAAK,CAAC,QAAQ,IAAI,IAAI;gBAChC,SAAS,EAAE,EAAE,CAAC,WAAW,EAAE;gBAC3B,sEAAsE;gBACtE,2CAA2C;gBAC3C,UAAU,EAAE,MAAM,CAAC,SAAS,CAAC,WAAW,EAAE;gBAC1C,WAAW,EAAE,IAAI;aAClB,CAAC;YAEF,MAAM,QAAQ,GAAG,MAAM,IAAA,6BAAS,EAC9B,aAAa,EACb,KAAK,CAAC,MAAM,EACZ,4BAAoB,CACrB,CAAC;YACF,MAAM,IAAA,8BAAU,EACd,aAAa,EACb,KAAK,CAAC,MAAM,EACZ,4BAAoB,EACpB,CAAC,GAAG,IAAI,CAAC,QAAQ,CAAC,OAAO,EAAE,EAAE,CAAC,EAAE,MAAM,CAAC,EACvC,QAAQ,EACR,EAAE,UAAU,EAAE,+BAAuB,EAAE,CACxC,CAAC;YAEF,OAAO,EAAE,IAAI,EAAE,MAAM,CAAC,KAAK,EAAE,MAAM,EAAE,CAAC;QACxC,CAAC;QAED,KAAK,CAAC,MAAM,CAAC,KAAK;YAChB,MAAM,WAAW,GAAG,YAAY,CAAC,cAAc,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;YAC5D,IAAI,CAAC,WAAW;gBAAE,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,cAAc,EAAE,CAAC;YAE/D,iEAAiE;YACjE,wEAAwE;YACxE,qEAAqE;YACrE,yEAAyE;YACzE,MAAM,QAAQ,GAAG,MAAM,YAAY,CAAC,OAAO,CAAC,oCAAiB,EAAE,KAAK,CAAC,IAAI,CAAC,CAAC;YAC3E,MAAM,EAAE,GAAG,GAAG,EAAE,CAAC;YAEjB,IAAI,CAAC,QAAQ,EAAE,CAAC;gBACd,wEAAwE;gBACxE,wEAAwE;gBACxE,yEAAyE;gBACzE,mEAAmE;gBACnE,EAAE;gBACF,wEAAwE;gBACxE,uEAAuE;gBACvE,yEAAyE;gBACzE,wEAAwE;gBACxE,uEAAuE;gBACvE,mDAAmD;gBACnD,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,cAAc,EAAE,CAAC;YAC/C,CAAC;YAED,MAAM,QAAQ,GAAG,MAAM,IAAA,6BAAS,EAC9B,aAAa,EACb,QAAQ,CAAC,MAAM,EACf,4BAAoB,CACrB,CAAC;YACF,MAAM,MAAM,GAAG,QAAQ,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,KAAK,CAAC,QAAQ,KAAK,WAAW,CAAC,CAAC;YAChF,yEAAyE;YACzE,yEAAyE;YACzE,+DAA+D;YAC/D,IAAI,CAAC,MAAM;gBAAE,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,cAAc,EAAE,MAAM,EAAE,QAAQ,CAAC,MAAM,EAAE,CAAC;YAEnF,2EAA2E;YAC3E,qEAAqE;YACrE,2CAA2C;YAC3C,EAAE;YACF,2EAA2E;YAC3E,yEAAyE;YACzE,yEAAyE;YACzE,yEAAyE;YACzE,wEAAwE;YACxE,IAAI,MAAM,CAAC,WAAW,KAAK,IAAI,EAAE,CAAC;gBAChC,OAAO;oBACL,EAAE,EAAE,KAAK;oBACT,MAAM,EAAE,kBAAkB;oBAC1B,MAAM,EAAE,QAAQ,CAAC,MAAM;oBACvB,cAAc,EAAE,MAAM,CAAC,gBAAgB;iBACxC,CAAC;YACJ,CAAC;YACD,IAAI,IAAI,CAAC,KAAK,CAAC,MAAM,CAAC,UAAU,CAAC,IAAI,EAAE,CAAC,OAAO,EAAE,EAAE,CAAC;gBAClD,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,QAAQ,CAAC,MAAM,EAAE,CAAC;YACnE,CAAC;YACD,IAAI,MAAM,CAAC,SAAS,KAAK,KAAK,CAAC,QAAQ,IAAI,MAAM,CAAC,gBAAgB,KAAK,KAAK,CAAC,cAAc,EAAE,CAAC;gBAC5F,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,iBAAiB,EAAE,MAAM,EAAE,QAAQ,CAAC,MAAM,EAAE,CAAC;YAC3E,CAAC;YACD,IAAI,CAAC,gBAAgB,CAAC,MAAM,CAAC,YAAY,EAAE,KAAK,CAAC,WAAW,CAAC,EAAE,CAAC;gBAC9D,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,uBAAuB,EAAE,MAAM,EAAE,QAAQ,CAAC,MAAM,EAAE,CAAC;YACjF,CAAC;YAED,wEAAwE;YACxE,2EAA2E;YAC3E,6DAA6D;YAC7D,EAAE;YACF,wEAAwE;YACxE,uEAAuE;YACvE,uEAAuE;YACvE,wEAAwE;YACxE,uEAAuE;YACvE,2EAA2E;YAC3E,qEAAqE;YACrE,wEAAwE;YACxE,WAAW;YACX,MAAM,QAAQ,GAA4B,EAAE,GAAG,MAAM,EAAE,WAAW,EAAE,EAAE,CAAC,WAAW,EAAE,EAAE,CAAC;YACvF,IAAI,CAAC;gBACH,MAAM,IAAA,8BAAU,EACd,aAAa,EACb,QAAQ,CAAC,MAAM,EACf,4BAAoB;gBACpB,uEAAuE;gBACvE,2CAA2C;gBAC3C,CAAC,GAAG,IAAI,CAAC,QAAQ,CAAC,OAAO,EAAE,EAAE,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,QAAQ,KAAK,WAAW,CAAC,EAAE,QAAQ,CAAC,EACnF,QAAQ,EACR,EAAE,UAAU,EAAE,+BAAuB,EAAE,CACxC,CAAC;YACJ,CAAC;YAAC,OAAO,GAAG,EAAE,CAAC;gBACb,IAAI,CAAC,IAAA,iCAAiB,EAAC,GAAG,CAAC;oBAAE,MAAM,GAAG,CAAC;gBACvC,wEAAwE;gBACxE,mEAAmE;gBACnE,yEAAyE;gBACzE,sEAAsE;gBACtE,sEAAsE;gBACtE,iEAAiE;gBACjE,OAAO;oBACL,EAAE,EAAE,KAAK;oBACT,MAAM,EAAE,kBAAkB;oBAC1B,MAAM,EAAE,QAAQ,CAAC,MAAM;oBACvB,cAAc,EAAE,MAAM,CAAC,gBAAgB;iBACxC,CAAC;YACJ,CAAC;YAED,2EAA2E;YAC3E,yEAAyE;YACzE,0BAA0B;YAC1B,OAAO,EAAE,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,CAAC,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,CAAC;QACjE,CAAC;QAED,KAAK,CAAC,IAAI,CAAC,MAAM;YACf,MAAM,QAAQ,GAAG,MAAM,IAAA,6BAAS,EAC9B,aAAa,EACb,MAAM,EACN,4BAAoB,CACrB,CAAC;YACF,OAAO,QAAQ,CAAC,OAAO,CAAC;QAC1B,CAAC;KACF,CAAC;AACJ,CAAC"}
|
package/dist/bearer.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { RequestHandler } from 'express';
|
|
2
|
-
import type {
|
|
2
|
+
import type { BaseUserInfo, User } from '@boopkit/cap-users';
|
|
3
3
|
import './express.js';
|
|
4
4
|
import type { GrantService, OAuthGrantRecord } from './grant-service.js';
|
|
5
5
|
import { type TokenService } from './token-service.js';
|
|
@@ -49,10 +49,33 @@ export interface ResolvedOAuthPrincipal {
|
|
|
49
49
|
grant: OAuthGrantRecord;
|
|
50
50
|
clientPublicId: string;
|
|
51
51
|
}
|
|
52
|
+
/**
|
|
53
|
+
* Everything the resource server needs, and nothing else.
|
|
54
|
+
*
|
|
55
|
+
* The user read is a single injected function. It used to be that **and** a
|
|
56
|
+
* separate `AuthMiddlewareDeps` argument — cap-users' `{ authService,
|
|
57
|
+
* identityService, userService }` trio — which was handed to
|
|
58
|
+
* `createBearerMiddleware` and never read by it (boop-9ral). An application
|
|
59
|
+
* therefore supplied its user layer twice: once as `findUserById`, which the
|
|
60
|
+
* lookup below actually calls, and once as a `userService` inside a parameter
|
|
61
|
+
* nobody touched.
|
|
62
|
+
*
|
|
63
|
+
* Dropping it is the same narrowing `user-store.ts` applies to the identity and
|
|
64
|
+
* property services: what this capability can ask for stays smaller than what
|
|
65
|
+
* cap-users offers, so an application resolving users through a cache, a test
|
|
66
|
+
* harness or a service of its own owes this package one function rather than a
|
|
67
|
+
* fully constructed installation.
|
|
68
|
+
*/
|
|
52
69
|
export interface OAuthBearerOptions {
|
|
53
70
|
tokenService: TokenService;
|
|
54
71
|
grantService: GrantService;
|
|
55
|
-
/**
|
|
72
|
+
/**
|
|
73
|
+
* How this application turns a user id into the principal `req.user` carries.
|
|
74
|
+
*
|
|
75
|
+
* cap-users' `userService.findById` is the usual answer and the one the
|
|
76
|
+
* scaffold passes — but it is injected rather than reached for, so it need not
|
|
77
|
+
* be cap-users' at all.
|
|
78
|
+
*/
|
|
56
79
|
findUserById(userId: number): Promise<User<BaseUserInfo> | null>;
|
|
57
80
|
/**
|
|
58
81
|
* The `WWW-Authenticate` value on a 401.
|
|
@@ -83,7 +106,7 @@ export declare function createOAuthTokenResolver(options: OAuthBearerOptions): (
|
|
|
83
106
|
* the binding is made per request. That is a closure allocation, not work; the
|
|
84
107
|
* three reads inside it are the cost of the request either way.
|
|
85
108
|
*/
|
|
86
|
-
export declare function createOAuthBearerMiddleware(
|
|
109
|
+
export declare function createOAuthBearerMiddleware(options: OAuthBearerOptions): RequestHandler;
|
|
87
110
|
/** The challenge sent when the request carried no usable OAuth token at all. */
|
|
88
111
|
export declare const DEFAULT_BEARER_CHALLENGE = "Bearer";
|
|
89
112
|
/**
|
|
@@ -142,5 +165,5 @@ export interface OAuthResourceServer {
|
|
|
142
165
|
/** Then gate them: `requireOAuth('profile')`. */
|
|
143
166
|
requireOAuth(...scopes: string[]): RequestHandler;
|
|
144
167
|
}
|
|
145
|
-
export declare function createOAuthResourceServer(
|
|
168
|
+
export declare function createOAuthResourceServer(options: OAuthBearerOptions): OAuthResourceServer;
|
|
146
169
|
//# sourceMappingURL=bearer.d.ts.map
|
package/dist/bearer.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"bearer.d.ts","sourceRoot":"","sources":["../src/bearer.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAyB,cAAc,EAAY,MAAM,SAAS,CAAC;AAE/E,OAAO,KAAK,EAAE,
|
|
1
|
+
{"version":3,"file":"bearer.d.ts","sourceRoot":"","sources":["../src/bearer.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAyB,cAAc,EAAY,MAAM,SAAS,CAAC;AAE/E,OAAO,KAAK,EAAE,YAAY,EAAE,IAAI,EAAE,MAAM,oBAAoB,CAAC;AAE7D,OAAO,cAAc,CAAC;AACtB,OAAO,KAAK,EAAE,YAAY,EAAE,gBAAgB,EAAE,MAAM,oBAAoB,CAAC;AACzE,OAAO,EAAuB,KAAK,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAE5E;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAuCG;AAEH,0EAA0E;AAC1E,MAAM,WAAW,sBAAsB;IACrC,IAAI,EAAE,IAAI,CAAC,YAAY,CAAC,CAAC;IACzB,KAAK,EAAE,gBAAgB,CAAC;IACxB,cAAc,EAAE,MAAM,CAAC;CACxB;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,MAAM,WAAW,kBAAkB;IACjC,YAAY,EAAE,YAAY,CAAC;IAC3B,YAAY,EAAE,YAAY,CAAC;IAC3B;;;;;;OAMG;IACH,YAAY,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,YAAY,CAAC,GAAG,IAAI,CAAC,CAAC;IACjE;;;;;;;OAOG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED;;;;;;;GAOG;AACH,wBAAgB,wBAAwB,CACtC,OAAO,EAAE,kBAAkB,GAC1B,CAAC,KAAK,EAAE,MAAM,KAAK,OAAO,CAAC,sBAAsB,GAAG,IAAI,CAAC,CAe3D;AAED;;;;;;;;;GASG;AACH,wBAAgB,2BAA2B,CAAC,OAAO,EAAE,kBAAkB,GAAG,cAAc,CAwBvF;AAED,gFAAgF;AAChF,eAAO,MAAM,wBAAwB,WAAW,CAAC;AAEjD;;;;;;;GAOG;AACH,wBAAgB,aAAa,CAAC,OAAO,EAAE,SAAS,MAAM,EAAE,EAAE,QAAQ,EAAE,SAAS,MAAM,EAAE,GAAG,MAAM,EAAE,CAG/F;AAcD;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,wBAAgB,YAAY,CAAC,GAAG,cAAc,EAAE,MAAM,EAAE,GAAG,cAAc,CAgCxE;AAED;;;;;;;;;;;;;;;GAeG;AACH,MAAM,WAAW,mBAAmB;IAClC,oDAAoD;IACpD,aAAa,EAAE,cAAc,CAAC;IAC9B,iDAAiD;IACjD,YAAY,CAAC,GAAG,MAAM,EAAE,MAAM,EAAE,GAAG,cAAc,CAAC;CACnD;AAED,wBAAgB,yBAAyB,CAAC,OAAO,EAAE,kBAAkB,GAAG,mBAAmB,CAM1F"}
|
package/dist/bearer.js
CHANGED
|
@@ -42,7 +42,7 @@ function createOAuthTokenResolver(options) {
|
|
|
42
42
|
* the binding is made per request. That is a closure allocation, not work; the
|
|
43
43
|
* three reads inside it are the cost of the request either way.
|
|
44
44
|
*/
|
|
45
|
-
function createOAuthBearerMiddleware(
|
|
45
|
+
function createOAuthBearerMiddleware(options) {
|
|
46
46
|
const resolveOAuthToken = createOAuthTokenResolver(options);
|
|
47
47
|
const challenge = options.challenge ?? 'Bearer';
|
|
48
48
|
return function oauthBearer(req, res, next) {
|
|
@@ -51,7 +51,7 @@ function createOAuthBearerMiddleware(deps, options) {
|
|
|
51
51
|
// caller who presented nothing at all — the path on which the resolver
|
|
52
52
|
// itself never refuses anybody.
|
|
53
53
|
req.oauthChallenge = challenge;
|
|
54
|
-
const middleware = (0, cap_users_1.createBearerMiddleware)(
|
|
54
|
+
const middleware = (0, cap_users_1.createBearerMiddleware)({
|
|
55
55
|
strategy: 'oauth',
|
|
56
56
|
challenge,
|
|
57
57
|
resolve: async (token) => {
|
|
@@ -140,10 +140,10 @@ function requireOAuth(...requiredScopes) {
|
|
|
140
140
|
next();
|
|
141
141
|
};
|
|
142
142
|
}
|
|
143
|
-
function createOAuthResourceServer(
|
|
143
|
+
function createOAuthResourceServer(options) {
|
|
144
144
|
const challenge = options.challenge ?? exports.DEFAULT_BEARER_CHALLENGE;
|
|
145
145
|
return {
|
|
146
|
-
resolveBearer: createOAuthBearerMiddleware(
|
|
146
|
+
resolveBearer: createOAuthBearerMiddleware({ ...options, challenge }),
|
|
147
147
|
requireOAuth,
|
|
148
148
|
};
|
|
149
149
|
}
|
package/dist/bearer.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"bearer.js","sourceRoot":"","sources":["../src/bearer.ts"],"names":[],"mappings":";;;
|
|
1
|
+
{"version":3,"file":"bearer.js","sourceRoot":"","sources":["../src/bearer.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import type { ClientRegistryService } from './client-registry-service.js';
|
|
2
|
+
import { type GrantService } from './grant-service.js';
|
|
3
|
+
import type { TokenService } from './token-service.js';
|
|
4
|
+
import type { OauthClient } from './types.js';
|
|
5
|
+
/**
|
|
6
|
+
* Garbage collection for the client registry — the caller `pruneAbandonedClients`
|
|
7
|
+
* was always waiting for.
|
|
8
|
+
*
|
|
9
|
+
* ## The two halves the registry deliberately does not own
|
|
10
|
+
*
|
|
11
|
+
* `ClientRegistryService.pruneAbandonedClients({ before, isInUse })` knows how to
|
|
12
|
+
* pick candidates: stranger-created rows (`dynamic` and `metadata_document`)
|
|
13
|
+
* older than a cutoff. It knows nothing about grants, and it must not — the
|
|
14
|
+
* registry's whole discipline is that a client is software and a grant is a
|
|
15
|
+
* relationship between a person and that software, and the registry never learns
|
|
16
|
+
* who consented. So it takes the "is this in use?" question as a parameter and
|
|
17
|
+
* answers "no" by default, which is safe only in a deployment that has no grants
|
|
18
|
+
* at all.
|
|
19
|
+
*
|
|
20
|
+
* This file supplies the missing half, and a call site for it. Both live here
|
|
21
|
+
* rather than in the registry for the same reason the registry asked: answering
|
|
22
|
+
* the question means reading `oauth-grants`, which is a user-shaped read.
|
|
23
|
+
*
|
|
24
|
+
* ## What "in use" means, and why it is the grant rather than the token
|
|
25
|
+
*
|
|
26
|
+
* A client is in use when **somebody still has it connected** — that is, when
|
|
27
|
+
* some user's `oauth-grants` holds a live entry naming its `publicId`.
|
|
28
|
+
*
|
|
29
|
+
* The identity rows cannot carry that answer, in either direction, and
|
|
30
|
+
* `revocation-service.ts` makes the whole argument: an ended row does not mean
|
|
31
|
+
* disconnected, because ordinary expiry end-dates rows too, and a live row does
|
|
32
|
+
* not mean connected, because rotation leaves the superseded refresh row
|
|
33
|
+
* deliberately active. Reading tokens would delete a client half the deployment
|
|
34
|
+
* uses, or keep one nobody has used in a year. The grant record is the signal.
|
|
35
|
+
*
|
|
36
|
+
* Token rows are still how the *users* are found — `findUserIdsForClient` scans
|
|
37
|
+
* the `"<publicId>|"` key prefix the wire format was shaped to make cheap — and
|
|
38
|
+
* that is sound because a grant is only ever written at redemption, in the same
|
|
39
|
+
* request that mints the first token. A grant with no token row behind it is not
|
|
40
|
+
* a state this server can reach.
|
|
41
|
+
*
|
|
42
|
+
* ## What it deliberately does not protect
|
|
43
|
+
*
|
|
44
|
+
* An authorization code issued against a client whose row is about to be swept.
|
|
45
|
+
* Codes live for a minute and the cutoff is thirty days, so the window is a
|
|
46
|
+
* client that registered a month ago, never connected, and is mid-flow at the
|
|
47
|
+
* exact moment the sweep runs; it loses that one redemption and can register
|
|
48
|
+
* again. Closing it properly would mean scanning `oauth-codes`, which is a
|
|
49
|
+
* per-user property with no cross-user read — the cost is a full user scan to buy
|
|
50
|
+
* a minute of protection for a client that has never completed a grant.
|
|
51
|
+
*
|
|
52
|
+
* ## Deleting a row a grant still points at is fine, and intended
|
|
53
|
+
*
|
|
54
|
+
* An *ended* grant is not in use, so its client may be swept. The
|
|
55
|
+
* connected-clients screen already renders a grant whose registry row has gone —
|
|
56
|
+
* it falls back to the `client_id` the client presented and says the application
|
|
57
|
+
* is no longer registered here — because a connection the screen does not list is
|
|
58
|
+
* a connection nobody can disconnect.
|
|
59
|
+
*/
|
|
60
|
+
export interface ClientInUseOptions {
|
|
61
|
+
grantService: GrantService;
|
|
62
|
+
tokenService: TokenService;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* The real `isInUse` predicate: is anybody still connected to this client?
|
|
66
|
+
*
|
|
67
|
+
* Suitable for passing straight to `pruneAbandonedClients`. It is `false` for a
|
|
68
|
+
* client nobody ever connected and for one every user has disconnected, and
|
|
69
|
+
* `true` the moment one live grant names the client's `publicId`.
|
|
70
|
+
*/
|
|
71
|
+
export declare function createClientInUsePredicate(options: ClientInUseOptions): (client: OauthClient) => Promise<boolean>;
|
|
72
|
+
/** What one sweep did, in the shape a log line or a job result wants. */
|
|
73
|
+
export interface ClientPruneResult {
|
|
74
|
+
/** The cutoff actually used. Rows created before this were candidates. */
|
|
75
|
+
before: Date;
|
|
76
|
+
/** The `client_id` of every row deleted, in the order they were deleted. */
|
|
77
|
+
removed: string[];
|
|
78
|
+
}
|
|
79
|
+
export interface ClientPruneOptions extends ClientInUseOptions {
|
|
80
|
+
clientRegistry: ClientRegistryService;
|
|
81
|
+
/**
|
|
82
|
+
* How old an unconnected row has to be before it is garbage. Defaults to the
|
|
83
|
+
* plan's thirty days (`DEFAULT_ABANDONED_CLIENT_TTL_MS`).
|
|
84
|
+
*/
|
|
85
|
+
ttlMs?: number;
|
|
86
|
+
/** Injectable clock, so a test does not have to wait a month. */
|
|
87
|
+
now?: () => Date;
|
|
88
|
+
}
|
|
89
|
+
export interface ClientPruneJob {
|
|
90
|
+
/** Sweep once. Safe to run concurrently with the rest of the server. */
|
|
91
|
+
run(): Promise<ClientPruneResult>;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* The scheduled sweep, as one callable object.
|
|
95
|
+
*
|
|
96
|
+
* A job rather than a loose function because the thing that was missing was a
|
|
97
|
+
* *call site*: a scaffolded application schedules `run()` — from its own cron,
|
|
98
|
+
* from Heroku Scheduler, or from a cap-background-jobs consumer — and the
|
|
99
|
+
* capability owns which rows go and which stay.
|
|
100
|
+
*/
|
|
101
|
+
export declare function createClientPruneJob(options: ClientPruneOptions): ClientPruneJob;
|
|
102
|
+
//# sourceMappingURL=client-prune.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"client-prune.d.ts","sourceRoot":"","sources":["../src/client-prune.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,8BAA8B,CAAC;AAE1E,OAAO,EAAiB,KAAK,YAAY,EAAE,MAAM,oBAAoB,CAAC;AACtE,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AACvD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAE9C;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsDG;AAEH,MAAM,WAAW,kBAAkB;IACjC,YAAY,EAAE,YAAY,CAAC;IAC3B,YAAY,EAAE,YAAY,CAAC;CAC5B;AAED;;;;;;GAMG;AACH,wBAAgB,0BAA0B,CACxC,OAAO,EAAE,kBAAkB,GAC1B,CAAC,MAAM,EAAE,WAAW,KAAK,OAAO,CAAC,OAAO,CAAC,CAmB3C;AAED,yEAAyE;AACzE,MAAM,WAAW,iBAAiB;IAChC,0EAA0E;IAC1E,MAAM,EAAE,IAAI,CAAC;IACb,4EAA4E;IAC5E,OAAO,EAAE,MAAM,EAAE,CAAC;CACnB;AAED,MAAM,WAAW,kBAAmB,SAAQ,kBAAkB;IAC5D,cAAc,EAAE,qBAAqB,CAAC;IACtC;;;OAGG;IACH,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,iEAAiE;IACjE,GAAG,CAAC,EAAE,MAAM,IAAI,CAAC;CAClB;AAED,MAAM,WAAW,cAAc;IAC7B,wEAAwE;IACxE,GAAG,IAAI,OAAO,CAAC,iBAAiB,CAAC,CAAC;CACnC;AAED;;;;;;;GAOG;AACH,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,kBAAkB,GAAG,cAAc,CAahF"}
|