@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,207 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.OFFLINE_ACCESS_SCOPE = void 0;
4
+ exports.parseScopes = parseScopes;
5
+ exports.createTokenHandlers = createTokenHandlers;
6
+ exports.createTokenRouter = createTokenRouter;
7
+ const express_1 = require("express");
8
+ const body_field_js_1 = require("./body-field.js");
9
+ const oauth_error_js_1 = require("./oauth-error.js");
10
+ const pkce_js_1 = require("./pkce.js");
11
+ const request_client_js_1 = require("./request-client.js");
12
+ const token_service_js_1 = require("./token-service.js");
13
+ const user_store_js_1 = require("./user-store.js");
14
+ /** The scope that earns a refresh token, per the discovery document. */
15
+ exports.OFFLINE_ACCESS_SCOPE = 'offline_access';
16
+ /** Redemption failures, mapped to the wire. */
17
+ const REDEEM_ERROR = {
18
+ unknown_code: 'the authorization code is unknown, expired or already used',
19
+ already_redeemed: 'the authorization code has already been redeemed',
20
+ expired: 'the authorization code has expired',
21
+ client_mismatch: 'the authorization code was issued to a different client',
22
+ redirect_uri_mismatch: 'redirect_uri does not match the one the code was issued for',
23
+ };
24
+ /** RFC 6749 §3.3: scopes are space-delimited. Tolerate any run of whitespace. */
25
+ function parseScopes(raw) {
26
+ if (!raw)
27
+ return [];
28
+ return [...new Set(raw.split(/\s+/).filter(Boolean))];
29
+ }
30
+ function createTokenHandlers(deps) {
31
+ const { clientRegistry, tokenService, grantService, authorizationCodeService } = deps;
32
+ /** Shared with `/oauth/revoke`, so the two doors cannot disagree. */
33
+ const resolveClient = (body) => (0, request_client_js_1.resolveRequestClient)(clientRegistry, body);
34
+ /**
35
+ * End-date every token the grant holds, then the grant itself.
36
+ *
37
+ * The rows go first. The grant's `ended_at` is what the guard reads on the
38
+ * next request, so ending it first would leave a window in which the grant
39
+ * looked dead while its tokens were still resolvable to anyone reading the
40
+ * identity rows directly.
41
+ */
42
+ async function revokeGrant(userId, grant, reason) {
43
+ await tokenService.revokeTokensForClient(userId, grant.client_public_id);
44
+ await grantService.end(userId, grant.grant_id, reason);
45
+ }
46
+ /** Mint the pair and answer. One place, so the two grants cannot drift. */
47
+ async function issueTokens(userId, client, grant) {
48
+ const access = await tokenService.mint(userId, token_service_js_1.OAUTH_ACCESS_DOMAIN, client.publicId);
49
+ const response = {
50
+ access_token: access.token,
51
+ token_type: 'Bearer',
52
+ expires_in: tokenService.ttlSecondsFor(token_service_js_1.OAUTH_ACCESS_DOMAIN),
53
+ // The GRANT's scopes, not anything the request asked for. The token
54
+ // carries no copy and the guard reads the grant, so reporting anything
55
+ // else here would be describing a credential that does not exist.
56
+ scope: grant.scopes.join(' '),
57
+ };
58
+ if (grant.scopes.includes(exports.OFFLINE_ACCESS_SCOPE)) {
59
+ const refresh = await tokenService.mint(userId, token_service_js_1.OAUTH_REFRESH_DOMAIN, client.publicId);
60
+ // Recording the new key is what invalidates the old one: the previous
61
+ // token still resolves, and now takes the theft branch.
62
+ await grantService.setRefreshKey(userId, grant.grant_id, refresh.identityKey);
63
+ response.refresh_token = refresh.token;
64
+ }
65
+ return response;
66
+ }
67
+ async function authorizationCodeGrant(body) {
68
+ const client = await resolveClient(body);
69
+ const code = (0, body_field_js_1.required)(body, 'code');
70
+ const redirectUri = (0, body_field_js_1.required)(body, 'redirect_uri');
71
+ // PKCE is required, not optional: OAuth 2.1 requires it for public clients
72
+ // and every client here is public.
73
+ const verifier = (0, body_field_js_1.required)(body, 'code_verifier');
74
+ let redeemed;
75
+ try {
76
+ redeemed = await authorizationCodeService.redeem({
77
+ code,
78
+ clientId: client.clientId,
79
+ clientPublicId: client.publicId,
80
+ redirectUri,
81
+ });
82
+ }
83
+ catch (err) {
84
+ // The single-use claim is the property write, so losing its revision
85
+ // check means another request redeemed this code first.
86
+ if ((0, user_store_js_1.isStaleWriteError)(err)) {
87
+ throw new oauth_error_js_1.OAuthError('invalid_grant', 'the authorization code has already been redeemed');
88
+ }
89
+ throw err;
90
+ }
91
+ if (!redeemed.ok) {
92
+ throw new oauth_error_js_1.OAuthError('invalid_grant', REDEEM_ERROR[redeemed.reason]);
93
+ }
94
+ const { record, userId } = redeemed;
95
+ if (!(0, pkce_js_1.verifyCodeChallenge)(verifier, record.code_challenge, record.code_challenge_method)) {
96
+ // The code is already spent at this point, and deliberately so: a failed
97
+ // verifier is a redemption attempt, and letting the code survive it would
98
+ // hand an attacker unlimited guesses at the challenge.
99
+ throw new oauth_error_js_1.OAuthError('invalid_grant', 'code_verifier does not match the code_challenge');
100
+ }
101
+ // The code's scopes become the grant's — replacing, never unioning. This is
102
+ // the write the whole consent screen exists to authorise.
103
+ const grant = await grantService.approve(userId, {
104
+ clientId: client.clientId,
105
+ clientPublicId: client.publicId,
106
+ scopes: record.scopes,
107
+ resource: record.resource,
108
+ });
109
+ return issueTokens(userId, client, grant);
110
+ }
111
+ async function refreshTokenGrant(body) {
112
+ const client = await resolveClient(body);
113
+ const presented = (0, body_field_js_1.required)(body, 'refresh_token');
114
+ const resolved = await tokenService.resolve(token_service_js_1.OAUTH_REFRESH_DOMAIN, presented);
115
+ // Unknown, malformed, expired (and swept), or revoked. All one answer.
116
+ if (!resolved) {
117
+ throw new oauth_error_js_1.OAuthError('invalid_grant', 'the refresh token is unknown, expired or revoked');
118
+ }
119
+ if (resolved.clientPublicId !== client.publicId) {
120
+ throw new oauth_error_js_1.OAuthError('invalid_grant', 'the refresh token was issued to a different client');
121
+ }
122
+ const grant = await grantService.findActive(resolved.userId, client.publicId);
123
+ if (!grant) {
124
+ throw new oauth_error_js_1.OAuthError('invalid_grant', 'this grant has been revoked');
125
+ }
126
+ if (grant.refresh_key !== resolved.identityKey) {
127
+ // Reuse of a rotated token. See the header for why the old row is still
128
+ // resolvable and why this is the whole grant's problem rather than this
129
+ // request's.
130
+ await revokeGrant(resolved.userId, grant, 'refresh_reuse');
131
+ throw new oauth_error_js_1.OAuthError('invalid_grant', 'this refresh token has already been rotated; the grant has been revoked');
132
+ }
133
+ // RFC 6749 §6 lets a client ask for a narrower scope on refresh. The grant
134
+ // remains the authority, so the request is validated as a subset and the
135
+ // response still reports the grant's set — a token that claimed a narrower
136
+ // audience than the guard will enforce would be a lie.
137
+ const requested = parseScopes((0, body_field_js_1.field)(body, 'scope'));
138
+ const exceeded = requested.filter((scope) => !grant.scopes.includes(scope));
139
+ if (exceeded.length > 0) {
140
+ throw new oauth_error_js_1.OAuthError('invalid_scope', `not covered by this grant: ${exceeded.join(' ')}`);
141
+ }
142
+ try {
143
+ const response = await issueTokens(resolved.userId, client, grant);
144
+ // The old row stays active on purpose — it is how the next presentation
145
+ // of this token is recognised as reuse rather than as a typo.
146
+ return response;
147
+ }
148
+ catch (err) {
149
+ if ((0, user_store_js_1.isStaleWriteError)(err)) {
150
+ // Somebody else moved the grant underneath us — a concurrent refresh,
151
+ // or a revoke landing at the same moment. Retryable, so say so rather
152
+ // than telling the client to throw its credentials away.
153
+ throw new oauth_error_js_1.OAuthError('temporarily_unavailable', 'the grant changed while refreshing');
154
+ }
155
+ throw err;
156
+ }
157
+ }
158
+ const token = async (req, res, next) => {
159
+ // §5.1 and §5.2 both: a token response must never be cached, whichever way
160
+ // it turned out.
161
+ res.set('Cache-Control', 'no-store');
162
+ res.set('Pragma', 'no-cache');
163
+ try {
164
+ const grantType = (0, body_field_js_1.required)(req.body, 'grant_type');
165
+ let response;
166
+ if (grantType === 'authorization_code') {
167
+ response = await authorizationCodeGrant(req.body);
168
+ }
169
+ else if (grantType === 'refresh_token') {
170
+ response = await refreshTokenGrant(req.body);
171
+ }
172
+ else {
173
+ throw new oauth_error_js_1.OAuthError('unsupported_grant_type', `this server issues tokens for authorization_code and refresh_token, not ${grantType}`);
174
+ }
175
+ res.status(200).json(response);
176
+ }
177
+ catch (err) {
178
+ if ((0, oauth_error_js_1.isOAuthError)(err)) {
179
+ if (err.error === 'invalid_client') {
180
+ // §5.2: a 401 here needs a challenge, or a client cannot tell an
181
+ // authentication failure from a malformed request.
182
+ res.set('WWW-Authenticate', 'Bearer');
183
+ }
184
+ res.status(err.status).json(err.toBody());
185
+ return;
186
+ }
187
+ next(err);
188
+ }
189
+ };
190
+ return { token };
191
+ }
192
+ /**
193
+ * `POST /oauth/token`, with both body parsers attached.
194
+ *
195
+ * The router parses its own body rather than relying on an app-level
196
+ * `express.urlencoded()`, so the endpoint behaves the same whatever order the
197
+ * host application mounts its middleware in — and so the limit here is the one
198
+ * that applies.
199
+ */
200
+ function createTokenRouter(options) {
201
+ const handlers = createTokenHandlers(options);
202
+ const router = (0, express_1.Router)();
203
+ const limit = options.bodyLimit ?? '16kb';
204
+ router.post('/oauth/token', (0, express_1.urlencoded)({ extended: false, limit }), (0, express_1.json)({ limit }), handlers.token);
205
+ return router;
206
+ }
207
+ //# sourceMappingURL=token-controller.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"token-controller.js","sourceRoot":"","sources":["../src/token-controller.ts"],"names":[],"mappings":";;;AAsFA,kCAGC;AAED,kDAiMC;AAeD,8CAaC;AAxTD,qCAAqG;AAGrG,mDAAkD;AAGlD,qDAA4D;AAC5D,uCAAgD;AAChD,2DAA2D;AAC3D,yDAI4B;AAE5B,mDAAoD;AA0DpD,wEAAwE;AAC3D,QAAA,oBAAoB,GAAG,gBAAgB,CAAC;AAErD,+CAA+C;AAC/C,MAAM,YAAY,GAAkC;IAClD,YAAY,EAAE,4DAA4D;IAC1E,gBAAgB,EAAE,kDAAkD;IACpE,OAAO,EAAE,oCAAoC;IAC7C,eAAe,EAAE,yDAAyD;IAC1E,qBAAqB,EAAE,6DAA6D;CACrF,CAAC;AAEF,iFAAiF;AACjF,SAAgB,WAAW,CAAC,GAAuB;IACjD,IAAI,CAAC,GAAG;QAAE,OAAO,EAAE,CAAC;IACpB,OAAO,CAAC,GAAG,IAAI,GAAG,CAAC,GAAG,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC;AACxD,CAAC;AAED,SAAgB,mBAAmB,CAAC,IAAsB;IACxD,MAAM,EAAE,cAAc,EAAE,YAAY,EAAE,YAAY,EAAE,wBAAwB,EAAE,GAAG,IAAI,CAAC;IAEtF,qEAAqE;IACrE,MAAM,aAAa,GAAG,CAAC,IAAa,EAAE,EAAE,CAAC,IAAA,wCAAoB,EAAC,cAAc,EAAE,IAAI,CAAC,CAAC;IAEpF;;;;;;;OAOG;IACH,KAAK,UAAU,WAAW,CACxB,MAAc,EACd,KAAuB,EACvB,MAAc;QAEd,MAAM,YAAY,CAAC,qBAAqB,CAAC,MAAM,EAAE,KAAK,CAAC,gBAAgB,CAAC,CAAC;QACzE,MAAM,YAAY,CAAC,GAAG,CAAC,MAAM,EAAE,KAAK,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAC;IACzD,CAAC;IAED,2EAA2E;IAC3E,KAAK,UAAU,WAAW,CACxB,MAAc,EACd,MAAmB,EACnB,KAAuB;QAEvB,MAAM,MAAM,GAAG,MAAM,YAAY,CAAC,IAAI,CAAC,MAAM,EAAE,sCAAmB,EAAE,MAAM,CAAC,QAAQ,CAAC,CAAC;QAErF,MAAM,QAAQ,GAAkB;YAC9B,YAAY,EAAE,MAAM,CAAC,KAAK;YAC1B,UAAU,EAAE,QAAQ;YACpB,UAAU,EAAE,YAAY,CAAC,aAAa,CAAC,sCAAmB,CAAC;YAC3D,oEAAoE;YACpE,uEAAuE;YACvE,kEAAkE;YAClE,KAAK,EAAE,KAAK,CAAC,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC;SAC9B,CAAC;QAEF,IAAI,KAAK,CAAC,MAAM,CAAC,QAAQ,CAAC,4BAAoB,CAAC,EAAE,CAAC;YAChD,MAAM,OAAO,GAAG,MAAM,YAAY,CAAC,IAAI,CAAC,MAAM,EAAE,uCAAoB,EAAE,MAAM,CAAC,QAAQ,CAAC,CAAC;YACvF,sEAAsE;YACtE,wDAAwD;YACxD,MAAM,YAAY,CAAC,aAAa,CAAC,MAAM,EAAE,KAAK,CAAC,QAAQ,EAAE,OAAO,CAAC,WAAW,CAAC,CAAC;YAC9E,QAAQ,CAAC,aAAa,GAAG,OAAO,CAAC,KAAK,CAAC;QACzC,CAAC;QAED,OAAO,QAAQ,CAAC;IAClB,CAAC;IAED,KAAK,UAAU,sBAAsB,CAAC,IAAa;QACjD,MAAM,MAAM,GAAG,MAAM,aAAa,CAAC,IAAI,CAAC,CAAC;QACzC,MAAM,IAAI,GAAG,IAAA,wBAAQ,EAAC,IAAI,EAAE,MAAM,CAAC,CAAC;QACpC,MAAM,WAAW,GAAG,IAAA,wBAAQ,EAAC,IAAI,EAAE,cAAc,CAAC,CAAC;QACnD,2EAA2E;QAC3E,mCAAmC;QACnC,MAAM,QAAQ,GAAG,IAAA,wBAAQ,EAAC,IAAI,EAAE,eAAe,CAAC,CAAC;QAEjD,IAAI,QAAQ,CAAC;QACb,IAAI,CAAC;YACH,QAAQ,GAAG,MAAM,wBAAwB,CAAC,MAAM,CAAC;gBAC/C,IAAI;gBACJ,QAAQ,EAAE,MAAM,CAAC,QAAQ;gBACzB,cAAc,EAAE,MAAM,CAAC,QAAQ;gBAC/B,WAAW;aACZ,CAAC,CAAC;QACL,CAAC;QAAC,OAAO,GAAG,EAAE,CAAC;YACb,qEAAqE;YACrE,wDAAwD;YACxD,IAAI,IAAA,iCAAiB,EAAC,GAAG,CAAC,EAAE,CAAC;gBAC3B,MAAM,IAAI,2BAAU,CAAC,eAAe,EAAE,kDAAkD,CAAC,CAAC;YAC5F,CAAC;YACD,MAAM,GAAG,CAAC;QACZ,CAAC;QAED,IAAI,CAAC,QAAQ,CAAC,EAAE,EAAE,CAAC;YACjB,MAAM,IAAI,2BAAU,CAAC,eAAe,EAAE,YAAY,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC,CAAC;QACvE,CAAC;QAED,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,GAAG,QAAQ,CAAC;QACpC,IAAI,CAAC,IAAA,6BAAmB,EAAC,QAAQ,EAAE,MAAM,CAAC,cAAc,EAAE,MAAM,CAAC,qBAAqB,CAAC,EAAE,CAAC;YACxF,yEAAyE;YACzE,0EAA0E;YAC1E,uDAAuD;YACvD,MAAM,IAAI,2BAAU,CAAC,eAAe,EAAE,iDAAiD,CAAC,CAAC;QAC3F,CAAC;QAED,4EAA4E;QAC5E,0DAA0D;QAC1D,MAAM,KAAK,GAAG,MAAM,YAAY,CAAC,OAAO,CAAC,MAAM,EAAE;YAC/C,QAAQ,EAAE,MAAM,CAAC,QAAQ;YACzB,cAAc,EAAE,MAAM,CAAC,QAAQ;YAC/B,MAAM,EAAE,MAAM,CAAC,MAAM;YACrB,QAAQ,EAAE,MAAM,CAAC,QAAQ;SAC1B,CAAC,CAAC;QAEH,OAAO,WAAW,CAAC,MAAM,EAAE,MAAM,EAAE,KAAK,CAAC,CAAC;IAC5C,CAAC;IAED,KAAK,UAAU,iBAAiB,CAAC,IAAa;QAC5C,MAAM,MAAM,GAAG,MAAM,aAAa,CAAC,IAAI,CAAC,CAAC;QACzC,MAAM,SAAS,GAAG,IAAA,wBAAQ,EAAC,IAAI,EAAE,eAAe,CAAC,CAAC;QAElD,MAAM,QAAQ,GAAG,MAAM,YAAY,CAAC,OAAO,CAAC,uCAAoB,EAAE,SAAS,CAAC,CAAC;QAC7E,uEAAuE;QACvE,IAAI,CAAC,QAAQ,EAAE,CAAC;YACd,MAAM,IAAI,2BAAU,CAAC,eAAe,EAAE,kDAAkD,CAAC,CAAC;QAC5F,CAAC;QACD,IAAI,QAAQ,CAAC,cAAc,KAAK,MAAM,CAAC,QAAQ,EAAE,CAAC;YAChD,MAAM,IAAI,2BAAU,CAAC,eAAe,EAAE,oDAAoD,CAAC,CAAC;QAC9F,CAAC;QAED,MAAM,KAAK,GAAG,MAAM,YAAY,CAAC,UAAU,CAAC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC,QAAQ,CAAC,CAAC;QAC9E,IAAI,CAAC,KAAK,EAAE,CAAC;YACX,MAAM,IAAI,2BAAU,CAAC,eAAe,EAAE,6BAA6B,CAAC,CAAC;QACvE,CAAC;QAED,IAAI,KAAK,CAAC,WAAW,KAAK,QAAQ,CAAC,WAAW,EAAE,CAAC;YAC/C,wEAAwE;YACxE,wEAAwE;YACxE,aAAa;YACb,MAAM,WAAW,CAAC,QAAQ,CAAC,MAAM,EAAE,KAAK,EAAE,eAAe,CAAC,CAAC;YAC3D,MAAM,IAAI,2BAAU,CAClB,eAAe,EACf,yEAAyE,CAC1E,CAAC;QACJ,CAAC;QAED,2EAA2E;QAC3E,yEAAyE;QACzE,2EAA2E;QAC3E,uDAAuD;QACvD,MAAM,SAAS,GAAG,WAAW,CAAC,IAAA,qBAAK,EAAC,IAAI,EAAE,OAAO,CAAC,CAAC,CAAC;QACpD,MAAM,QAAQ,GAAG,SAAS,CAAC,MAAM,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,CAAC,KAAK,CAAC,MAAM,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAC,CAAC;QAC5E,IAAI,QAAQ,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;YACxB,MAAM,IAAI,2BAAU,CAAC,eAAe,EAAE,8BAA8B,QAAQ,CAAC,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC;QAC5F,CAAC;QAED,IAAI,CAAC;YACH,MAAM,QAAQ,GAAG,MAAM,WAAW,CAAC,QAAQ,CAAC,MAAM,EAAE,MAAM,EAAE,KAAK,CAAC,CAAC;YACnE,wEAAwE;YACxE,8DAA8D;YAC9D,OAAO,QAAQ,CAAC;QAClB,CAAC;QAAC,OAAO,GAAG,EAAE,CAAC;YACb,IAAI,IAAA,iCAAiB,EAAC,GAAG,CAAC,EAAE,CAAC;gBAC3B,sEAAsE;gBACtE,sEAAsE;gBACtE,yDAAyD;gBACzD,MAAM,IAAI,2BAAU,CAAC,yBAAyB,EAAE,oCAAoC,CAAC,CAAC;YACxF,CAAC;YACD,MAAM,GAAG,CAAC;QACZ,CAAC;IACH,CAAC;IAED,MAAM,KAAK,GAAmB,KAAK,EAAE,GAAY,EAAE,GAAa,EAAE,IAAI,EAAE,EAAE;QACxE,2EAA2E;QAC3E,iBAAiB;QACjB,GAAG,CAAC,GAAG,CAAC,eAAe,EAAE,UAAU,CAAC,CAAC;QACrC,GAAG,CAAC,GAAG,CAAC,QAAQ,EAAE,UAAU,CAAC,CAAC;QAE9B,IAAI,CAAC;YACH,MAAM,SAAS,GAAG,IAAA,wBAAQ,EAAC,GAAG,CAAC,IAAI,EAAE,YAAY,CAAC,CAAC;YAEnD,IAAI,QAAuB,CAAC;YAC5B,IAAI,SAAS,KAAK,oBAAoB,EAAE,CAAC;gBACvC,QAAQ,GAAG,MAAM,sBAAsB,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;YACpD,CAAC;iBAAM,IAAI,SAAS,KAAK,eAAe,EAAE,CAAC;gBACzC,QAAQ,GAAG,MAAM,iBAAiB,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;YAC/C,CAAC;iBAAM,CAAC;gBACN,MAAM,IAAI,2BAAU,CAClB,wBAAwB,EACxB,2EAA2E,SAAS,EAAE,CACvF,CAAC;YACJ,CAAC;YAED,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC;QACjC,CAAC;QAAC,OAAO,GAAG,EAAE,CAAC;YACb,IAAI,IAAA,6BAAY,EAAC,GAAG,CAAC,EAAE,CAAC;gBACtB,IAAI,GAAG,CAAC,KAAK,KAAK,gBAAgB,EAAE,CAAC;oBACnC,iEAAiE;oBACjE,mDAAmD;oBACnD,GAAG,CAAC,GAAG,CAAC,kBAAkB,EAAE,QAAQ,CAAC,CAAC;gBACxC,CAAC;gBACD,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,MAAM,EAAE,CAAC,CAAC;gBAC1C,OAAO;YACT,CAAC;YACD,IAAI,CAAC,GAAG,CAAC,CAAC;QACZ,CAAC;IACH,CAAC,CAAC;IAEF,OAAO,EAAE,KAAK,EAAE,CAAC;AACnB,CAAC;AAOD;;;;;;;GAOG;AACH,SAAgB,iBAAiB,CAAC,OAA2B;IAC3D,MAAM,QAAQ,GAAG,mBAAmB,CAAC,OAAO,CAAC,CAAC;IAC9C,MAAM,MAAM,GAAG,IAAA,gBAAM,GAAE,CAAC;IACxB,MAAM,KAAK,GAAG,OAAO,CAAC,SAAS,IAAI,MAAM,CAAC;IAE1C,MAAM,CAAC,IAAI,CACT,cAAc,EACd,IAAA,oBAAU,EAAC,EAAE,QAAQ,EAAE,KAAK,EAAE,KAAK,EAAE,CAAC,EACtC,IAAA,cAAI,EAAC,EAAE,KAAK,EAAE,CAAC,EACf,QAAQ,CAAC,KAAK,CACf,CAAC;IAEF,OAAO,MAAM,CAAC;AAChB,CAAC"}
@@ -0,0 +1,175 @@
1
+ import type { IdentityStore, StoredIdentity } from './user-store.js';
2
+ /**
3
+ * Minting, parsing and resolving the three OAuth credentials.
4
+ *
5
+ * ## The wire format, and why it has a prefix at all
6
+ *
7
+ * `<client publicId>.<generateToken(partition)>`. Splitting on the **first**
8
+ * `.` yields the client that the token was issued to before any database is
9
+ * touched, which is what makes client-wide revocation a key-prefix scan
10
+ * (`key LIKE '<publicId>|%'`) instead of a full table walk. `publicId` is
11
+ * twelve lowercase base36 characters precisely so that neither separator can
12
+ * appear in it — see `public-id.ts`.
13
+ *
14
+ * The stored identity key is `"<publicId>|<sha256 of the whole token>"`. The
15
+ * raw token is never stored anywhere, in either half of the two-read model:
16
+ * `tests/packages/cap-oauth-server-credentials-at-rest.test.ts` runs a complete
17
+ * flow and reads both property rows back to prove it.
18
+ *
19
+ * ## Expiry is `createdAt + ttl`, and the sweep is not an optimisation
20
+ *
21
+ * The plan corrects both the spec and the findings here, and the correction is
22
+ * the load-bearing part of this file. cap-users' `endedAt` is a **tombstone,
23
+ * not a scheduled expiry**: `findByDomainAndKey` returns `null` whenever
24
+ * `endedAt !== null`, so a *future* `endedAt` would read as already ended, and
25
+ * `endDate(domain, key)` takes no timestamp and always writes `new Date()`.
26
+ * A token's lifetime therefore has no home on the identity row.
27
+ *
28
+ * So a token is expired when `identity.createdAt + ttl < now`, where the TTL is
29
+ * configuration. That comparison is retroactive — it changes its answer for
30
+ * every existing row the moment the configuration changes — which is why
31
+ * `resolve` **writes `endedAt` on the first expired read**. Without that write,
32
+ * lengthening `OAUTH_ACCESS_TOKEN_TTL_SECONDS` would resurrect every token that
33
+ * had expired but whose row nobody had happened to look at. With it, the row is
34
+ * permanently dead the first time anybody notices, whatever the configuration
35
+ * does afterwards.
36
+ *
37
+ * Rotation then gives the refresh token a sliding window for free: each
38
+ * rotation mints a new row with a fresh `createdAt`, so a connector in daily use
39
+ * never expires and an abandoned one does.
40
+ *
41
+ * ## The authorization code is not governed by this rule
42
+ *
43
+ * Its `oauth-codes` property entry carries an explicit `expires_at` and that
44
+ * field stays the authority. The 120 seconds below is what writes it.
45
+ */
46
+ /** The three identity domains the settled data model fixes. */
47
+ export declare const OAUTH_CODE_DOMAIN = "oauth-code";
48
+ export declare const OAUTH_ACCESS_DOMAIN = "oauth-access";
49
+ export declare const OAUTH_REFRESH_DOMAIN = "oauth-refresh";
50
+ export type OAuthTokenDomain = typeof OAUTH_CODE_DOMAIN | typeof OAUTH_ACCESS_DOMAIN | typeof OAUTH_REFRESH_DOMAIN;
51
+ /**
52
+ * 120 seconds, and deliberately not an environment variable.
53
+ *
54
+ * The OAuth 2.1 draft caps the code at ten minutes and the security BCP
55
+ * recommends about sixty seconds. Every legitimate redemption is a back-channel
56
+ * POST made within a second of the redirect — but Claude's hosted callback puts
57
+ * a third-party server-side hop in the middle whose latency we do not control,
58
+ * and a code that is a little too short reads as a compliance bug and gets
59
+ * debugged as one. Two minutes doubles the BCP's number and stays an order of
60
+ * magnitude under the ceiling.
61
+ *
62
+ * It is not configuration because no application legitimately wants a longer
63
+ * window, and a code TTL is exactly the number that gets raised to "fix" an
64
+ * unrelated bug. The service option below exists so tests can force expiry
65
+ * without waiting; it is not wired to any `env` entry.
66
+ */
67
+ export declare const AUTHORIZATION_CODE_TTL_SECONDS = 120;
68
+ /** A freshly minted credential: the raw value, and where it now lives. */
69
+ export interface MintedToken {
70
+ /** The value handed to the client. Never stored, never logged. */
71
+ token: string;
72
+ /** `"<publicId>|<sha256>"` — the `user_identities.key` that was written. */
73
+ identityKey: string;
74
+ clientPublicId: string;
75
+ domain: OAuthTokenDomain;
76
+ identity: StoredIdentity;
77
+ /** `createdAt + ttl`, computed for the caller's `expires_in`. */
78
+ expiresAt: Date;
79
+ }
80
+ /** A presented token that resolved to a live row. */
81
+ export interface ResolvedToken {
82
+ userId: number;
83
+ clientPublicId: string;
84
+ identityKey: string;
85
+ identity: StoredIdentity;
86
+ expiresAt: Date;
87
+ }
88
+ export interface TokenServiceOptions {
89
+ identityStore: IdentityStore;
90
+ /** Seconds. Clamped at construction; see `clampTtlSeconds`. */
91
+ accessTokenTtlSeconds: number;
92
+ /** Seconds. Clamped at construction. Sliding, by way of rotation. */
93
+ refreshTokenTtlSeconds: number;
94
+ /**
95
+ * Seconds. Defaults to `AUTHORIZATION_CODE_TTL_SECONDS`; a test sets it to
96
+ * zero to force expiry rather than sleeping for two minutes.
97
+ */
98
+ authorizationCodeTtlSeconds?: number;
99
+ /**
100
+ * Rows per page in the client-wide identity scan. Defaults to cap-users'
101
+ * own page size.
102
+ *
103
+ * The seam exists so a test can prove the scan keeps reading past the end of
104
+ * a page without standing up a hundred users; no `env` entry sets it.
105
+ */
106
+ clientScanPageSize?: number;
107
+ /** Injectable clock, so expiry tests do not sleep. */
108
+ now?: () => Date;
109
+ /** Where a clamp is announced. Once, at construction. */
110
+ warn?(message: string): void;
111
+ }
112
+ export interface TokenService {
113
+ /** `<publicId>.<random>`. Exposed for tests and for the format's own tests. */
114
+ formatToken(clientPublicId: string, secret: string): string;
115
+ /** Split on the first `.`, or answer that this is not one of our tokens. */
116
+ parseToken(raw: string): {
117
+ clientPublicId: string;
118
+ secret: string;
119
+ } | null;
120
+ /** `"<publicId>|<sha256 of the whole token>"`. */
121
+ identityKeyFor(raw: string): string | null;
122
+ /** Seconds this species of token lives for. Read once, at construction. */
123
+ ttlSecondsFor(domain: OAuthTokenDomain): number;
124
+ /** Mint, hash, and write the identity row. */
125
+ mint(userId: number, domain: OAuthTokenDomain, clientPublicId: string): Promise<MintedToken>;
126
+ /**
127
+ * Resolve a presented token, applying the expiry rule and sweeping an expired
128
+ * row's `endedAt` on the way past. `null` means "not a live credential" for
129
+ * every reason: malformed, unknown, revoked, or expired.
130
+ */
131
+ resolve(domain: OAuthTokenDomain, raw: string): Promise<ResolvedToken | null>;
132
+ /** End-date a token's row by its raw value. Idempotent enough to retry. */
133
+ revoke(domain: OAuthTokenDomain, raw: string): Promise<void>;
134
+ /** End-date a token's row by its stored key — the revocation path's shape. */
135
+ revokeByKey(domain: OAuthTokenDomain, identityKey: string): Promise<void>;
136
+ /**
137
+ * Every live token row this user holds for this client, across both token
138
+ * domains. The enumeration revocation needs, since `endDate` is keyed on
139
+ * `(domain, key)` and cannot take a predicate.
140
+ */
141
+ findTokensForClient(userId: number, clientPublicId: string): Promise<StoredIdentity[]>;
142
+ /**
143
+ * End-date every live access and refresh row this user holds for this client,
144
+ * and answer with the rows that were ended.
145
+ *
146
+ * The revocation half of `findTokensForClient`. It lives here rather than in a
147
+ * caller because the `"<publicId>|"` key prefix and the two token domains are
148
+ * this file's knowledge — a caller that reproduced the prefix scan would be
149
+ * the second place that has to be right about the wire format, and a prefix
150
+ * one character too short is exactly the bug that ends one user's *other*
151
+ * connections.
152
+ */
153
+ revokeTokensForClient(userId: number, clientPublicId: string): Promise<StoredIdentity[]>;
154
+ /**
155
+ * Every user this client ever held a token for — the enumeration behind
156
+ * client-wide revocation, and the reason the wire format puts the client
157
+ * first.
158
+ *
159
+ * **Ended rows count.** An idle connection whose access and refresh rows have
160
+ * both been swept by the expiry rule is still a live grant and still a person
161
+ * who has this client connected; reading live rows only would answer that
162
+ * nobody uses a client half the deployment uses. The rows are never deleted,
163
+ * so "ever issued a token" is the honest question to ask of them, and the
164
+ * grant record — read by the caller, per user — is what decides whether there
165
+ * is anything left to revoke.
166
+ *
167
+ * It is the caller's job to do something with the answer, not this file's: a
168
+ * user id here means a token was minted once, nothing more.
169
+ */
170
+ findUserIdsForClient(clientPublicId: string): Promise<number[]>;
171
+ }
172
+ /** The key half of the wire format, kept next to the token half. */
173
+ export declare function buildIdentityKey(clientPublicId: string, rawToken: string): string;
174
+ export declare function createTokenService(options: TokenServiceOptions): TokenService;
175
+ //# sourceMappingURL=token-service.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"token-service.d.ts","sourceRoot":"","sources":["../src/token-service.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,251 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.AUTHORIZATION_CODE_TTL_SECONDS = exports.OAUTH_REFRESH_DOMAIN = exports.OAUTH_ACCESS_DOMAIN = exports.OAUTH_CODE_DOMAIN = void 0;
4
+ exports.buildIdentityKey = buildIdentityKey;
5
+ exports.createTokenService = createTokenService;
6
+ const cap_users_1 = require("@boopkit/cap-users");
7
+ const config_js_1 = require("./config.js");
8
+ const public_id_js_1 = require("./public-id.js");
9
+ /**
10
+ * Minting, parsing and resolving the three OAuth credentials.
11
+ *
12
+ * ## The wire format, and why it has a prefix at all
13
+ *
14
+ * `<client publicId>.<generateToken(partition)>`. Splitting on the **first**
15
+ * `.` yields the client that the token was issued to before any database is
16
+ * touched, which is what makes client-wide revocation a key-prefix scan
17
+ * (`key LIKE '<publicId>|%'`) instead of a full table walk. `publicId` is
18
+ * twelve lowercase base36 characters precisely so that neither separator can
19
+ * appear in it — see `public-id.ts`.
20
+ *
21
+ * The stored identity key is `"<publicId>|<sha256 of the whole token>"`. The
22
+ * raw token is never stored anywhere, in either half of the two-read model:
23
+ * `tests/packages/cap-oauth-server-credentials-at-rest.test.ts` runs a complete
24
+ * flow and reads both property rows back to prove it.
25
+ *
26
+ * ## Expiry is `createdAt + ttl`, and the sweep is not an optimisation
27
+ *
28
+ * The plan corrects both the spec and the findings here, and the correction is
29
+ * the load-bearing part of this file. cap-users' `endedAt` is a **tombstone,
30
+ * not a scheduled expiry**: `findByDomainAndKey` returns `null` whenever
31
+ * `endedAt !== null`, so a *future* `endedAt` would read as already ended, and
32
+ * `endDate(domain, key)` takes no timestamp and always writes `new Date()`.
33
+ * A token's lifetime therefore has no home on the identity row.
34
+ *
35
+ * So a token is expired when `identity.createdAt + ttl < now`, where the TTL is
36
+ * configuration. That comparison is retroactive — it changes its answer for
37
+ * every existing row the moment the configuration changes — which is why
38
+ * `resolve` **writes `endedAt` on the first expired read**. Without that write,
39
+ * lengthening `OAUTH_ACCESS_TOKEN_TTL_SECONDS` would resurrect every token that
40
+ * had expired but whose row nobody had happened to look at. With it, the row is
41
+ * permanently dead the first time anybody notices, whatever the configuration
42
+ * does afterwards.
43
+ *
44
+ * Rotation then gives the refresh token a sliding window for free: each
45
+ * rotation mints a new row with a fresh `createdAt`, so a connector in daily use
46
+ * never expires and an abandoned one does.
47
+ *
48
+ * ## The authorization code is not governed by this rule
49
+ *
50
+ * Its `oauth-codes` property entry carries an explicit `expires_at` and that
51
+ * field stays the authority. The 120 seconds below is what writes it.
52
+ */
53
+ /** The three identity domains the settled data model fixes. */
54
+ exports.OAUTH_CODE_DOMAIN = 'oauth-code';
55
+ exports.OAUTH_ACCESS_DOMAIN = 'oauth-access';
56
+ exports.OAUTH_REFRESH_DOMAIN = 'oauth-refresh';
57
+ /** `generateToken`'s partition segment, one per credential species. */
58
+ const PARTITION = {
59
+ [exports.OAUTH_CODE_DOMAIN]: 'AC',
60
+ [exports.OAUTH_ACCESS_DOMAIN]: 'AT',
61
+ [exports.OAUTH_REFRESH_DOMAIN]: 'RT',
62
+ };
63
+ /**
64
+ * 120 seconds, and deliberately not an environment variable.
65
+ *
66
+ * The OAuth 2.1 draft caps the code at ten minutes and the security BCP
67
+ * recommends about sixty seconds. Every legitimate redemption is a back-channel
68
+ * POST made within a second of the redirect — but Claude's hosted callback puts
69
+ * a third-party server-side hop in the middle whose latency we do not control,
70
+ * and a code that is a little too short reads as a compliance bug and gets
71
+ * debugged as one. Two minutes doubles the BCP's number and stays an order of
72
+ * magnitude under the ceiling.
73
+ *
74
+ * It is not configuration because no application legitimately wants a longer
75
+ * window, and a code TTL is exactly the number that gets raised to "fix" an
76
+ * unrelated bug. The service option below exists so tests can force expiry
77
+ * without waiting; it is not wired to any `env` entry.
78
+ */
79
+ exports.AUTHORIZATION_CODE_TTL_SECONDS = 120;
80
+ /** The key half of the wire format, kept next to the token half. */
81
+ function buildIdentityKey(clientPublicId, rawToken) {
82
+ return `${clientPublicId}|${(0, cap_users_1.hashToken)(rawToken)}`;
83
+ }
84
+ function createTokenService(options) {
85
+ const { identityStore, clientScanPageSize } = options;
86
+ const now = options.now ?? (() => new Date());
87
+ const warn = options.warn ?? ((message) => console.warn(`[cap-oauth-server] ${message}`));
88
+ // Read once, here, rather than per request: a running process must have one
89
+ // answer, and a clamp that fires per request is a log line nobody reads.
90
+ const ttlSeconds = {
91
+ [exports.OAUTH_ACCESS_DOMAIN]: (0, config_js_1.clampTtlSeconds)(options.accessTokenTtlSeconds, config_js_1.ACCESS_TOKEN_TTL_BOUNDS, 'OAUTH_ACCESS_TOKEN_TTL_SECONDS', warn),
92
+ [exports.OAUTH_REFRESH_DOMAIN]: (0, config_js_1.clampTtlSeconds)(options.refreshTokenTtlSeconds, config_js_1.REFRESH_TOKEN_TTL_BOUNDS, 'OAUTH_REFRESH_TOKEN_TTL_SECONDS', warn),
93
+ // Not clamped and not configurable — see the constant's note. The option is
94
+ // the test seam, and a test that asks for zero means zero.
95
+ [exports.OAUTH_CODE_DOMAIN]: options.authorizationCodeTtlSeconds ?? exports.AUTHORIZATION_CODE_TTL_SECONDS,
96
+ };
97
+ function formatToken(clientPublicId, secret) {
98
+ return `${clientPublicId}.${secret}`;
99
+ }
100
+ function parseToken(raw) {
101
+ if (typeof raw !== 'string')
102
+ return null;
103
+ // The FIRST dot. `generateToken` produces hyphens, never dots, so today
104
+ // there is only one — but splitting on the last would turn a future format
105
+ // change into a silent misparse rather than a loud one.
106
+ const dot = raw.indexOf('.');
107
+ if (dot <= 0)
108
+ return null;
109
+ const clientPublicId = raw.slice(0, dot);
110
+ const secret = raw.slice(dot + 1);
111
+ // A prefix that is not a well-formed publicId cannot be one of ours, and
112
+ // rejecting it here keeps a hostile prefix out of the `LIKE` scan revocation
113
+ // is built on.
114
+ if (!(0, public_id_js_1.isValidClientPublicId)(clientPublicId))
115
+ return null;
116
+ if (secret === '')
117
+ return null;
118
+ return { clientPublicId, secret };
119
+ }
120
+ function identityKeyFor(raw) {
121
+ const parsed = parseToken(raw);
122
+ if (!parsed)
123
+ return null;
124
+ return buildIdentityKey(parsed.clientPublicId, raw);
125
+ }
126
+ function expiryOf(domain, createdAt) {
127
+ return new Date(createdAt.getTime() + ttlSeconds[domain] * 1000);
128
+ }
129
+ /**
130
+ * Every live token row this user holds for this client.
131
+ *
132
+ * The prefix is `"<publicId>|"` with the separator, never the bare publicId:
133
+ * publicIds are fixed-length so no one is a prefix of another today, but a
134
+ * scan that leaned on that would end a different client's tokens the moment
135
+ * the length ever changed.
136
+ */
137
+ async function findTokensForClient(userId, clientPublicId) {
138
+ const prefix = `${clientPublicId}|`;
139
+ const rows = await identityStore.findAllByUser(userId);
140
+ return rows.filter((row) => (row.domain === exports.OAUTH_ACCESS_DOMAIN || row.domain === exports.OAUTH_REFRESH_DOMAIN) &&
141
+ row.key.startsWith(prefix));
142
+ }
143
+ /**
144
+ * Every user id with a token row under this client's prefix, ended or not.
145
+ *
146
+ * Paged, and deliberately: this is the one read here that is bounded by a
147
+ * client rather than by a person, so a deployment's whole token history for a
148
+ * popular client can sit behind it.
149
+ */
150
+ async function findUserIdsForClient(clientPublicId) {
151
+ // The same guard `parseToken` applies to a presented token, for the same
152
+ // reason: this value is about to anchor a `LIKE` scan across the identity
153
+ // table, and a value that was never one of our public ids has no business
154
+ // being what it is anchored on.
155
+ if (!(0, public_id_js_1.isValidClientPublicId)(clientPublicId)) {
156
+ throw new TypeError(`Unable to find the users of client "${clientPublicId}": that is not a well-formed client public id. ` +
157
+ 'Pass the twelve-character publicId from the client registry, not the client_id URL.');
158
+ }
159
+ const prefix = `${clientPublicId}|`;
160
+ const userIds = new Set();
161
+ // Both token domains, because a row in either is proof this client reached
162
+ // this person, and neither domain is guaranteed to still hold one.
163
+ for (const domain of [exports.OAUTH_ACCESS_DOMAIN, exports.OAUTH_REFRESH_DOMAIN]) {
164
+ let after;
165
+ for (;;) {
166
+ const page = await identityStore.findAllByDomainAndKeyPrefix(domain, prefix, {
167
+ includeEnded: true,
168
+ limit: clientScanPageSize,
169
+ after,
170
+ });
171
+ for (const row of page.identities) {
172
+ userIds.add(row.userId);
173
+ }
174
+ if (page.nextCursor == null)
175
+ break;
176
+ after = page.nextCursor;
177
+ }
178
+ }
179
+ return [...userIds];
180
+ }
181
+ return {
182
+ formatToken,
183
+ parseToken,
184
+ identityKeyFor,
185
+ ttlSecondsFor(domain) {
186
+ return ttlSeconds[domain];
187
+ },
188
+ async mint(userId, domain, clientPublicId) {
189
+ const token = formatToken(clientPublicId, (0, cap_users_1.generateToken)(PARTITION[domain]));
190
+ const identityKey = buildIdentityKey(clientPublicId, token);
191
+ const identity = await identityStore.create(userId, domain, identityKey);
192
+ return {
193
+ token,
194
+ identityKey,
195
+ clientPublicId,
196
+ domain,
197
+ identity,
198
+ expiresAt: expiryOf(domain, identity.createdAt),
199
+ };
200
+ },
201
+ async resolve(domain, raw) {
202
+ const parsed = parseToken(raw);
203
+ if (!parsed)
204
+ return null;
205
+ const identityKey = buildIdentityKey(parsed.clientPublicId, raw);
206
+ const identity = await identityStore.findByDomainAndKey(domain, identityKey);
207
+ // Active-only, so this covers revoked as well as unknown. Nothing here
208
+ // distinguishes the two for the caller, and nothing should: telling a
209
+ // client which of the two it hit is telling it whether the token was ever
210
+ // real.
211
+ if (!identity)
212
+ return null;
213
+ const expiresAt = expiryOf(domain, identity.createdAt);
214
+ if (expiresAt.getTime() < now().getTime()) {
215
+ // The sweep. Correctness, not housekeeping — see the header.
216
+ await identityStore.endDate(domain, identityKey);
217
+ return null;
218
+ }
219
+ return {
220
+ userId: identity.userId,
221
+ clientPublicId: parsed.clientPublicId,
222
+ identityKey,
223
+ identity,
224
+ expiresAt,
225
+ };
226
+ },
227
+ async revoke(domain, raw) {
228
+ const identityKey = identityKeyFor(raw);
229
+ if (!identityKey)
230
+ return;
231
+ await identityStore.endDate(domain, identityKey);
232
+ },
233
+ async revokeByKey(domain, identityKey) {
234
+ await identityStore.endDate(domain, identityKey);
235
+ },
236
+ findTokensForClient,
237
+ findUserIdsForClient,
238
+ async revokeTokensForClient(userId, clientPublicId) {
239
+ // Active rows only, so an already-ended row keeps the `endedAt` it has
240
+ // rather than being restamped with this moment. It also means this sweeps
241
+ // the rows rotation left behind: a superseded refresh row stays active on
242
+ // purpose, and so does the access token from before the last refresh.
243
+ const rows = await findTokensForClient(userId, clientPublicId);
244
+ for (const row of rows) {
245
+ await identityStore.endDate(row.domain, row.key);
246
+ }
247
+ return rows;
248
+ },
249
+ };
250
+ }
251
+ //# sourceMappingURL=token-service.js.map