@serialsubscriptions/platform-integration 0.0.79
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/README.md +1 -0
- package/lib/SSIProject.d.ts +343 -0
- package/lib/SSIProject.js +429 -0
- package/lib/SSIProjectApi.d.ts +384 -0
- package/lib/SSIProjectApi.js +534 -0
- package/lib/SSISubscribedFeatureApi.d.ts +387 -0
- package/lib/SSISubscribedFeatureApi.js +511 -0
- package/lib/SSISubscribedLimitApi.d.ts +384 -0
- package/lib/SSISubscribedLimitApi.js +534 -0
- package/lib/SSISubscribedPlanApi.d.ts +384 -0
- package/lib/SSISubscribedPlanApi.js +537 -0
- package/lib/SubscribedPlanManager.d.ts +380 -0
- package/lib/SubscribedPlanManager.js +288 -0
- package/lib/UsageApi.d.ts +128 -0
- package/lib/UsageApi.js +224 -0
- package/lib/auth.server.d.ts +192 -0
- package/lib/auth.server.js +579 -0
- package/lib/cache/SSICache.d.ts +40 -0
- package/lib/cache/SSICache.js +134 -0
- package/lib/cache/backends/MemoryCacheBackend.d.ts +15 -0
- package/lib/cache/backends/MemoryCacheBackend.js +46 -0
- package/lib/cache/backends/RedisCacheBackend.d.ts +27 -0
- package/lib/cache/backends/RedisCacheBackend.js +95 -0
- package/lib/cache/constants.d.ts +7 -0
- package/lib/cache/constants.js +10 -0
- package/lib/cache/types.d.ts +27 -0
- package/lib/cache/types.js +2 -0
- package/lib/frontend/index.d.ts +1 -0
- package/lib/frontend/index.js +6 -0
- package/lib/frontend/session/SessionClient.d.ts +24 -0
- package/lib/frontend/session/SessionClient.js +145 -0
- package/lib/index.d.ts +15 -0
- package/lib/index.js +38 -0
- package/lib/lib/session/SessionClient.d.ts +11 -0
- package/lib/lib/session/SessionClient.js +47 -0
- package/lib/lib/session/index.d.ts +3 -0
- package/lib/lib/session/index.js +3 -0
- package/lib/lib/session/stores/MemoryStore.d.ts +7 -0
- package/lib/lib/session/stores/MemoryStore.js +23 -0
- package/lib/lib/session/stores/index.d.ts +1 -0
- package/lib/lib/session/stores/index.js +1 -0
- package/lib/lib/session/types.d.ts +37 -0
- package/lib/lib/session/types.js +1 -0
- package/lib/session/SessionClient.d.ts +19 -0
- package/lib/session/SessionClient.js +132 -0
- package/lib/session/SessionManager.d.ts +139 -0
- package/lib/session/SessionManager.js +443 -0
- package/lib/stateStore.d.ts +5 -0
- package/lib/stateStore.js +9 -0
- package/lib/storage/SSIStorage.d.ts +24 -0
- package/lib/storage/SSIStorage.js +117 -0
- package/lib/storage/backends/MemoryBackend.d.ts +10 -0
- package/lib/storage/backends/MemoryBackend.js +44 -0
- package/lib/storage/backends/PostgresBackend.d.ts +24 -0
- package/lib/storage/backends/PostgresBackend.js +106 -0
- package/lib/storage/backends/RedisBackend.d.ts +19 -0
- package/lib/storage/backends/RedisBackend.js +78 -0
- package/lib/storage/types.d.ts +27 -0
- package/lib/storage/types.js +2 -0
- package/package.json +71 -0
|
@@ -0,0 +1,579 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// packages/platform-integration/src/auth.server.ts
|
|
3
|
+
// Production-grade JWT verification using jose library
|
|
4
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
5
|
+
if (k2 === undefined) k2 = k;
|
|
6
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
7
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
8
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
9
|
+
}
|
|
10
|
+
Object.defineProperty(o, k2, desc);
|
|
11
|
+
}) : (function(o, m, k, k2) {
|
|
12
|
+
if (k2 === undefined) k2 = k;
|
|
13
|
+
o[k2] = m[k];
|
|
14
|
+
}));
|
|
15
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
16
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
17
|
+
}) : function(o, v) {
|
|
18
|
+
o["default"] = v;
|
|
19
|
+
});
|
|
20
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
21
|
+
var ownKeys = function(o) {
|
|
22
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
23
|
+
var ar = [];
|
|
24
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
25
|
+
return ar;
|
|
26
|
+
};
|
|
27
|
+
return ownKeys(o);
|
|
28
|
+
};
|
|
29
|
+
return function (mod) {
|
|
30
|
+
if (mod && mod.__esModule) return mod;
|
|
31
|
+
var result = {};
|
|
32
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
33
|
+
__setModuleDefault(result, mod);
|
|
34
|
+
return result;
|
|
35
|
+
};
|
|
36
|
+
})();
|
|
37
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
38
|
+
exports.AuthServer = exports.scopes = void 0;
|
|
39
|
+
const crypto = __importStar(require("crypto"));
|
|
40
|
+
const jose_1 = require("jose");
|
|
41
|
+
const stateStore_1 = require("./stateStore");
|
|
42
|
+
const SSICache_1 = require("./cache/SSICache");
|
|
43
|
+
const constants_1 = require("./cache/constants");
|
|
44
|
+
exports.scopes = {
|
|
45
|
+
defaultScopes: "openid profile email view_project create_project delete_project update_project view_subscribed_plan view_subscribed_feature view_subscribed_limit access_subscription_usage"
|
|
46
|
+
};
|
|
47
|
+
// Helper to normalize issuer URLs by removing trailing slashes
|
|
48
|
+
function normalizeIssuer(url) {
|
|
49
|
+
return url.replace(/\/+$/, "");
|
|
50
|
+
}
|
|
51
|
+
class AuthServer {
|
|
52
|
+
constructor(config) {
|
|
53
|
+
// Use empty object as default if config is not provided
|
|
54
|
+
const cfg = config ?? {};
|
|
55
|
+
let scopesString;
|
|
56
|
+
if (cfg.scopes === undefined || cfg.scopes === null) {
|
|
57
|
+
scopesString = exports.scopes.defaultScopes;
|
|
58
|
+
}
|
|
59
|
+
else if (Array.isArray(cfg.scopes)) {
|
|
60
|
+
scopesString = cfg.scopes.length > 0 ? cfg.scopes.join(" ") : exports.scopes.defaultScopes;
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
scopesString = cfg.scopes.trim() || exports.scopes.defaultScopes;
|
|
64
|
+
}
|
|
65
|
+
// Get values from config or fall back to environment variables
|
|
66
|
+
const issuerBaseUrlRaw = cfg.issuerBaseUrl ?? process.env.SSI_ISSUER_BASE_URL ?? "";
|
|
67
|
+
const clientId = cfg.clientId ?? process.env.SSI_CLIENT_ID ?? "";
|
|
68
|
+
const clientSecret = cfg.clientSecret ?? process.env.SSI_CLIENT_SECRET ?? "";
|
|
69
|
+
const redirectUriRaw = cfg.redirectUri ?? process.env.SSI_REDIRECT_URI ?? "";
|
|
70
|
+
const jwksPath = cfg.jwksPath ?? process.env.SSI_ISSUER_JWKS_PATH ?? "/.well-known/jwks.json";
|
|
71
|
+
// Validate required values
|
|
72
|
+
if (!issuerBaseUrlRaw) {
|
|
73
|
+
throw new Error("issuerBaseUrl is required. Provide it in config or set SSI_ISSUER_BASE_URL environment variable.");
|
|
74
|
+
}
|
|
75
|
+
if (!clientId) {
|
|
76
|
+
throw new Error("clientId is required. Provide it in config or set SSI_CLIENT_ID environment variable.");
|
|
77
|
+
}
|
|
78
|
+
// Create both normalized forms (with and without trailing slash)
|
|
79
|
+
// This allows JWT validation to succeed regardless of how the issuer formats their iss claim
|
|
80
|
+
const issuerNoSlash = normalizeIssuer(issuerBaseUrlRaw);
|
|
81
|
+
const issuerWithSlash = issuerNoSlash + "/";
|
|
82
|
+
const issuerAccepted = [issuerNoSlash, issuerWithSlash];
|
|
83
|
+
// Normalize redirectUri using URL class
|
|
84
|
+
const redirectUri = redirectUriRaw ? new URL(redirectUriRaw).toString() : "";
|
|
85
|
+
// Normalize jwksUri if provided, otherwise construct it
|
|
86
|
+
const jwksUri = cfg.jwksUri
|
|
87
|
+
? (cfg.jwksUri.startsWith('http://') || cfg.jwksUri.startsWith('https://'))
|
|
88
|
+
? new URL(cfg.jwksUri).toString()
|
|
89
|
+
: new URL(cfg.jwksUri, issuerNoSlash).toString()
|
|
90
|
+
: `${issuerNoSlash}${jwksPath.startsWith("/") ? jwksPath : `/${jwksPath}`}`;
|
|
91
|
+
// Resolve logout endpoint: config > full URL env > issuer + path env > default /user/logout
|
|
92
|
+
const logoutPathEnv = process.env.SSI_ISSUER_LOGOUT_PATH?.trim();
|
|
93
|
+
const logoutEndpointEnv = process.env.SSI_LOGOUT_ENDPOINT?.trim();
|
|
94
|
+
const resolvedLogoutEndpointRaw = cfg.logoutEndpoint
|
|
95
|
+
?? (logoutEndpointEnv || `${issuerNoSlash}${logoutPathEnv ? (logoutPathEnv.startsWith('/') ? logoutPathEnv : `/${logoutPathEnv}`) : '/user/logout'}`);
|
|
96
|
+
// Normalize logout endpoint using URL class (handle relative paths by constructing relative to issuer)
|
|
97
|
+
const resolvedLogoutEndpoint = resolvedLogoutEndpointRaw
|
|
98
|
+
? (resolvedLogoutEndpointRaw.startsWith('http://') || resolvedLogoutEndpointRaw.startsWith('https://'))
|
|
99
|
+
? new URL(resolvedLogoutEndpointRaw).toString()
|
|
100
|
+
: new URL(resolvedLogoutEndpointRaw, issuerNoSlash).toString()
|
|
101
|
+
: "";
|
|
102
|
+
// Normalize authorization and token endpoints if provided, otherwise construct them
|
|
103
|
+
const authorizationEndpoint = cfg.authorizationEndpoint
|
|
104
|
+
? (cfg.authorizationEndpoint.startsWith('http://') || cfg.authorizationEndpoint.startsWith('https://'))
|
|
105
|
+
? new URL(cfg.authorizationEndpoint).toString()
|
|
106
|
+
: new URL(cfg.authorizationEndpoint, issuerNoSlash).toString()
|
|
107
|
+
: `${issuerNoSlash}/oauth/authorize`;
|
|
108
|
+
const tokenEndpoint = cfg.tokenEndpoint
|
|
109
|
+
? (cfg.tokenEndpoint.startsWith('http://') || cfg.tokenEndpoint.startsWith('https://'))
|
|
110
|
+
? new URL(cfg.tokenEndpoint).toString()
|
|
111
|
+
: new URL(cfg.tokenEndpoint, issuerNoSlash).toString()
|
|
112
|
+
: `${issuerNoSlash}/oauth/token`;
|
|
113
|
+
this.cfg = {
|
|
114
|
+
issuerBaseUrl: issuerBaseUrlRaw, // keep as provided for URLs we build
|
|
115
|
+
authorizationEndpoint,
|
|
116
|
+
tokenEndpoint,
|
|
117
|
+
logoutEndpoint: resolvedLogoutEndpoint,
|
|
118
|
+
jwksUri,
|
|
119
|
+
jwks: cfg.jwks,
|
|
120
|
+
clientId,
|
|
121
|
+
clientSecret,
|
|
122
|
+
redirectUri,
|
|
123
|
+
audience: cfg.audience ?? "",
|
|
124
|
+
scopes: scopesString,
|
|
125
|
+
issuerAccepted, // Both forms accepted for JWT validation
|
|
126
|
+
};
|
|
127
|
+
// Initialize state storage: use provided storage or default from environment
|
|
128
|
+
this.stateStorage = cfg.storage
|
|
129
|
+
? cfg.storage.withClass('state')
|
|
130
|
+
: stateStore_1.stateStorage;
|
|
131
|
+
// Initialize JWKS cache with "jwks" prefix
|
|
132
|
+
this.jwksCache = SSICache_1.SSICache.fromEnv().withPrefix('jwks');
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Build the authorization URL and persist a CSRF state for the callback.
|
|
136
|
+
* Returns: { url, stateKey, stateValue }
|
|
137
|
+
*/
|
|
138
|
+
async getLoginUrl(opts = {}) {
|
|
139
|
+
const stateKey = opts.stateKey ?? crypto.randomUUID();
|
|
140
|
+
const stateValue = crypto.randomUUID(); // CSRF token value
|
|
141
|
+
await this.stateStorage.set(stateKey, 'value', stateValue, opts.stateTtlSeconds ?? 600);
|
|
142
|
+
const u = new URL(this.cfg.authorizationEndpoint);
|
|
143
|
+
u.searchParams.set("response_type", "code");
|
|
144
|
+
u.searchParams.set("client_id", this.cfg.clientId);
|
|
145
|
+
u.searchParams.set("redirect_uri", this.cfg.redirectUri);
|
|
146
|
+
u.searchParams.set("scope", this.cfg.scopes);
|
|
147
|
+
u.searchParams.set("state", `${stateKey}:${stateValue}`);
|
|
148
|
+
if (this.cfg.audience)
|
|
149
|
+
u.searchParams.set("audience", this.cfg.audience);
|
|
150
|
+
if (opts.extraParams) {
|
|
151
|
+
for (const [k, v] of Object.entries(opts.extraParams))
|
|
152
|
+
u.searchParams.set(k, v);
|
|
153
|
+
}
|
|
154
|
+
return { url: u.toString(), stateKey, stateValue };
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Build the logout URL for RP-initiated logout.
|
|
158
|
+
* Common params: id_token_hint, post_logout_redirect_uri
|
|
159
|
+
*/
|
|
160
|
+
getLogoutUrl(opts = {}) {
|
|
161
|
+
// Ensure logout URL uses the same origin as the authorization endpoint
|
|
162
|
+
const authOrigin = new URL(this.cfg.authorizationEndpoint).origin;
|
|
163
|
+
const configured = this.cfg.logoutEndpoint;
|
|
164
|
+
let u;
|
|
165
|
+
if (configured && /^https?:\/\//i.test(configured)) {
|
|
166
|
+
const tmp = new URL(configured);
|
|
167
|
+
u = new URL(tmp.pathname + tmp.search, authOrigin);
|
|
168
|
+
}
|
|
169
|
+
else {
|
|
170
|
+
const path = configured && configured.length > 0
|
|
171
|
+
? (configured.startsWith('/') ? configured : `/${configured}`)
|
|
172
|
+
: '/user/logout';
|
|
173
|
+
u = new URL(path, authOrigin);
|
|
174
|
+
}
|
|
175
|
+
if (opts.idTokenHint)
|
|
176
|
+
u.searchParams.set('id_token_hint', opts.idTokenHint);
|
|
177
|
+
// Use provided postLogoutRedirectUri, or default to NEXT_PUBLIC_APP_URL if available
|
|
178
|
+
const postLogoutRedirectUriRaw = opts.postLogoutRedirectUri ?? process.env.NEXT_PUBLIC_APP_URL;
|
|
179
|
+
if (postLogoutRedirectUriRaw) {
|
|
180
|
+
// Normalize the URL using URL class
|
|
181
|
+
const normalizedPostLogoutUri = new URL(postLogoutRedirectUriRaw).toString();
|
|
182
|
+
u.searchParams.set('post_logout_redirect_uri', normalizedPostLogoutUri);
|
|
183
|
+
}
|
|
184
|
+
if (opts.extraParams) {
|
|
185
|
+
for (const [k, v] of Object.entries(opts.extraParams))
|
|
186
|
+
u.searchParams.set(k, v);
|
|
187
|
+
}
|
|
188
|
+
return { url: u.toString() };
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Handle the OAuth callback: validates state and exchanges the code for tokens.
|
|
192
|
+
* Returns TokenResponse (and includes raw for debugging).
|
|
193
|
+
*/
|
|
194
|
+
async handleCallback(params) {
|
|
195
|
+
const code = params.code ?? "";
|
|
196
|
+
const state = params.state ?? "";
|
|
197
|
+
if (!code || !state) {
|
|
198
|
+
throw new Error("Missing code or state.");
|
|
199
|
+
}
|
|
200
|
+
// State validation
|
|
201
|
+
// Expect state to be `${stateKey}:${stateValue}`
|
|
202
|
+
// Use split(":", 2) to only split on the first colon, in case stateValue somehow contains colons
|
|
203
|
+
const parts = state.split(":", 2);
|
|
204
|
+
if (parts.length !== 2 || !parts[0] || !parts[1]) {
|
|
205
|
+
throw new Error(`Invalid state format. Expected "stateKey:stateValue", got: ${state.substring(0, 100)}`);
|
|
206
|
+
}
|
|
207
|
+
const [stateKey, stateValue] = parts;
|
|
208
|
+
const storedValue = await this.stateStorage.get(stateKey, 'value');
|
|
209
|
+
const expected = storedValue === null ? undefined : storedValue;
|
|
210
|
+
if (!expected) {
|
|
211
|
+
throw new Error(`Invalid or expired state. State key "${stateKey}" not found in store. This usually means the state has expired or was never stored.`);
|
|
212
|
+
}
|
|
213
|
+
if (expected !== stateValue) {
|
|
214
|
+
throw new Error(`Invalid state. State value mismatch for key "${stateKey}". Expected "${expected}", got "${stateValue}".`);
|
|
215
|
+
}
|
|
216
|
+
await this.stateStorage.del(stateKey, 'value');
|
|
217
|
+
// Token exchange (authorization_code)
|
|
218
|
+
const body = new URLSearchParams({
|
|
219
|
+
grant_type: "authorization_code",
|
|
220
|
+
code,
|
|
221
|
+
redirect_uri: this.cfg.redirectUri,
|
|
222
|
+
client_id: this.cfg.clientId,
|
|
223
|
+
});
|
|
224
|
+
// Confidential client uses client_secret_post for PoC
|
|
225
|
+
if (this.cfg.clientSecret) {
|
|
226
|
+
body.set("client_secret", this.cfg.clientSecret);
|
|
227
|
+
}
|
|
228
|
+
const resp = await fetch(this.cfg.tokenEndpoint, {
|
|
229
|
+
method: "POST",
|
|
230
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
231
|
+
body,
|
|
232
|
+
});
|
|
233
|
+
if (!resp.ok) {
|
|
234
|
+
const txt = await resp.text().catch(() => "");
|
|
235
|
+
throw new Error(`Token endpoint error (${resp.status}): ${txt}`);
|
|
236
|
+
}
|
|
237
|
+
const json = await resp.json();
|
|
238
|
+
// Verify ID token if present
|
|
239
|
+
let idClaims;
|
|
240
|
+
if (json.id_token) {
|
|
241
|
+
idClaims = await this.verifyWithIssuer(json.id_token);
|
|
242
|
+
}
|
|
243
|
+
// Verify access token if it's a JWT (3 parts)
|
|
244
|
+
let accessClaims;
|
|
245
|
+
if (json.access_token && json.access_token.split(".").length === 3) {
|
|
246
|
+
try {
|
|
247
|
+
accessClaims = await this.verifyWithIssuer(json.access_token);
|
|
248
|
+
}
|
|
249
|
+
catch {
|
|
250
|
+
// opaque or differently signed; ignore for now
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
const out = {
|
|
254
|
+
access_token: json.access_token,
|
|
255
|
+
id_token: json.id_token,
|
|
256
|
+
token_type: json.token_type,
|
|
257
|
+
refresh_token: json.refresh_token,
|
|
258
|
+
expires_in: json.expires_in,
|
|
259
|
+
scope: json.scope,
|
|
260
|
+
raw: json,
|
|
261
|
+
id_claims: idClaims,
|
|
262
|
+
access_claims: accessClaims,
|
|
263
|
+
};
|
|
264
|
+
this.lastTokens = out;
|
|
265
|
+
return out;
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* Exchange a refresh_token for new tokens.
|
|
269
|
+
* Verifies any returned JWTs and returns a TokenResponse.
|
|
270
|
+
*
|
|
271
|
+
* @param refreshToken - The refresh token to exchange for new tokens
|
|
272
|
+
* @param options - Optional parameters for the refresh request
|
|
273
|
+
* @param options.scope - Optional scope parameter (cannot exceed originally granted scope)
|
|
274
|
+
* @param options.useAuthHeader - Use Authorization header instead of client_secret in body
|
|
275
|
+
* @returns Promise<TokenResponse> - New tokens with verified claims
|
|
276
|
+
*/
|
|
277
|
+
async refreshTokens(refreshToken, options = {}) {
|
|
278
|
+
if (!refreshToken)
|
|
279
|
+
throw new Error("Missing refresh_token");
|
|
280
|
+
const body = new URLSearchParams({
|
|
281
|
+
grant_type: "refresh_token",
|
|
282
|
+
refresh_token: refreshToken,
|
|
283
|
+
client_id: this.cfg.clientId,
|
|
284
|
+
});
|
|
285
|
+
// Add scope if provided
|
|
286
|
+
if (options.scope) {
|
|
287
|
+
body.set("scope", options.scope);
|
|
288
|
+
}
|
|
289
|
+
// Prepare headers
|
|
290
|
+
const headers = {
|
|
291
|
+
"content-type": "application/x-www-form-urlencoded"
|
|
292
|
+
};
|
|
293
|
+
// Handle client authentication
|
|
294
|
+
if (this.cfg.clientSecret) {
|
|
295
|
+
if (options.useAuthHeader) {
|
|
296
|
+
// Use Authorization header (Basic auth with base64 encoded client_id:client_secret)
|
|
297
|
+
const credentials = Buffer.from(`${this.cfg.clientId}:${this.cfg.clientSecret}`).toString('base64');
|
|
298
|
+
headers["authorization"] = `Basic ${credentials}`;
|
|
299
|
+
}
|
|
300
|
+
else {
|
|
301
|
+
// Use client_secret in body (default behavior)
|
|
302
|
+
body.set("client_secret", this.cfg.clientSecret);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
const resp = await fetch(this.cfg.tokenEndpoint, {
|
|
306
|
+
method: "POST",
|
|
307
|
+
headers,
|
|
308
|
+
body,
|
|
309
|
+
});
|
|
310
|
+
if (!resp.ok) {
|
|
311
|
+
const txt = await resp.text().catch(() => "");
|
|
312
|
+
let errorMessage = `Token endpoint error (${resp.status}): ${txt}`;
|
|
313
|
+
// Try to parse OAuth error response for better error messages
|
|
314
|
+
try {
|
|
315
|
+
const errorJson = JSON.parse(txt);
|
|
316
|
+
if (errorJson.error) {
|
|
317
|
+
errorMessage = `OAuth error (${errorJson.error}): ${errorJson.error_description || errorJson.error}`;
|
|
318
|
+
// Handle specific OAuth errors
|
|
319
|
+
if (errorJson.error === "invalid_grant") {
|
|
320
|
+
errorMessage += " - The refresh token is invalid or expired. User needs to re-authenticate.";
|
|
321
|
+
}
|
|
322
|
+
else if (errorJson.error === "invalid_client") {
|
|
323
|
+
errorMessage += " - Client authentication failed. Check client_id and client_secret.";
|
|
324
|
+
}
|
|
325
|
+
else if (errorJson.error === "invalid_scope") {
|
|
326
|
+
errorMessage += " - The requested scope is invalid or exceeds the originally granted scope.";
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
catch {
|
|
331
|
+
// If JSON parsing fails, use the original error message
|
|
332
|
+
}
|
|
333
|
+
throw new Error(errorMessage);
|
|
334
|
+
}
|
|
335
|
+
const json = await resp.json();
|
|
336
|
+
// Verify ID token if present
|
|
337
|
+
let idClaims;
|
|
338
|
+
if (json.id_token) {
|
|
339
|
+
try {
|
|
340
|
+
idClaims = await this.verifyWithIssuer(json.id_token);
|
|
341
|
+
}
|
|
342
|
+
catch (error) {
|
|
343
|
+
throw new Error(`ID token verification failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
// Verify access token if it's a JWT (3 parts)
|
|
347
|
+
let accessClaims;
|
|
348
|
+
if (json.access_token && json.access_token.split(".").length === 3) {
|
|
349
|
+
try {
|
|
350
|
+
accessClaims = await this.verifyWithIssuer(json.access_token);
|
|
351
|
+
}
|
|
352
|
+
catch (error) {
|
|
353
|
+
// Log warning but don't fail - access token might be opaque
|
|
354
|
+
console.warn(`Access token verification failed (may be opaque): ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
const out = {
|
|
358
|
+
access_token: json.access_token,
|
|
359
|
+
id_token: json.id_token,
|
|
360
|
+
token_type: json.token_type,
|
|
361
|
+
refresh_token: json.refresh_token,
|
|
362
|
+
expires_in: json.expires_in,
|
|
363
|
+
scope: json.scope,
|
|
364
|
+
raw: json,
|
|
365
|
+
id_claims: idClaims,
|
|
366
|
+
access_claims: accessClaims,
|
|
367
|
+
};
|
|
368
|
+
this.lastTokens = out;
|
|
369
|
+
return out;
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* Fetch and cache JWKS from the remote endpoint.
|
|
373
|
+
* Uses SSICache to cache the JWKS response.
|
|
374
|
+
* @private
|
|
375
|
+
*/
|
|
376
|
+
async fetchAndCacheJWKS() {
|
|
377
|
+
const cacheKey = this.cfg.jwksUri;
|
|
378
|
+
// Use remember to get from cache or fetch and cache
|
|
379
|
+
const jwks = await this.jwksCache.remember(cacheKey, constants_1.TTL_JWKS, async () => {
|
|
380
|
+
// Fetch JWKS from remote endpoint
|
|
381
|
+
const response = await fetch(this.cfg.jwksUri);
|
|
382
|
+
if (!response.ok) {
|
|
383
|
+
throw new Error(`Failed to fetch JWKS from ${this.cfg.jwksUri}: ${response.status} ${response.statusText}`);
|
|
384
|
+
}
|
|
385
|
+
const jwksData = await response.json();
|
|
386
|
+
// Validate JWKS structure
|
|
387
|
+
if (!jwksData || !Array.isArray(jwksData.keys)) {
|
|
388
|
+
throw new Error(`Invalid JWKS structure from ${this.cfg.jwksUri}`);
|
|
389
|
+
}
|
|
390
|
+
return jwksData;
|
|
391
|
+
});
|
|
392
|
+
return jwks;
|
|
393
|
+
}
|
|
394
|
+
/**
|
|
395
|
+
* Verify JWT signature and claims using jose library.
|
|
396
|
+
* Handles JWKS fetching, caching, kid selection, and algorithm validation.
|
|
397
|
+
* @private
|
|
398
|
+
*/
|
|
399
|
+
async verifyWithIssuer(jwt) {
|
|
400
|
+
// If static JWKS provided, use jose's importJWK for each key
|
|
401
|
+
if (this.cfg.jwks && Array.isArray(this.cfg.jwks.keys)) {
|
|
402
|
+
// For static JWKS, we need to import and try each key
|
|
403
|
+
for (const key of this.cfg.jwks.keys) {
|
|
404
|
+
try {
|
|
405
|
+
const publicKey = await (0, jose_1.importJWK)(key, "RS256");
|
|
406
|
+
const { payload } = await (0, jose_1.jwtVerify)(jwt, publicKey, {
|
|
407
|
+
issuer: this.cfg.issuerAccepted,
|
|
408
|
+
audience: this.cfg.clientId,
|
|
409
|
+
algorithms: ["RS256"],
|
|
410
|
+
});
|
|
411
|
+
return payload;
|
|
412
|
+
}
|
|
413
|
+
catch {
|
|
414
|
+
// Try next key
|
|
415
|
+
continue;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
throw new Error("No valid JWK found in static JWKS");
|
|
419
|
+
}
|
|
420
|
+
// Fetch and cache JWKS from remote endpoint
|
|
421
|
+
const jwks = await this.fetchAndCacheJWKS();
|
|
422
|
+
// Try each key in the JWKS
|
|
423
|
+
for (const key of jwks.keys) {
|
|
424
|
+
try {
|
|
425
|
+
const publicKey = await (0, jose_1.importJWK)(key, "RS256");
|
|
426
|
+
const { payload } = await (0, jose_1.jwtVerify)(jwt, publicKey, {
|
|
427
|
+
issuer: this.cfg.issuerAccepted,
|
|
428
|
+
audience: this.cfg.clientId,
|
|
429
|
+
algorithms: ["RS256"],
|
|
430
|
+
});
|
|
431
|
+
return payload;
|
|
432
|
+
}
|
|
433
|
+
catch {
|
|
434
|
+
// Try next key
|
|
435
|
+
continue;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
throw new Error("No valid JWK found in JWKS to verify the JWT");
|
|
439
|
+
}
|
|
440
|
+
/**
|
|
441
|
+
* Public method to verify a JWT.
|
|
442
|
+
* Use this in middleware or anywhere you need to validate tokens.
|
|
443
|
+
*/
|
|
444
|
+
async verifyJwt(jwt) {
|
|
445
|
+
const claims = await this.verifyWithIssuer(jwt);
|
|
446
|
+
return claims;
|
|
447
|
+
}
|
|
448
|
+
/**
|
|
449
|
+
* Verify and decode a JWT before returning its claims.
|
|
450
|
+
* Throws if the JWT is invalid, expired, or fails signature verification.
|
|
451
|
+
*
|
|
452
|
+
* This method is safe to use in middleware or debugging contexts when you
|
|
453
|
+
* simply need the decoded, verified claims from a JWT string.
|
|
454
|
+
*/
|
|
455
|
+
async verifyAndDecodeJwt(jwt) {
|
|
456
|
+
if (!jwt)
|
|
457
|
+
throw new Error("Missing JWT");
|
|
458
|
+
const parts = jwt.split(".");
|
|
459
|
+
if (parts.length !== 3)
|
|
460
|
+
throw new Error("Invalid JWT format");
|
|
461
|
+
// Reuse the same cryptographic verification logic
|
|
462
|
+
const claims = await this.verifyWithIssuer(jwt);
|
|
463
|
+
// If verifyWithIssuer() throws, this will never return
|
|
464
|
+
return claims;
|
|
465
|
+
}
|
|
466
|
+
/**
|
|
467
|
+
* Automatically refresh tokens if they are expired or about to expire.
|
|
468
|
+
* This is a convenience method that handles the refresh logic automatically.
|
|
469
|
+
*
|
|
470
|
+
* @param refreshToken - The refresh token to use for refreshing
|
|
471
|
+
* @param options - Optional parameters for the refresh request
|
|
472
|
+
* @param options.bufferSeconds - How many seconds before expiry to consider tokens as "about to expire" (default: 60)
|
|
473
|
+
* @param options.scope - Optional scope parameter
|
|
474
|
+
* @param options.useAuthHeader - Use Authorization header instead of client_secret in body
|
|
475
|
+
* @returns Promise<TokenResponse | null> - New tokens if refreshed, null if not needed
|
|
476
|
+
*/
|
|
477
|
+
async autoRefreshTokens(refreshToken, options = {}) {
|
|
478
|
+
const bufferSeconds = options.bufferSeconds ?? 60;
|
|
479
|
+
// Check if we have current tokens and if they need refreshing
|
|
480
|
+
if (this.lastTokens?.access_token && this.lastTokens?.expires_in) {
|
|
481
|
+
const now = Math.floor(Date.now() / 1000);
|
|
482
|
+
const expiresAt = now + this.lastTokens.expires_in;
|
|
483
|
+
const refreshThreshold = now + bufferSeconds;
|
|
484
|
+
// If tokens are still valid for more than bufferSeconds, no need to refresh
|
|
485
|
+
if (expiresAt > refreshThreshold) {
|
|
486
|
+
return null;
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
try {
|
|
490
|
+
return await this.refreshTokens(refreshToken, {
|
|
491
|
+
scope: options.scope,
|
|
492
|
+
useAuthHeader: options.useAuthHeader
|
|
493
|
+
});
|
|
494
|
+
}
|
|
495
|
+
catch (error) {
|
|
496
|
+
// If refresh fails, clear the last tokens as they're likely invalid
|
|
497
|
+
this.lastTokens = undefined;
|
|
498
|
+
throw error;
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
/**
|
|
502
|
+
* Check if the current tokens are expired or about to expire.
|
|
503
|
+
*
|
|
504
|
+
* @param bufferSeconds - How many seconds before expiry to consider tokens as "about to expire" (default: 60)
|
|
505
|
+
* @returns boolean - true if tokens need refreshing, false otherwise
|
|
506
|
+
*/
|
|
507
|
+
needsTokenRefresh(bufferSeconds = 60) {
|
|
508
|
+
if (!this.lastTokens?.access_token || !this.lastTokens?.expires_in) {
|
|
509
|
+
return true; // No tokens or missing expiry info
|
|
510
|
+
}
|
|
511
|
+
const now = Math.floor(Date.now() / 1000);
|
|
512
|
+
const expiresAt = now + this.lastTokens.expires_in;
|
|
513
|
+
const refreshThreshold = now + bufferSeconds;
|
|
514
|
+
return expiresAt <= refreshThreshold;
|
|
515
|
+
}
|
|
516
|
+
/**
|
|
517
|
+
* Get the time until the current access token expires.
|
|
518
|
+
*
|
|
519
|
+
* @returns number - Seconds until expiry, or 0 if no token or expired
|
|
520
|
+
*/
|
|
521
|
+
getTokenExpirySeconds() {
|
|
522
|
+
if (!this.lastTokens?.expires_in) {
|
|
523
|
+
return 0;
|
|
524
|
+
}
|
|
525
|
+
const now = Math.floor(Date.now() / 1000);
|
|
526
|
+
const expiresAt = now + this.lastTokens.expires_in;
|
|
527
|
+
const timeLeft = expiresAt - now;
|
|
528
|
+
return Math.max(0, timeLeft);
|
|
529
|
+
}
|
|
530
|
+
/** Returns the last TokenResponse produced by handleCallback/refreshTokens in this instance */
|
|
531
|
+
getLastTokenResponse() {
|
|
532
|
+
return this.lastTokens;
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
exports.AuthServer = AuthServer;
|
|
536
|
+
/**
|
|
537
|
+
* USAGE EXAMPLES
|
|
538
|
+
*
|
|
539
|
+
* // Basic token refresh
|
|
540
|
+
* const auth = new AuthServer(config);
|
|
541
|
+
* const newTokens = await auth.refreshTokens(refreshToken);
|
|
542
|
+
*
|
|
543
|
+
* // Refresh with Authorization header instead of client_secret in body
|
|
544
|
+
* const newTokens = await auth.refreshTokens(refreshToken, {
|
|
545
|
+
* useAuthHeader: true
|
|
546
|
+
* });
|
|
547
|
+
*
|
|
548
|
+
* // Refresh with specific scope (cannot exceed originally granted scope)
|
|
549
|
+
* const newTokens = await auth.refreshTokens(refreshToken, {
|
|
550
|
+
* scope: "openid profile email offline_access"
|
|
551
|
+
* });
|
|
552
|
+
*
|
|
553
|
+
* // Automatic refresh - only refreshes if tokens are expired or about to expire
|
|
554
|
+
* const newTokens = await auth.autoRefreshTokens(refreshToken, {
|
|
555
|
+
* bufferSeconds: 120, // Refresh if expires within 2 minutes
|
|
556
|
+
* useAuthHeader: true
|
|
557
|
+
* });
|
|
558
|
+
*
|
|
559
|
+
* // Check if tokens need refreshing
|
|
560
|
+
* if (auth.needsTokenRefresh(60)) {
|
|
561
|
+
* const newTokens = await auth.refreshTokens(refreshToken);
|
|
562
|
+
* }
|
|
563
|
+
*
|
|
564
|
+
* // Get time until token expires
|
|
565
|
+
* const secondsUntilExpiry = auth.getTokenExpirySeconds();
|
|
566
|
+
* console.log(`Token expires in ${secondsUntilExpiry} seconds`);
|
|
567
|
+
*
|
|
568
|
+
* // Error handling
|
|
569
|
+
* try {
|
|
570
|
+
* const newTokens = await auth.refreshTokens(refreshToken);
|
|
571
|
+
* // Use new tokens...
|
|
572
|
+
* } catch (error) {
|
|
573
|
+
* if (error.message.includes('invalid_grant')) {
|
|
574
|
+
* // Refresh token is invalid/expired - redirect to login
|
|
575
|
+
* return Response.redirect('/login');
|
|
576
|
+
* }
|
|
577
|
+
* // Handle other errors...
|
|
578
|
+
* }
|
|
579
|
+
*/
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { CacheInitOpts } from './types';
|
|
2
|
+
export declare class SSICache {
|
|
3
|
+
private backend;
|
|
4
|
+
private container;
|
|
5
|
+
private prefix?;
|
|
6
|
+
private static __shared?;
|
|
7
|
+
private constructor();
|
|
8
|
+
static fromEnv(): SSICache;
|
|
9
|
+
static init(opts: CacheInitOpts): SSICache;
|
|
10
|
+
/**
|
|
11
|
+
* Returns a cache instance pinned to a prefix ("jwks", "oidc", "rl", etc.).
|
|
12
|
+
*/
|
|
13
|
+
withPrefix(prefix?: string): SSICache;
|
|
14
|
+
private k;
|
|
15
|
+
get<T = unknown>(key: string): Promise<T | null>;
|
|
16
|
+
set<T = unknown>(key: string, value: T, ttlSec: number): Promise<void>;
|
|
17
|
+
del(key: string): Promise<void>;
|
|
18
|
+
mget<T = unknown>(keys: string[]): Promise<(T | null)[]>;
|
|
19
|
+
mset<T = unknown>(entries: Array<{
|
|
20
|
+
key: string;
|
|
21
|
+
value: T;
|
|
22
|
+
ttlSec: number;
|
|
23
|
+
}>): Promise<void>;
|
|
24
|
+
incrby(key: string, by?: number, ttlSec?: number): Promise<number>;
|
|
25
|
+
ttl(key: string): Promise<number | null>;
|
|
26
|
+
/**
|
|
27
|
+
* compute-if-absent: get from cache or compute and cache the value
|
|
28
|
+
*/
|
|
29
|
+
remember<T>(key: string, ttlSec: number, loader: () => Promise<T>): Promise<T>;
|
|
30
|
+
/**
|
|
31
|
+
* Simple distributed lock (best-effort): returns lock token if acquired
|
|
32
|
+
* Only works properly with Redis backend; memory backend returns a naive token
|
|
33
|
+
*/
|
|
34
|
+
acquireLock(key: string, ttlMs: number): Promise<string | null>;
|
|
35
|
+
/**
|
|
36
|
+
* Release a lock acquired via acquireLock
|
|
37
|
+
*/
|
|
38
|
+
releaseLock(key: string, token: string): Promise<void>;
|
|
39
|
+
close(): Promise<void>;
|
|
40
|
+
}
|