@boopkit/cap-oauth-server 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/capability.json +120 -0
- package/dist/authorization-code-service.d.ts +105 -0
- package/dist/authorization-code-service.d.ts.map +1 -0
- package/dist/authorization-code-service.js +142 -0
- package/dist/authorization-code-service.js.map +1 -0
- package/dist/bearer.d.ts +146 -0
- package/dist/bearer.d.ts.map +1 -0
- package/dist/bearer.js +150 -0
- package/dist/bearer.js.map +1 -0
- package/dist/body-field.d.ts +24 -0
- package/dist/body-field.d.ts.map +1 -0
- package/dist/body-field.js +43 -0
- package/dist/body-field.js.map +1 -0
- package/dist/client-registry-service.d.ts +120 -0
- package/dist/client-registry-service.d.ts.map +1 -0
- package/dist/client-registry-service.js +450 -0
- package/dist/client-registry-service.js.map +1 -0
- package/dist/config.d.ts +82 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +157 -0
- package/dist/config.js.map +1 -0
- package/dist/connections-controller.d.ts +106 -0
- package/dist/connections-controller.d.ts.map +1 -0
- package/dist/connections-controller.js +161 -0
- package/dist/connections-controller.js.map +1 -0
- package/dist/consent-controller.d.ts +197 -0
- package/dist/consent-controller.d.ts.map +1 -0
- package/dist/consent-controller.js +532 -0
- package/dist/consent-controller.js.map +1 -0
- package/dist/discovery-controller.d.ts +16 -0
- package/dist/discovery-controller.d.ts.map +1 -0
- package/dist/discovery-controller.js +52 -0
- package/dist/discovery-controller.js.map +1 -0
- package/dist/discovery.d.ts +95 -0
- package/dist/discovery.d.ts.map +1 -0
- package/dist/discovery.js +101 -0
- package/dist/discovery.js.map +1 -0
- package/dist/errors.d.ts +26 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +41 -0
- package/dist/errors.js.map +1 -0
- package/dist/express.d.ts +66 -0
- package/dist/express.d.ts.map +1 -0
- package/dist/express.js +3 -0
- package/dist/express.js.map +1 -0
- package/dist/grant-service.d.ts +102 -0
- package/dist/grant-service.d.ts.map +1 -0
- package/dist/grant-service.js +137 -0
- package/dist/grant-service.js.map +1 -0
- package/dist/index.d.ts +67 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +148 -0
- package/dist/index.js.map +1 -0
- package/dist/metadata-document.d.ts +60 -0
- package/dist/metadata-document.d.ts.map +1 -0
- package/dist/metadata-document.js +148 -0
- package/dist/metadata-document.js.map +1 -0
- package/dist/oauth-error.d.ts +45 -0
- package/dist/oauth-error.d.ts.map +1 -0
- package/dist/oauth-error.js +51 -0
- package/dist/oauth-error.js.map +1 -0
- package/dist/pkce.d.ts +29 -0
- package/dist/pkce.d.ts.map +1 -0
- package/dist/pkce.js +65 -0
- package/dist/pkce.js.map +1 -0
- package/dist/property-array.d.ts +60 -0
- package/dist/property-array.d.ts.map +1 -0
- package/dist/property-array.js +31 -0
- package/dist/property-array.js.map +1 -0
- package/dist/public-id.d.ts +35 -0
- package/dist/public-id.d.ts.map +1 -0
- package/dist/public-id.js +76 -0
- package/dist/public-id.js.map +1 -0
- package/dist/rate-limit.d.ts +56 -0
- package/dist/rate-limit.d.ts.map +1 -0
- package/dist/rate-limit.js +55 -0
- package/dist/rate-limit.js.map +1 -0
- package/dist/redirect-uri.d.ts +89 -0
- package/dist/redirect-uri.d.ts.map +1 -0
- package/dist/redirect-uri.js +249 -0
- package/dist/redirect-uri.js.map +1 -0
- package/dist/registration-controller.d.ts +62 -0
- package/dist/registration-controller.d.ts.map +1 -0
- package/dist/registration-controller.js +79 -0
- package/dist/registration-controller.js.map +1 -0
- package/dist/request-client.d.ts +19 -0
- package/dist/request-client.d.ts.map +1 -0
- package/dist/request-client.js +32 -0
- package/dist/request-client.js.map +1 -0
- package/dist/revocation-controller.d.ts +28 -0
- package/dist/revocation-controller.d.ts.map +1 -0
- package/dist/revocation-controller.js +176 -0
- package/dist/revocation-controller.js.map +1 -0
- package/dist/revocation-service.d.ts +113 -0
- package/dist/revocation-service.d.ts.map +1 -0
- package/dist/revocation-service.js +121 -0
- package/dist/revocation-service.js.map +1 -0
- package/dist/token-controller.d.ts +79 -0
- package/dist/token-controller.d.ts.map +1 -0
- package/dist/token-controller.js +207 -0
- package/dist/token-controller.js.map +1 -0
- package/dist/token-service.d.ts +175 -0
- package/dist/token-service.d.ts.map +1 -0
- package/dist/token-service.js +251 -0
- package/dist/token-service.js.map +1 -0
- package/dist/types.d.ts +130 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +13 -0
- package/dist/types.js.map +1 -0
- package/dist/user-store.d.ts +110 -0
- package/dist/user-store.d.ts.map +1 -0
- package/dist/user-store.js +40 -0
- package/dist/user-store.js.map +1 -0
- package/package.json +37 -0
- package/scaffold/prisma/schema/oauth-clients.prisma +80 -0
- package/scaffold/reference/oauth-server.ref.md +478 -0
- package/scaffold/src/middleware/oauth-server.ts +227 -0
- package/scaffold/src/scripts/seed-oauth-clients.ts +72 -0
- package/scaffold/src/services/oauth-server.service.ts +101 -0
- package/scaffold/styles/oauth-server.css +345 -0
- package/scaffold/views/oauth-connected-clients.hbs +98 -0
- package/scaffold/views/oauth-consent-error.hbs +22 -0
- package/scaffold/views/oauth-consent.hbs +138 -0
- package/scaffold/views/oauth-disconnect-confirm.hbs +57 -0
|
@@ -0,0 +1,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":"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"}
|
|
@@ -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
|