@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.
Files changed (124) hide show
  1. package/capability.json +120 -0
  2. package/dist/authorization-code-service.d.ts +105 -0
  3. package/dist/authorization-code-service.d.ts.map +1 -0
  4. package/dist/authorization-code-service.js +142 -0
  5. package/dist/authorization-code-service.js.map +1 -0
  6. package/dist/bearer.d.ts +146 -0
  7. package/dist/bearer.d.ts.map +1 -0
  8. package/dist/bearer.js +150 -0
  9. package/dist/bearer.js.map +1 -0
  10. package/dist/body-field.d.ts +24 -0
  11. package/dist/body-field.d.ts.map +1 -0
  12. package/dist/body-field.js +43 -0
  13. package/dist/body-field.js.map +1 -0
  14. package/dist/client-registry-service.d.ts +120 -0
  15. package/dist/client-registry-service.d.ts.map +1 -0
  16. package/dist/client-registry-service.js +450 -0
  17. package/dist/client-registry-service.js.map +1 -0
  18. package/dist/config.d.ts +82 -0
  19. package/dist/config.d.ts.map +1 -0
  20. package/dist/config.js +157 -0
  21. package/dist/config.js.map +1 -0
  22. package/dist/connections-controller.d.ts +106 -0
  23. package/dist/connections-controller.d.ts.map +1 -0
  24. package/dist/connections-controller.js +161 -0
  25. package/dist/connections-controller.js.map +1 -0
  26. package/dist/consent-controller.d.ts +197 -0
  27. package/dist/consent-controller.d.ts.map +1 -0
  28. package/dist/consent-controller.js +532 -0
  29. package/dist/consent-controller.js.map +1 -0
  30. package/dist/discovery-controller.d.ts +16 -0
  31. package/dist/discovery-controller.d.ts.map +1 -0
  32. package/dist/discovery-controller.js +52 -0
  33. package/dist/discovery-controller.js.map +1 -0
  34. package/dist/discovery.d.ts +95 -0
  35. package/dist/discovery.d.ts.map +1 -0
  36. package/dist/discovery.js +101 -0
  37. package/dist/discovery.js.map +1 -0
  38. package/dist/errors.d.ts +26 -0
  39. package/dist/errors.d.ts.map +1 -0
  40. package/dist/errors.js +41 -0
  41. package/dist/errors.js.map +1 -0
  42. package/dist/express.d.ts +66 -0
  43. package/dist/express.d.ts.map +1 -0
  44. package/dist/express.js +3 -0
  45. package/dist/express.js.map +1 -0
  46. package/dist/grant-service.d.ts +102 -0
  47. package/dist/grant-service.d.ts.map +1 -0
  48. package/dist/grant-service.js +137 -0
  49. package/dist/grant-service.js.map +1 -0
  50. package/dist/index.d.ts +67 -0
  51. package/dist/index.d.ts.map +1 -0
  52. package/dist/index.js +148 -0
  53. package/dist/index.js.map +1 -0
  54. package/dist/metadata-document.d.ts +60 -0
  55. package/dist/metadata-document.d.ts.map +1 -0
  56. package/dist/metadata-document.js +148 -0
  57. package/dist/metadata-document.js.map +1 -0
  58. package/dist/oauth-error.d.ts +45 -0
  59. package/dist/oauth-error.d.ts.map +1 -0
  60. package/dist/oauth-error.js +51 -0
  61. package/dist/oauth-error.js.map +1 -0
  62. package/dist/pkce.d.ts +29 -0
  63. package/dist/pkce.d.ts.map +1 -0
  64. package/dist/pkce.js +65 -0
  65. package/dist/pkce.js.map +1 -0
  66. package/dist/property-array.d.ts +60 -0
  67. package/dist/property-array.d.ts.map +1 -0
  68. package/dist/property-array.js +31 -0
  69. package/dist/property-array.js.map +1 -0
  70. package/dist/public-id.d.ts +35 -0
  71. package/dist/public-id.d.ts.map +1 -0
  72. package/dist/public-id.js +76 -0
  73. package/dist/public-id.js.map +1 -0
  74. package/dist/rate-limit.d.ts +56 -0
  75. package/dist/rate-limit.d.ts.map +1 -0
  76. package/dist/rate-limit.js +55 -0
  77. package/dist/rate-limit.js.map +1 -0
  78. package/dist/redirect-uri.d.ts +89 -0
  79. package/dist/redirect-uri.d.ts.map +1 -0
  80. package/dist/redirect-uri.js +249 -0
  81. package/dist/redirect-uri.js.map +1 -0
  82. package/dist/registration-controller.d.ts +62 -0
  83. package/dist/registration-controller.d.ts.map +1 -0
  84. package/dist/registration-controller.js +79 -0
  85. package/dist/registration-controller.js.map +1 -0
  86. package/dist/request-client.d.ts +19 -0
  87. package/dist/request-client.d.ts.map +1 -0
  88. package/dist/request-client.js +32 -0
  89. package/dist/request-client.js.map +1 -0
  90. package/dist/revocation-controller.d.ts +28 -0
  91. package/dist/revocation-controller.d.ts.map +1 -0
  92. package/dist/revocation-controller.js +176 -0
  93. package/dist/revocation-controller.js.map +1 -0
  94. package/dist/revocation-service.d.ts +113 -0
  95. package/dist/revocation-service.d.ts.map +1 -0
  96. package/dist/revocation-service.js +121 -0
  97. package/dist/revocation-service.js.map +1 -0
  98. package/dist/token-controller.d.ts +79 -0
  99. package/dist/token-controller.d.ts.map +1 -0
  100. package/dist/token-controller.js +207 -0
  101. package/dist/token-controller.js.map +1 -0
  102. package/dist/token-service.d.ts +175 -0
  103. package/dist/token-service.d.ts.map +1 -0
  104. package/dist/token-service.js +251 -0
  105. package/dist/token-service.js.map +1 -0
  106. package/dist/types.d.ts +130 -0
  107. package/dist/types.d.ts.map +1 -0
  108. package/dist/types.js +13 -0
  109. package/dist/types.js.map +1 -0
  110. package/dist/user-store.d.ts +110 -0
  111. package/dist/user-store.d.ts.map +1 -0
  112. package/dist/user-store.js +40 -0
  113. package/dist/user-store.js.map +1 -0
  114. package/package.json +37 -0
  115. package/scaffold/prisma/schema/oauth-clients.prisma +80 -0
  116. package/scaffold/reference/oauth-server.ref.md +478 -0
  117. package/scaffold/src/middleware/oauth-server.ts +227 -0
  118. package/scaffold/src/scripts/seed-oauth-clients.ts +72 -0
  119. package/scaffold/src/services/oauth-server.service.ts +101 -0
  120. package/scaffold/styles/oauth-server.css +345 -0
  121. package/scaffold/views/oauth-connected-clients.hbs +98 -0
  122. package/scaffold/views/oauth-consent-error.hbs +22 -0
  123. package/scaffold/views/oauth-consent.hbs +138 -0
  124. package/scaffold/views/oauth-disconnect-confirm.hbs +57 -0
@@ -0,0 +1,176 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createRevocationHandlers = createRevocationHandlers;
4
+ exports.createRevocationRouter = createRevocationRouter;
5
+ const express_1 = require("express");
6
+ const body_field_js_1 = require("./body-field.js");
7
+ const oauth_error_js_1 = require("./oauth-error.js");
8
+ const request_client_js_1 = require("./request-client.js");
9
+ const revocation_service_js_1 = require("./revocation-service.js");
10
+ const token_service_js_1 = require("./token-service.js");
11
+ /**
12
+ * `POST /oauth/revoke` — RFC 7009, the third and last way a connection ends.
13
+ *
14
+ * The other two were already here. A person disconnecting in the application
15
+ * goes through `/connections` and its confirmation page; an operator retiring a
16
+ * compromised client calls `revocationService.revokeClient`. This is the
17
+ * **client-driven** case: a connector handing a token back because the user
18
+ * disconnected on its side, which is the only one of the three that a piece of
19
+ * software rather than a person initiates.
20
+ *
21
+ * It exists because the discovery document advertises `revocation_endpoint`, and
22
+ * this package's own rule — argued in `discovery.ts`, and enforced on the device
23
+ * grant type by `OAUTH_DEVICE_CODE_ENABLED` — is that advertising an endpoint
24
+ * which answers 404 is a lie a client cannot interpret: having read the
25
+ * metadata, it reads the 404 as a server fault rather than as an unsupported
26
+ * feature. The alternative was taking the field back out. Mounting it is better,
27
+ * because a connector that cannot hand a token back leaves the user to discover
28
+ * the still-live connection in an application they may never open again.
29
+ *
30
+ * ## Which token ends what, which is the decision this file exists to record
31
+ *
32
+ * RFC 7009 §2.1 leaves it open in both directions: revoking a refresh token
33
+ * SHOULD also invalidate the access tokens issued from it, and revoking an
34
+ * access token MAY invalidate the refresh token. This server answers the two
35
+ * halves differently, and the second answer is the non-obvious one.
36
+ *
37
+ * **A refresh token ends the whole grant.** Take the SHOULD. The grant is this
38
+ * server's unit of connection — it is what the guard reads, what `/connections`
39
+ * lists, and what carries the revocation reason — and a connector calling this
40
+ * on user-disconnect wants the connection gone rather than one credential of it.
41
+ * So the presented refresh token routes to `revocationService`, which end-dates
42
+ * the grant with `REVOKED_BY_CLIENT` and sweeps every live token row the client
43
+ * holds for that user.
44
+ *
45
+ * **An access token ends only its own row. Decline the MAY.** This server
46
+ * rotates the refresh token on every use but deliberately does *not* end-date
47
+ * the superseded **access** row, so the access token from before a refresh stays
48
+ * live until its TTL runs out. A tidy client that revokes that old access token
49
+ * right after refreshing is doing exactly the housekeeping RFC 7009 exists for —
50
+ * and under the MAY it would destroy the grant it had just refreshed. That is a
51
+ * self-inflicted outage the specification permits and we should not build.
52
+ *
53
+ * **An already-ended grant still gets the presented row swept.**
54
+ * `revokeClientForUser` looks for an *active* grant and answers `null` when
55
+ * there is none, and rotation leaves superseded refresh rows deliberately
56
+ * active — so a live refresh row under a dead grant is a reachable state, and
57
+ * the fall-through to `revokeByKey` is what ends it. Without that, the answer
58
+ * would still be 200 and the row would still be there.
59
+ *
60
+ * ## §2.2, and the rule that everything is 200
61
+ *
62
+ * A revocation response says nothing about the token. Revoked, already invalid,
63
+ * expired, never existed, or belonging to a different client — all of them are
64
+ * **200 with an empty body**, because any other answer tells an unauthenticated
65
+ * caller (every client here is public) whether a string it holds is a real
66
+ * credential. The last of those is the one that feels wrong and is not: there is
67
+ * no way to report "that token is not yours" without first confirming the token
68
+ * exists.
69
+ *
70
+ * The errors are only about the request itself: `400 invalid_request` for a
71
+ * missing `token` or `client_id`, `400 unsupported_token_type` for a
72
+ * `token_type_hint` naming a species this server does not issue (§2.2.1), and
73
+ * `401 invalid_client` for a client that is unknown or suspended.
74
+ *
75
+ * ## The hint is advisory, so both domains are tried
76
+ *
77
+ * §2.1 says the hint is an optimisation and a server MUST fall back to searching
78
+ * the other types. It is honoured as an ordering and nothing more: a client that
79
+ * mislabels a correctly-presented token still gets it revoked. A given token
80
+ * exists in at most one domain — the raw value is hashed into the key — so the
81
+ * order only ever saves a lookup, never changes the outcome.
82
+ */
83
+ /**
84
+ * RFC 7009 §2.1's registered hints, mapped to this server's token domains.
85
+ *
86
+ * A `Map` rather than an object literal, because the hint is attacker-supplied
87
+ * and an object's keys include the ones it inherited: `token_type_hint=toString`
88
+ * would read back a function from `Object.prototype` and pass for a recognised
89
+ * hint. A `Map` knows only what was put in it.
90
+ */
91
+ const DOMAIN_BY_HINT = new Map([
92
+ ['access_token', token_service_js_1.OAUTH_ACCESS_DOMAIN],
93
+ ['refresh_token', token_service_js_1.OAUTH_REFRESH_DOMAIN],
94
+ ]);
95
+ function createRevocationHandlers(deps) {
96
+ const { clientRegistry, tokenService, revocationService } = deps;
97
+ /** The hint's domain first, then the other. See the header: advisory only. */
98
+ function searchOrder(hint) {
99
+ const hinted = hint === undefined ? undefined : DOMAIN_BY_HINT.get(hint);
100
+ if (hint !== undefined && hinted === undefined) {
101
+ // §2.2.1. An unrecognised hint is the one thing about the token itself
102
+ // that is safe to refuse, because it says nothing about whether the token
103
+ // is real — it is a statement about the request's vocabulary.
104
+ throw new oauth_error_js_1.OAuthError('unsupported_token_type', `this server issues access_token and refresh_token, not ${hint}`);
105
+ }
106
+ if (hinted === token_service_js_1.OAUTH_REFRESH_DOMAIN)
107
+ return [token_service_js_1.OAUTH_REFRESH_DOMAIN, token_service_js_1.OAUTH_ACCESS_DOMAIN];
108
+ return [token_service_js_1.OAUTH_ACCESS_DOMAIN, token_service_js_1.OAUTH_REFRESH_DOMAIN];
109
+ }
110
+ /** End what this token stands for. See the header for why the two differ. */
111
+ async function endCredential(domain, userId, identityKey, clientPublicId) {
112
+ if (domain === token_service_js_1.OAUTH_ACCESS_DOMAIN) {
113
+ await tokenService.revokeByKey(token_service_js_1.OAUTH_ACCESS_DOMAIN, identityKey);
114
+ return;
115
+ }
116
+ const revoked = await revocationService.revokeClientForUser(userId, clientPublicId, revocation_service_js_1.REVOKED_BY_CLIENT);
117
+ // No active grant to end — but the row that was presented is live, or it
118
+ // would not have resolved a moment ago. Sweep it on its own.
119
+ if (!revoked) {
120
+ await tokenService.revokeByKey(token_service_js_1.OAUTH_REFRESH_DOMAIN, identityKey);
121
+ }
122
+ }
123
+ const revoke = async (req, res, next) => {
124
+ // §2.2 sends no body worth caching, and the request carried a credential.
125
+ res.set('Cache-Control', 'no-store');
126
+ res.set('Pragma', 'no-cache');
127
+ try {
128
+ const presented = (0, body_field_js_1.required)(req.body, 'token');
129
+ const order = searchOrder((0, body_field_js_1.field)(req.body, 'token_type_hint'));
130
+ const client = await (0, request_client_js_1.resolveRequestClient)(clientRegistry, req.body);
131
+ // The client prefix is readable off the token before any lookup, so a
132
+ // token belonging to somebody else is dropped without touching the
133
+ // database — and, per §2.2, without being distinguishable from success.
134
+ const parsed = tokenService.parseToken(presented);
135
+ if (parsed && parsed.clientPublicId === client.publicId) {
136
+ for (const domain of order) {
137
+ const resolved = await tokenService.resolve(domain, presented);
138
+ if (!resolved)
139
+ continue;
140
+ await endCredential(domain, resolved.userId, resolved.identityKey, client.publicId);
141
+ break;
142
+ }
143
+ }
144
+ // §2.2: 200 and nothing else, whatever happened above.
145
+ res.status(200).end();
146
+ }
147
+ catch (err) {
148
+ if ((0, oauth_error_js_1.isOAuthError)(err)) {
149
+ if (err.error === 'invalid_client') {
150
+ res.set('WWW-Authenticate', 'Bearer');
151
+ }
152
+ res.status(err.status).json(err.toBody());
153
+ return;
154
+ }
155
+ next(err);
156
+ }
157
+ };
158
+ return { revoke };
159
+ }
160
+ /**
161
+ * `POST /oauth/revoke`, with both body parsers attached.
162
+ *
163
+ * Form and JSON, for the reason `createTokenRouter` mounts both: the RFC says
164
+ * form and the ecosystem sends JSON, and a client that gets this wrong on the
165
+ * revocation endpoint has already got it right on the token endpoint. The
166
+ * router parses its own body so the endpoint behaves the same whatever order the
167
+ * host application mounts its middleware in.
168
+ */
169
+ function createRevocationRouter(options) {
170
+ const handlers = createRevocationHandlers(options);
171
+ const router = (0, express_1.Router)();
172
+ const limit = options.bodyLimit ?? '16kb';
173
+ router.post('/oauth/revoke', (0, express_1.urlencoded)({ extended: false, limit }), (0, express_1.json)({ limit }), handlers.revoke);
174
+ return router;
175
+ }
176
+ //# sourceMappingURL=revocation-controller.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"revocation-controller.js","sourceRoot":"","sources":["../src/revocation-controller.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,113 @@
1
+ import { type GrantService, type OAuthGrantRecord } from './grant-service.js';
2
+ import type { TokenService } from './token-service.js';
3
+ import type { StoredIdentity } from './user-store.js';
4
+ /**
5
+ * Disconnecting a client — the write behind the disconnect button, and behind
6
+ * the operator's answer to a compromised client.
7
+ *
8
+ * ## Soft revocation, and why the signal lives on the grant
9
+ *
10
+ * Nothing is deleted. The grant record is end-dated with a reason and the
11
+ * client's token rows are end-dated too, so cap-users' active-only resolution
12
+ * refuses the very next request whatever the token's stated lifetime says. The
13
+ * rows that are left behind are the audit trail, which is the point: a user who
14
+ * has just disconnected something wants to see that it stuck, and an operator
15
+ * investigating an incident wants to see what was once connected.
16
+ *
17
+ * **But the identity rows cannot carry the answer, in either direction.** An
18
+ * ended row does not mean revoked: `tokenService.resolve` end-dates a row the
19
+ * first time it reads an expired one, so ordinary expiry produces the same
20
+ * tombstone that revocation does. And a *live* row does not mean connected:
21
+ * rotation leaves the superseded refresh row deliberately active, so that
22
+ * presenting it again is recognisable as reuse rather than as a typo, and a
23
+ * connector in daily use therefore accumulates live rows it will never use
24
+ * again. Neither the presence nor the absence of a row is the signal.
25
+ *
26
+ * The signal is the **grant record**, which carries `ended_at` and a reason, and
27
+ * that is what the connected-clients screen and the resource-server guard both
28
+ * read. A test covers exactly this: a client that rotated three times and was
29
+ * then revoked still reads as revoked, and every one of the token rows those
30
+ * rotations left behind stops working.
31
+ *
32
+ * ## The order of the two writes
33
+ *
34
+ * The grant is ended **first**, then the tokens. Both orders leave the client
35
+ * locked out — the guard needs a live grant *and* a live token row, so ending
36
+ * either one is enough — but ending the grant first makes the lockout a single
37
+ * write. If the token sweep then fails halfway, access is already refused and
38
+ * the sweep is safe to retry, whereas the other order leaves a window in which
39
+ * some tokens still resolve against a live grant.
40
+ *
41
+ * ## Client-wide revocation finds its own users
42
+ *
43
+ * `revokeClient` takes a client and nothing else. It asks cap-users for every
44
+ * identity row whose key starts with `"<publicId>|"` — the scan the token wire
45
+ * format was shaped to make cheap — and sweeps the users that come back, one
46
+ * grant at a time. The operator answering a compromised client therefore does
47
+ * not have to already know the blast radius, which was the old shape's real
48
+ * cost: `revokeClientForUsers(userIds, ...)` was correct and swept exactly the
49
+ * accounts somebody had thought to name.
50
+ *
51
+ * The scan is still cap-users' query rather than ours (`IdentityStore`, backed
52
+ * by `findAllByDomainAndKeyPrefix`), so the one hard invariant is unchanged —
53
+ * this capability does not reach into the identity table, by prefix or
54
+ * otherwise.
55
+ *
56
+ * Two things it deliberately does **not** do. It does not read revocation off
57
+ * the identity rows: they say who this client once reached, and the grant
58
+ * record says whether anything is still connected. And it does not narrow the
59
+ * scan to live rows, because an idle connection's rows are all end-dated by the
60
+ * expiry sweep while its grant is perfectly alive.
61
+ */
62
+ /** The reason recorded when a person disconnects a client themselves. */
63
+ export declare const REVOKED_BY_USER = "user_revoked";
64
+ /** The reason recorded when an operator retires a client for everybody. */
65
+ export declare const REVOKED_CLIENT_WIDE = "client_revoked";
66
+ /**
67
+ * The reason recorded when the client itself hands a refresh token back at
68
+ * `POST /oauth/revoke` — RFC 7009's case, and the third of the three.
69
+ *
70
+ * It is a distinct word from `REVOKED_BY_USER` on purpose. Both are somebody
71
+ * choosing to disconnect, but only one of them was a person standing in front
72
+ * of this application's confirmation page; an operator reading the audit trail
73
+ * afterwards wants to know which door the disconnect came through.
74
+ */
75
+ export declare const REVOKED_BY_CLIENT = "client_requested";
76
+ /** What one revocation did. */
77
+ export interface RevokedGrant {
78
+ /** The grant as it now stands, carrying `ended_at` and `ended_reason`. */
79
+ grant: OAuthGrantRecord;
80
+ /**
81
+ * The token rows this revocation end-dated.
82
+ *
83
+ * Empty is an ordinary outcome, not a failure: a grant whose access token has
84
+ * already expired and whose refresh token was rotated out has no live rows
85
+ * left to end.
86
+ */
87
+ endedTokens: StoredIdentity[];
88
+ }
89
+ export interface RevocationService {
90
+ /** Disconnect one grant by its id. `null` when this user has no such grant. */
91
+ revokeGrant(userId: number, grantId: string, reason?: string): Promise<RevokedGrant | null>;
92
+ /** The same, addressed by client instead. `null` when nothing is connected. */
93
+ revokeClientForUser(userId: number, clientPublicId: string, reason?: string): Promise<RevokedGrant | null>;
94
+ /**
95
+ * Retire one client across the whole deployment — the compromised-client case.
96
+ *
97
+ * Finds its own users, through the token service's key-prefix scan. Answers
98
+ * with one entry per user that actually had a live grant, so the result's
99
+ * length is how many connections were broken rather than how many users the
100
+ * scan turned up.
101
+ *
102
+ * Throws when `clientPublicId` is not a well-formed public id, rather than
103
+ * quietly sweeping nothing: during an incident, a typo that reads as "no
104
+ * users are affected" is the worst answer available.
105
+ */
106
+ revokeClient(clientPublicId: string, reason?: string): Promise<RevokedGrant[]>;
107
+ }
108
+ export interface RevocationServiceOptions {
109
+ grantService: GrantService;
110
+ tokenService: TokenService;
111
+ }
112
+ export declare function createRevocationService(options: RevocationServiceOptions): RevocationService;
113
+ //# sourceMappingURL=revocation-service.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"revocation-service.d.ts","sourceRoot":"","sources":["../src/revocation-service.ts"],"names":[],"mappings":"AAAA,OAAO,EAAiB,KAAK,YAAY,EAAE,KAAK,gBAAgB,EAAE,MAAM,oBAAoB,CAAC;AAC7F,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AACvD,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAEtD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAyDG;AAEH,yEAAyE;AACzE,eAAO,MAAM,eAAe,iBAAiB,CAAC;AAE9C,2EAA2E;AAC3E,eAAO,MAAM,mBAAmB,mBAAmB,CAAC;AAEpD;;;;;;;;GAQG;AACH,eAAO,MAAM,iBAAiB,qBAAqB,CAAC;AAEpD,+BAA+B;AAC/B,MAAM,WAAW,YAAY;IAC3B,0EAA0E;IAC1E,KAAK,EAAE,gBAAgB,CAAC;IACxB;;;;;;OAMG;IACH,WAAW,EAAE,cAAc,EAAE,CAAC;CAC/B;AAED,MAAM,WAAW,iBAAiB;IAChC,+EAA+E;IAC/E,WAAW,CAAC,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,GAAG,IAAI,CAAC,CAAC;IAC5F,+EAA+E;IAC/E,mBAAmB,CACjB,MAAM,EAAE,MAAM,EACd,cAAc,EAAE,MAAM,EACtB,MAAM,CAAC,EAAE,MAAM,GACd,OAAO,CAAC,YAAY,GAAG,IAAI,CAAC,CAAC;IAChC;;;;;;;;;;;OAWG;IACH,YAAY,CAAC,cAAc,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,EAAE,CAAC,CAAC;CAChF;AAED,MAAM,WAAW,wBAAwB;IACvC,YAAY,EAAE,YAAY,CAAC;IAC3B,YAAY,EAAE,YAAY,CAAC;CAC5B;AAED,wBAAgB,uBAAuB,CAAC,OAAO,EAAE,wBAAwB,GAAG,iBAAiB,CAmD5F"}
@@ -0,0 +1,121 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.REVOKED_BY_CLIENT = exports.REVOKED_CLIENT_WIDE = exports.REVOKED_BY_USER = void 0;
4
+ exports.createRevocationService = createRevocationService;
5
+ const grant_service_js_1 = require("./grant-service.js");
6
+ /**
7
+ * Disconnecting a client — the write behind the disconnect button, and behind
8
+ * the operator's answer to a compromised client.
9
+ *
10
+ * ## Soft revocation, and why the signal lives on the grant
11
+ *
12
+ * Nothing is deleted. The grant record is end-dated with a reason and the
13
+ * client's token rows are end-dated too, so cap-users' active-only resolution
14
+ * refuses the very next request whatever the token's stated lifetime says. The
15
+ * rows that are left behind are the audit trail, which is the point: a user who
16
+ * has just disconnected something wants to see that it stuck, and an operator
17
+ * investigating an incident wants to see what was once connected.
18
+ *
19
+ * **But the identity rows cannot carry the answer, in either direction.** An
20
+ * ended row does not mean revoked: `tokenService.resolve` end-dates a row the
21
+ * first time it reads an expired one, so ordinary expiry produces the same
22
+ * tombstone that revocation does. And a *live* row does not mean connected:
23
+ * rotation leaves the superseded refresh row deliberately active, so that
24
+ * presenting it again is recognisable as reuse rather than as a typo, and a
25
+ * connector in daily use therefore accumulates live rows it will never use
26
+ * again. Neither the presence nor the absence of a row is the signal.
27
+ *
28
+ * The signal is the **grant record**, which carries `ended_at` and a reason, and
29
+ * that is what the connected-clients screen and the resource-server guard both
30
+ * read. A test covers exactly this: a client that rotated three times and was
31
+ * then revoked still reads as revoked, and every one of the token rows those
32
+ * rotations left behind stops working.
33
+ *
34
+ * ## The order of the two writes
35
+ *
36
+ * The grant is ended **first**, then the tokens. Both orders leave the client
37
+ * locked out — the guard needs a live grant *and* a live token row, so ending
38
+ * either one is enough — but ending the grant first makes the lockout a single
39
+ * write. If the token sweep then fails halfway, access is already refused and
40
+ * the sweep is safe to retry, whereas the other order leaves a window in which
41
+ * some tokens still resolve against a live grant.
42
+ *
43
+ * ## Client-wide revocation finds its own users
44
+ *
45
+ * `revokeClient` takes a client and nothing else. It asks cap-users for every
46
+ * identity row whose key starts with `"<publicId>|"` — the scan the token wire
47
+ * format was shaped to make cheap — and sweeps the users that come back, one
48
+ * grant at a time. The operator answering a compromised client therefore does
49
+ * not have to already know the blast radius, which was the old shape's real
50
+ * cost: `revokeClientForUsers(userIds, ...)` was correct and swept exactly the
51
+ * accounts somebody had thought to name.
52
+ *
53
+ * The scan is still cap-users' query rather than ours (`IdentityStore`, backed
54
+ * by `findAllByDomainAndKeyPrefix`), so the one hard invariant is unchanged —
55
+ * this capability does not reach into the identity table, by prefix or
56
+ * otherwise.
57
+ *
58
+ * Two things it deliberately does **not** do. It does not read revocation off
59
+ * the identity rows: they say who this client once reached, and the grant
60
+ * record says whether anything is still connected. And it does not narrow the
61
+ * scan to live rows, because an idle connection's rows are all end-dated by the
62
+ * expiry sweep while its grant is perfectly alive.
63
+ */
64
+ /** The reason recorded when a person disconnects a client themselves. */
65
+ exports.REVOKED_BY_USER = 'user_revoked';
66
+ /** The reason recorded when an operator retires a client for everybody. */
67
+ exports.REVOKED_CLIENT_WIDE = 'client_revoked';
68
+ /**
69
+ * The reason recorded when the client itself hands a refresh token back at
70
+ * `POST /oauth/revoke` — RFC 7009's case, and the third of the three.
71
+ *
72
+ * It is a distinct word from `REVOKED_BY_USER` on purpose. Both are somebody
73
+ * choosing to disconnect, but only one of them was a person standing in front
74
+ * of this application's confirmation page; an operator reading the audit trail
75
+ * afterwards wants to know which door the disconnect came through.
76
+ */
77
+ exports.REVOKED_BY_CLIENT = 'client_requested';
78
+ function createRevocationService(options) {
79
+ const { grantService, tokenService } = options;
80
+ /** End the grant, then sweep its tokens. See the header for the order. */
81
+ async function revoke(userId, grant, reason) {
82
+ const ended = (await grantService.end(userId, grant.grant_id, reason)) ?? grant;
83
+ const endedTokens = await tokenService.revokeTokensForClient(userId, grant.client_public_id);
84
+ return { grant: ended, endedTokens };
85
+ }
86
+ return {
87
+ async revokeGrant(userId, grantId, reason = exports.REVOKED_BY_USER) {
88
+ const grant = await grantService.findById(userId, grantId);
89
+ if (!grant)
90
+ return null;
91
+ // An already-ended grant still gets its tokens swept. `grantService.end`
92
+ // keeps the original reason and timestamp, so this is idempotent rather
93
+ // than a second revocation, and it repairs a run that failed partway.
94
+ return revoke(userId, grant, reason);
95
+ },
96
+ async revokeClientForUser(userId, clientPublicId, reason = exports.REVOKED_BY_USER) {
97
+ const grant = await grantService.findActive(userId, clientPublicId);
98
+ if (!grant)
99
+ return null;
100
+ return revoke(userId, grant, reason);
101
+ },
102
+ async revokeClient(clientPublicId, reason = exports.REVOKED_CLIENT_WIDE) {
103
+ // Ended rows included, so a user whose tokens have all expired is still
104
+ // found — their grant is what says they are connected. See the header.
105
+ const userIds = await tokenService.findUserIdsForClient(clientPublicId);
106
+ const revoked = [];
107
+ for (const userId of userIds) {
108
+ const grants = await grantService.list(userId);
109
+ // Only this client's grants, matched on the full `client_public_id`
110
+ // rather than on a prefix of it: a `startsWith` here would be the
111
+ // too-broad scan that ends a neighbouring client's connections.
112
+ const mine = grants.filter((grant) => grant.client_public_id === clientPublicId && (0, grant_service_js_1.isGrantActive)(grant));
113
+ for (const grant of mine) {
114
+ revoked.push(await revoke(userId, grant, reason));
115
+ }
116
+ }
117
+ return revoked;
118
+ },
119
+ };
120
+ }
121
+ //# sourceMappingURL=revocation-service.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"revocation-service.js","sourceRoot":"","sources":["../src/revocation-service.ts"],"names":[],"mappings":";;;AA2HA,0DAmDC;AA9KD,yDAA6F;AAI7F;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAyDG;AAEH,yEAAyE;AAC5D,QAAA,eAAe,GAAG,cAAc,CAAC;AAE9C,2EAA2E;AAC9D,QAAA,mBAAmB,GAAG,gBAAgB,CAAC;AAEpD;;;;;;;;GAQG;AACU,QAAA,iBAAiB,GAAG,kBAAkB,CAAC;AA6CpD,SAAgB,uBAAuB,CAAC,OAAiC;IACvE,MAAM,EAAE,YAAY,EAAE,YAAY,EAAE,GAAG,OAAO,CAAC;IAE/C,0EAA0E;IAC1E,KAAK,UAAU,MAAM,CACnB,MAAc,EACd,KAAuB,EACvB,MAAc;QAEd,MAAM,KAAK,GAAG,CAAC,MAAM,YAAY,CAAC,GAAG,CAAC,MAAM,EAAE,KAAK,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAC,IAAI,KAAK,CAAC;QAChF,MAAM,WAAW,GAAG,MAAM,YAAY,CAAC,qBAAqB,CAAC,MAAM,EAAE,KAAK,CAAC,gBAAgB,CAAC,CAAC;QAC7F,OAAO,EAAE,KAAK,EAAE,KAAK,EAAE,WAAW,EAAE,CAAC;IACvC,CAAC;IAED,OAAO;QACL,KAAK,CAAC,WAAW,CAAC,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,uBAAe;YACzD,MAAM,KAAK,GAAG,MAAM,YAAY,CAAC,QAAQ,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;YAC3D,IAAI,CAAC,KAAK;gBAAE,OAAO,IAAI,CAAC;YACxB,yEAAyE;YACzE,wEAAwE;YACxE,sEAAsE;YACtE,OAAO,MAAM,CAAC,MAAM,EAAE,KAAK,EAAE,MAAM,CAAC,CAAC;QACvC,CAAC;QAED,KAAK,CAAC,mBAAmB,CAAC,MAAM,EAAE,cAAc,EAAE,MAAM,GAAG,uBAAe;YACxE,MAAM,KAAK,GAAG,MAAM,YAAY,CAAC,UAAU,CAAC,MAAM,EAAE,cAAc,CAAC,CAAC;YACpE,IAAI,CAAC,KAAK;gBAAE,OAAO,IAAI,CAAC;YACxB,OAAO,MAAM,CAAC,MAAM,EAAE,KAAK,EAAE,MAAM,CAAC,CAAC;QACvC,CAAC;QAED,KAAK,CAAC,YAAY,CAAC,cAAc,EAAE,MAAM,GAAG,2BAAmB;YAC7D,wEAAwE;YACxE,uEAAuE;YACvE,MAAM,OAAO,GAAG,MAAM,YAAY,CAAC,oBAAoB,CAAC,cAAc,CAAC,CAAC;YAExE,MAAM,OAAO,GAAmB,EAAE,CAAC;YACnC,KAAK,MAAM,MAAM,IAAI,OAAO,EAAE,CAAC;gBAC7B,MAAM,MAAM,GAAG,MAAM,YAAY,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;gBAC/C,oEAAoE;gBACpE,kEAAkE;gBAClE,gEAAgE;gBAChE,MAAM,IAAI,GAAG,MAAM,CAAC,MAAM,CACxB,CAAC,KAAK,EAAE,EAAE,CAAC,KAAK,CAAC,gBAAgB,KAAK,cAAc,IAAI,IAAA,gCAAa,EAAC,KAAK,CAAC,CAC7E,CAAC;gBACF,KAAK,MAAM,KAAK,IAAI,IAAI,EAAE,CAAC;oBACzB,OAAO,CAAC,IAAI,CAAC,MAAM,MAAM,CAAC,MAAM,EAAE,KAAK,EAAE,MAAM,CAAC,CAAC,CAAC;gBACpD,CAAC;YACH,CAAC;YACD,OAAO,OAAO,CAAC;QACjB,CAAC;KACF,CAAC;AACJ,CAAC"}
@@ -0,0 +1,79 @@
1
+ import { Router, type RequestHandler } from 'express';
2
+ import type { AuthorizationCodeService } from './authorization-code-service.js';
3
+ import type { ClientRegistryService } from './client-registry-service.js';
4
+ import type { GrantService } from './grant-service.js';
5
+ import { type TokenService } from './token-service.js';
6
+ /**
7
+ * `POST /oauth/token` — the two grants a boop application issues tokens for.
8
+ *
9
+ * ## Both body encodings, and only one of them is the specification's
10
+ *
11
+ * RFC 6749 §4.1.3 says `application/x-www-form-urlencoded`, full stop. ChatGPT
12
+ * sends JSON. So both parsers are mounted, and that is **tolerated input rather
13
+ * than a second supported format**: a client that reads the metadata and
14
+ * follows the RFC sends a form, and one that does not still connects. The
15
+ * umbrella spec lists "JSON-only token endpoints" among the donor defects; the
16
+ * mirror-image defect — form-only, in an ecosystem that demonstrably sends JSON
17
+ * — costs a user a connection they cannot debug.
18
+ *
19
+ * ## Reuse of a rotated refresh token revokes the whole grant
20
+ *
21
+ * Presenting a refresh token that has already been rotated is evidence of
22
+ * theft, because the legitimate client holds the newer one. The alternative —
23
+ * refusing the request and leaving the grant alive — lets an attacker who lost
24
+ * the race keep trying, and lets a compromised client keep working beside the
25
+ * real one. So all of the grant's `oauth-access` and `oauth-refresh` rows are
26
+ * end-dated, the grant is end-dated with `reason: "refresh_reuse"`, and the
27
+ * answer is `400 invalid_grant`.
28
+ *
29
+ * **How reuse is detected, since it is not obvious.** A rotated refresh token's
30
+ * identity row is left *active* rather than end-dated, and the grant records
31
+ * the identity key of the token that is currently valid. Presenting an old
32
+ * token therefore still resolves — which is the point, because resolution is
33
+ * the only thing that yields a user id, and without a user id there is no way
34
+ * to reach the grant at all (grants are user properties, and cap-users'
35
+ * `(domain, key)` lookup is active-only). The old row grants nothing: its key
36
+ * does not match `grant.refresh_key`, so it takes the theft branch. End-dating
37
+ * the row instead would make the presented token resolve to nothing, and a
38
+ * stolen token would then be indistinguishable from a typo — which is precisely
39
+ * the case this design exists to tell apart.
40
+ *
41
+ * Superseded rows are bounded: they age out under the same
42
+ * `createdAt + refresh ttl` rule as any other token, and revoking the grant
43
+ * end-dates all of them at once.
44
+ */
45
+ /** RFC 6749 §5.1's success body. */
46
+ export interface TokenResponse {
47
+ access_token: string;
48
+ token_type: 'Bearer';
49
+ expires_in: number;
50
+ refresh_token?: string;
51
+ scope: string;
52
+ }
53
+ export interface TokenHandlerDeps {
54
+ clientRegistry: ClientRegistryService;
55
+ tokenService: TokenService;
56
+ grantService: GrantService;
57
+ authorizationCodeService: AuthorizationCodeService;
58
+ }
59
+ /** The scope that earns a refresh token, per the discovery document. */
60
+ export declare const OFFLINE_ACCESS_SCOPE = "offline_access";
61
+ /** RFC 6749 §3.3: scopes are space-delimited. Tolerate any run of whitespace. */
62
+ export declare function parseScopes(raw: string | undefined): string[];
63
+ export declare function createTokenHandlers(deps: TokenHandlerDeps): {
64
+ token: RequestHandler;
65
+ };
66
+ export interface TokenRouterOptions extends TokenHandlerDeps {
67
+ /** Maximum accepted body size. A token request is a handful of fields. */
68
+ bodyLimit?: string;
69
+ }
70
+ /**
71
+ * `POST /oauth/token`, with both body parsers attached.
72
+ *
73
+ * The router parses its own body rather than relying on an app-level
74
+ * `express.urlencoded()`, so the endpoint behaves the same whatever order the
75
+ * host application mounts its middleware in — and so the limit here is the one
76
+ * that applies.
77
+ */
78
+ export declare function createTokenRouter(options: TokenRouterOptions): Router;
79
+ //# sourceMappingURL=token-controller.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"token-controller.d.ts","sourceRoot":"","sources":["../src/token-controller.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAkC,KAAK,cAAc,EAAiB,MAAM,SAAS,CAAC;AAErG,OAAO,KAAK,EAAE,wBAAwB,EAAiB,MAAM,iCAAiC,CAAC;AAE/F,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,8BAA8B,CAAC;AAC1E,OAAO,KAAK,EAAE,YAAY,EAAoB,MAAM,oBAAoB,CAAC;AAIzE,OAAO,EAGL,KAAK,YAAY,EAClB,MAAM,oBAAoB,CAAC;AAI5B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsCG;AAEH,oCAAoC;AACpC,MAAM,WAAW,aAAa;IAC5B,YAAY,EAAE,MAAM,CAAC;IACrB,UAAU,EAAE,QAAQ,CAAC;IACrB,UAAU,EAAE,MAAM,CAAC;IACnB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,KAAK,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,gBAAgB;IAC/B,cAAc,EAAE,qBAAqB,CAAC;IACtC,YAAY,EAAE,YAAY,CAAC;IAC3B,YAAY,EAAE,YAAY,CAAC;IAC3B,wBAAwB,EAAE,wBAAwB,CAAC;CACpD;AAED,wEAAwE;AACxE,eAAO,MAAM,oBAAoB,mBAAmB,CAAC;AAWrD,iFAAiF;AACjF,wBAAgB,WAAW,CAAC,GAAG,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,EAAE,CAG7D;AAED,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,gBAAgB,GAAG;IAAE,KAAK,EAAE,cAAc,CAAA;CAAE,CAiMrF;AAED,MAAM,WAAW,kBAAmB,SAAQ,gBAAgB;IAC1D,0EAA0E;IAC1E,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED;;;;;;;GAOG;AACH,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,kBAAkB,GAAG,MAAM,CAarE"}