@serialsubscriptions/platform-integration 0.0.84 → 0.85.4
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/lib/SubscribedPlanManager.d.ts +7 -0
- package/lib/SubscribedPlanManager.js +16 -1
- package/lib/auth.server.d.ts +11 -1
- package/lib/auth.server.js +62 -4
- package/lib/clientConfig.d.ts +42 -0
- package/lib/clientConfig.js +80 -0
- package/lib/frontend/index.d.ts +2 -0
- package/lib/frontend/index.js +8 -2
- package/lib/frontend/session/SessionClient.d.ts +14 -2
- package/lib/frontend/session/SessionClient.js +23 -17
- package/lib/index.d.ts +2 -0
- package/lib/index.js +2 -0
- package/lib/requestConfig.d.ts +61 -0
- package/lib/requestConfig.js +145 -0
- package/lib/session/SessionManager.d.ts +31 -8
- package/lib/session/SessionManager.js +153 -28
- package/package.json +4 -1
|
@@ -36,9 +36,16 @@ import { type JsonApiResource } from './SSISubscribedPlanApi';
|
|
|
36
36
|
* Attributes for a subscribed_plan--subscribed_plan resource.
|
|
37
37
|
* Based on your JSON sample; extra fields will still be present in the raw JSON:API resource.
|
|
38
38
|
*/
|
|
39
|
+
/**
|
|
40
|
+
* Known values for subscribed plan `status` (and for project `subscription_status`
|
|
41
|
+
* when synced from the platform). Document the full list in SUBSCRIBEDPLAN_MANAGER.md.
|
|
42
|
+
*/
|
|
43
|
+
export declare const SUBSCRIPTION_STATUS: {};
|
|
44
|
+
export type SubscriptionStatusValue = (typeof SUBSCRIPTION_STATUS)[keyof typeof SUBSCRIPTION_STATUS] | string;
|
|
39
45
|
export interface SubscribedPlanAttributes {
|
|
40
46
|
drupal_internal__id: number;
|
|
41
47
|
name: string;
|
|
48
|
+
/** Plan status from the platform. See SUBSCRIBEDPLAN_MANAGER.md for possible values. */
|
|
42
49
|
status: string;
|
|
43
50
|
subscription_period: string;
|
|
44
51
|
name_subscript: string | null;
|
|
@@ -33,11 +33,26 @@
|
|
|
33
33
|
* ```
|
|
34
34
|
*/
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
-
exports.SubscribedPlanManager = void 0;
|
|
36
|
+
exports.SubscribedPlanManager = exports.SUBSCRIPTION_STATUS = void 0;
|
|
37
37
|
const SSISubscribedPlanApi_1 = require("./SSISubscribedPlanApi");
|
|
38
38
|
const SSISubscribedFeatureApi_1 = require("./SSISubscribedFeatureApi");
|
|
39
39
|
const SSISubscribedLimitApi_1 = require("./SSISubscribedLimitApi");
|
|
40
40
|
const SSICache_1 = require("./cache/SSICache");
|
|
41
|
+
/**
|
|
42
|
+
* Attributes for a subscribed_plan--subscribed_plan resource.
|
|
43
|
+
* Based on your JSON sample; extra fields will still be present in the raw JSON:API resource.
|
|
44
|
+
*/
|
|
45
|
+
/**
|
|
46
|
+
* Known values for subscribed plan `status` (and for project `subscription_status`
|
|
47
|
+
* when synced from the platform). Document the full list in SUBSCRIBEDPLAN_MANAGER.md.
|
|
48
|
+
*/
|
|
49
|
+
exports.SUBSCRIPTION_STATUS = {
|
|
50
|
+
// Add values as the platform defines them, e.g.:
|
|
51
|
+
// ACTIVE: 'active',
|
|
52
|
+
// CANCELLED: 'cancelled',
|
|
53
|
+
// TRIALING: 'trialing',
|
|
54
|
+
// PAST_DUE: 'past_due',
|
|
55
|
+
};
|
|
41
56
|
/**
|
|
42
57
|
* SubscribedPlanManager
|
|
43
58
|
*
|
package/lib/auth.server.d.ts
CHANGED
|
@@ -1,7 +1,16 @@
|
|
|
1
1
|
import { SSIStorage } from "./storage/SSIStorage";
|
|
2
|
+
import type { AuthConfigInput } from "./requestConfig";
|
|
2
3
|
export declare const scopes: {
|
|
3
4
|
readonly defaultScopes: "openid profile email view_project create_project delete_project update_project view_subscribed_plan view_subscribed_feature view_subscribed_limit access_subscription_usage";
|
|
4
5
|
};
|
|
6
|
+
/**
|
|
7
|
+
* Get client id and secret for an audience from env (for token endpoint use).
|
|
8
|
+
* If aud != SSI_CLIENT_ID, uses SSI_CLIENT_ID_<UPPERCASE(aud)> and SSI_CLIENT_SECRET_<UPPERCASE(aud)> when set.
|
|
9
|
+
*/
|
|
10
|
+
export declare function getClientCredentialsForAudience(aud: string, defaultClientId: string, defaultClientSecret: string): {
|
|
11
|
+
clientId: string;
|
|
12
|
+
clientSecret: string;
|
|
13
|
+
};
|
|
5
14
|
/**
|
|
6
15
|
* Check if a JWT is expired (or within bufferSeconds of expiry) by decoding its payload.
|
|
7
16
|
* Does not verify the signature — use only for expiry checks (e.g. "should I refresh?").
|
|
@@ -67,7 +76,8 @@ export declare class AuthServer {
|
|
|
67
76
|
private stateStorage;
|
|
68
77
|
private jwksCache;
|
|
69
78
|
private lastTokens?;
|
|
70
|
-
|
|
79
|
+
/** Accepts AuthConfig or SSIRequestConfig (e.g. from getRequestConfig(req)). */
|
|
80
|
+
constructor(config?: AuthConfigInput);
|
|
71
81
|
/**
|
|
72
82
|
* Build the authorization URL and persist a CSRF state for the callback.
|
|
73
83
|
* Returns: { url, stateKey, stateValue }
|
package/lib/auth.server.js
CHANGED
|
@@ -36,6 +36,7 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
36
36
|
})();
|
|
37
37
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
38
38
|
exports.AuthServer = exports.scopes = void 0;
|
|
39
|
+
exports.getClientCredentialsForAudience = getClientCredentialsForAudience;
|
|
39
40
|
exports.isJwtExpired = isJwtExpired;
|
|
40
41
|
exports.getJwtExpirySeconds = getJwtExpirySeconds;
|
|
41
42
|
const crypto = __importStar(require("crypto"));
|
|
@@ -43,6 +44,7 @@ const jose_1 = require("jose");
|
|
|
43
44
|
const stateStore_1 = require("./stateStore");
|
|
44
45
|
const SSICache_1 = require("./cache/SSICache");
|
|
45
46
|
const constants_1 = require("./cache/constants");
|
|
47
|
+
const requestConfig_1 = require("./requestConfig");
|
|
46
48
|
exports.scopes = {
|
|
47
49
|
defaultScopes: "openid profile email view_project create_project delete_project update_project view_subscribed_plan view_subscribed_feature view_subscribed_limit access_subscription_usage"
|
|
48
50
|
};
|
|
@@ -50,6 +52,61 @@ exports.scopes = {
|
|
|
50
52
|
function normalizeIssuer(url) {
|
|
51
53
|
return url.replace(/\/+$/, "");
|
|
52
54
|
}
|
|
55
|
+
/**
|
|
56
|
+
* Build env key for alternate client id/secret by audience.
|
|
57
|
+
* e.g. aud "scheduled_job_client" -> "SSI_CLIENT_ID_SCHEDULED_JOB_CLIENT"
|
|
58
|
+
*/
|
|
59
|
+
function audienceToEnvSuffix(aud) {
|
|
60
|
+
return String(aud).toUpperCase().replace(/-/g, "_");
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Resolve allowed audiences for JWT verification.
|
|
64
|
+
* If token aud != default client id, check SSI_CLIENT_ID_<UPPERCASE(aud)>; if set, that value is accepted as audience.
|
|
65
|
+
*/
|
|
66
|
+
function getAllowedAudiences(jwt, defaultClientId) {
|
|
67
|
+
const allowed = [defaultClientId];
|
|
68
|
+
let payload;
|
|
69
|
+
try {
|
|
70
|
+
payload = (0, jose_1.decodeJwt)(jwt);
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return allowed;
|
|
74
|
+
}
|
|
75
|
+
const aud = payload.aud;
|
|
76
|
+
const audList = aud == null ? [] : Array.isArray(aud) ? aud.map(String) : [String(aud)];
|
|
77
|
+
for (const a of audList) {
|
|
78
|
+
if (a && a !== defaultClientId) {
|
|
79
|
+
const envKey = `SSI_CLIENT_ID_${audienceToEnvSuffix(a)}`;
|
|
80
|
+
const alt = process.env[envKey]?.trim();
|
|
81
|
+
if (alt) {
|
|
82
|
+
if (!allowed.includes(alt))
|
|
83
|
+
allowed.push(alt);
|
|
84
|
+
// Also accept the token's aud value so verification passes when issuer puts aud in token
|
|
85
|
+
if (a !== alt && !allowed.includes(a))
|
|
86
|
+
allowed.push(a);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return allowed;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Get client id and secret for an audience from env (for token endpoint use).
|
|
94
|
+
* If aud != SSI_CLIENT_ID, uses SSI_CLIENT_ID_<UPPERCASE(aud)> and SSI_CLIENT_SECRET_<UPPERCASE(aud)> when set.
|
|
95
|
+
*/
|
|
96
|
+
function getClientCredentialsForAudience(aud, defaultClientId, defaultClientSecret) {
|
|
97
|
+
if (!aud || aud === defaultClientId)
|
|
98
|
+
return { clientId: defaultClientId, clientSecret: defaultClientSecret };
|
|
99
|
+
const suffix = audienceToEnvSuffix(aud);
|
|
100
|
+
const clientId = process.env[`SSI_CLIENT_ID_${suffix}`]?.trim();
|
|
101
|
+
const clientSecret = process.env[`SSI_CLIENT_SECRET_${suffix}`]?.trim();
|
|
102
|
+
if (clientId) {
|
|
103
|
+
return {
|
|
104
|
+
clientId,
|
|
105
|
+
clientSecret: clientSecret ?? defaultClientSecret,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
return { clientId: defaultClientId, clientSecret: defaultClientSecret };
|
|
109
|
+
}
|
|
53
110
|
/**
|
|
54
111
|
* Check if a JWT is expired (or within bufferSeconds of expiry) by decoding its payload.
|
|
55
112
|
* Does not verify the signature — use only for expiry checks (e.g. "should I refresh?").
|
|
@@ -93,9 +150,9 @@ function getJwtExpirySeconds(jwt) {
|
|
|
93
150
|
}
|
|
94
151
|
}
|
|
95
152
|
class AuthServer {
|
|
153
|
+
/** Accepts AuthConfig or SSIRequestConfig (e.g. from getRequestConfig(req)). */
|
|
96
154
|
constructor(config) {
|
|
97
|
-
|
|
98
|
-
const cfg = config ?? {};
|
|
155
|
+
const cfg = (0, requestConfig_1.normalizeAuthConfig)(config) ?? {};
|
|
99
156
|
let scopesString;
|
|
100
157
|
if (cfg.scopes === undefined || cfg.scopes === null) {
|
|
101
158
|
scopesString = exports.scopes.defaultScopes;
|
|
@@ -441,6 +498,7 @@ class AuthServer {
|
|
|
441
498
|
* @private
|
|
442
499
|
*/
|
|
443
500
|
async verifyWithIssuer(jwt) {
|
|
501
|
+
const allowedAudiences = getAllowedAudiences(jwt, this.cfg.clientId);
|
|
444
502
|
// If static JWKS provided, use jose's importJWK for each key
|
|
445
503
|
if (this.cfg.jwks && Array.isArray(this.cfg.jwks.keys)) {
|
|
446
504
|
// For static JWKS, we need to import and try each key
|
|
@@ -449,7 +507,7 @@ class AuthServer {
|
|
|
449
507
|
const publicKey = await (0, jose_1.importJWK)(key, "RS256");
|
|
450
508
|
const { payload } = await (0, jose_1.jwtVerify)(jwt, publicKey, {
|
|
451
509
|
issuer: this.cfg.issuerAccepted,
|
|
452
|
-
audience:
|
|
510
|
+
audience: allowedAudiences,
|
|
453
511
|
algorithms: ["RS256"],
|
|
454
512
|
});
|
|
455
513
|
return payload;
|
|
@@ -469,7 +527,7 @@ class AuthServer {
|
|
|
469
527
|
const publicKey = await (0, jose_1.importJWK)(key, "RS256");
|
|
470
528
|
const { payload } = await (0, jose_1.jwtVerify)(jwt, publicKey, {
|
|
471
529
|
issuer: this.cfg.issuerAccepted,
|
|
472
|
-
audience:
|
|
530
|
+
audience: allowedAudiences,
|
|
473
531
|
algorithms: ["RS256"],
|
|
474
532
|
});
|
|
475
533
|
return payload;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Config shape that host apps use in the browser (e.g. from getClientConfig()).
|
|
3
|
+
*
|
|
4
|
+
* Derived from public env (NEXT_PUBLIC_*) and optionally window.location.
|
|
5
|
+
* Use this type for the return value of your client config helper so SessionClient
|
|
6
|
+
* and other client code stay typed.
|
|
7
|
+
*
|
|
8
|
+
* - **baseUrl**: App origin (e.g. window.location.origin or NEXT_PUBLIC_APP_URL).
|
|
9
|
+
* - **apiUrl**: App API base; where your app's routes live (e.g. /api/v1/auth/session).
|
|
10
|
+
* Pass to SessionClient.getSessionClient(config.apiUrl ?? config.baseUrl).
|
|
11
|
+
* - **ssiApiUrl**: SSI Platform API base (for links, account portal, etc.).
|
|
12
|
+
* - **ssiIssuerBaseUrl**: SSI issuer URL (often same as ssiApiUrl; e.g. for /pricing links).
|
|
13
|
+
*/
|
|
14
|
+
export type SSIClientConfig = {
|
|
15
|
+
baseUrl: string | null;
|
|
16
|
+
apiUrl?: string | null;
|
|
17
|
+
ssiApiUrl?: string | null;
|
|
18
|
+
ssiIssuerBaseUrl?: string | null;
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* Base app URL (client): NEXT_PUBLIC_APP_URL or (in browser) window.location.origin.
|
|
22
|
+
*/
|
|
23
|
+
export declare function getClientBaseUrl(): string | null;
|
|
24
|
+
/**
|
|
25
|
+
* App API base URL (client): NEXT_PUBLIC_API_URL or baseUrl.
|
|
26
|
+
*/
|
|
27
|
+
export declare function getClientApiUrl(baseUrl: string | null): string | null;
|
|
28
|
+
/**
|
|
29
|
+
* SSI Platform API base URL (client): NEXT_PUBLIC_SSI_API_BASE_URL or derived from baseUrl
|
|
30
|
+
* hostname (e.g. https://<subdomain>.cerealstackdev.com on known domains).
|
|
31
|
+
*/
|
|
32
|
+
export declare function getClientSsiApiUrl(baseUrl: string | null): string | null;
|
|
33
|
+
/**
|
|
34
|
+
* Build SSIClientConfig from public env and (in browser) window.location.
|
|
35
|
+
* Use in client components; for SessionClient pass the result to getSessionClient(config).
|
|
36
|
+
*/
|
|
37
|
+
export declare function getClientConfig(): SSIClientConfig;
|
|
38
|
+
/**
|
|
39
|
+
* Returns the URL to pass to SessionClient (app base so it can call the app's session endpoint).
|
|
40
|
+
* Prefers apiUrl so the session route is resolved correctly when app and API differ.
|
|
41
|
+
*/
|
|
42
|
+
export declare function getSessionClientBaseUrl(config: SSIClientConfig | undefined | null): string | undefined;
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
"use strict";
|
|
3
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
exports.getClientBaseUrl = getClientBaseUrl;
|
|
5
|
+
exports.getClientApiUrl = getClientApiUrl;
|
|
6
|
+
exports.getClientSsiApiUrl = getClientSsiApiUrl;
|
|
7
|
+
exports.getClientConfig = getClientConfig;
|
|
8
|
+
exports.getSessionClientBaseUrl = getSessionClientBaseUrl;
|
|
9
|
+
function trimTrailingSlash(url) {
|
|
10
|
+
return url.replace(/\/$/, "");
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Base app URL (client): NEXT_PUBLIC_APP_URL or (in browser) window.location.origin.
|
|
14
|
+
*/
|
|
15
|
+
function getClientBaseUrl() {
|
|
16
|
+
const baseUrl = process.env.NEXT_PUBLIC_APP_URL ||
|
|
17
|
+
(typeof window !== "undefined" ? window.location.origin : null);
|
|
18
|
+
return baseUrl ? trimTrailingSlash(baseUrl) : null;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* App API base URL (client): NEXT_PUBLIC_API_URL or baseUrl.
|
|
22
|
+
*/
|
|
23
|
+
function getClientApiUrl(baseUrl) {
|
|
24
|
+
const apiUrl = process.env.NEXT_PUBLIC_API_URL || (baseUrl ?? null);
|
|
25
|
+
return apiUrl ? trimTrailingSlash(apiUrl) : null;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* SSI Platform API base URL (client): NEXT_PUBLIC_SSI_API_BASE_URL or derived from baseUrl
|
|
29
|
+
* hostname (e.g. https://<subdomain>.cerealstackdev.com on known domains).
|
|
30
|
+
*/
|
|
31
|
+
function getClientSsiApiUrl(baseUrl) {
|
|
32
|
+
if (!baseUrl)
|
|
33
|
+
return null;
|
|
34
|
+
if (process.env.NEXT_PUBLIC_SSI_API_BASE_URL) {
|
|
35
|
+
return trimTrailingSlash(process.env.NEXT_PUBLIC_SSI_API_BASE_URL);
|
|
36
|
+
}
|
|
37
|
+
try {
|
|
38
|
+
const url = new URL(baseUrl);
|
|
39
|
+
const hostname = url.hostname;
|
|
40
|
+
const validDomain = hostname.endsWith(".cerealstackdev.com") ||
|
|
41
|
+
hostname.endsWith(".cerealstackqa.com") ||
|
|
42
|
+
hostname.endsWith(".cerealstack.com");
|
|
43
|
+
if (!validDomain)
|
|
44
|
+
return null;
|
|
45
|
+
const parts = hostname.split(".");
|
|
46
|
+
if (parts.length < 3)
|
|
47
|
+
return null;
|
|
48
|
+
const subdomain = parts[parts.length - 3];
|
|
49
|
+
const domain = parts.slice(-2).join(".");
|
|
50
|
+
return `https://${subdomain}.${domain}`;
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Build SSIClientConfig from public env and (in browser) window.location.
|
|
58
|
+
* Use in client components; for SessionClient pass the result to getSessionClient(config).
|
|
59
|
+
*/
|
|
60
|
+
function getClientConfig() {
|
|
61
|
+
const baseUrl = getClientBaseUrl();
|
|
62
|
+
const apiUrl = getClientApiUrl(baseUrl);
|
|
63
|
+
const ssiApiUrl = getClientSsiApiUrl(baseUrl);
|
|
64
|
+
return {
|
|
65
|
+
baseUrl,
|
|
66
|
+
apiUrl,
|
|
67
|
+
ssiApiUrl,
|
|
68
|
+
ssiIssuerBaseUrl: ssiApiUrl,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Returns the URL to pass to SessionClient (app base so it can call the app's session endpoint).
|
|
73
|
+
* Prefers apiUrl so the session route is resolved correctly when app and API differ.
|
|
74
|
+
*/
|
|
75
|
+
function getSessionClientBaseUrl(config) {
|
|
76
|
+
if (config == null)
|
|
77
|
+
return undefined;
|
|
78
|
+
const url = config.apiUrl ?? config.baseUrl;
|
|
79
|
+
return url != null && url !== "" ? url : undefined;
|
|
80
|
+
}
|
package/lib/frontend/index.d.ts
CHANGED
package/lib/frontend/index.js
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.SessionClient = void 0;
|
|
4
|
-
// Browser/middleware-safe exports only
|
|
3
|
+
exports.getClientSsiApiUrl = exports.getClientApiUrl = exports.getClientBaseUrl = exports.getSessionClientBaseUrl = exports.getClientConfig = exports.SessionClient = void 0;
|
|
4
|
+
// Browser/middleware-safe exports only (no auth.server / requestConfig)
|
|
5
5
|
var SessionClient_1 = require("./session/SessionClient");
|
|
6
6
|
Object.defineProperty(exports, "SessionClient", { enumerable: true, get: function () { return SessionClient_1.SessionClient; } });
|
|
7
|
+
var clientConfig_1 = require("../clientConfig");
|
|
8
|
+
Object.defineProperty(exports, "getClientConfig", { enumerable: true, get: function () { return clientConfig_1.getClientConfig; } });
|
|
9
|
+
Object.defineProperty(exports, "getSessionClientBaseUrl", { enumerable: true, get: function () { return clientConfig_1.getSessionClientBaseUrl; } });
|
|
10
|
+
Object.defineProperty(exports, "getClientBaseUrl", { enumerable: true, get: function () { return clientConfig_1.getClientBaseUrl; } });
|
|
11
|
+
Object.defineProperty(exports, "getClientApiUrl", { enumerable: true, get: function () { return clientConfig_1.getClientApiUrl; } });
|
|
12
|
+
Object.defineProperty(exports, "getClientSsiApiUrl", { enumerable: true, get: function () { return clientConfig_1.getClientSsiApiUrl; } });
|
|
@@ -1,13 +1,25 @@
|
|
|
1
|
+
import { type SSIClientConfig } from '../../clientConfig';
|
|
1
2
|
export declare class SessionClient {
|
|
2
3
|
private readonly cookieHeader?;
|
|
3
|
-
private static readonly instances;
|
|
4
4
|
private readonly baseUrl;
|
|
5
5
|
private claims;
|
|
6
6
|
private ttl;
|
|
7
7
|
private initPromise;
|
|
8
8
|
private static normalizeBaseUrl;
|
|
9
9
|
constructor(baseUrl?: string, cookieHeader?: string | null | undefined);
|
|
10
|
-
|
|
10
|
+
/**
|
|
11
|
+
* Returns a new SessionClient for this request. Call once per request (or per user
|
|
12
|
+
* context) so session is not shared across requests.
|
|
13
|
+
*
|
|
14
|
+
* Accepts a base URL string or an SSIClientConfig (e.g. from getClientConfig());
|
|
15
|
+
* when given config, uses config.apiUrl ?? config.baseUrl.
|
|
16
|
+
*
|
|
17
|
+
* In Next.js server components/route handlers you can omit cookieHeader: the client
|
|
18
|
+
* will automatically use the current request's session cookie (via next/headers).
|
|
19
|
+
* In other server contexts (e.g. middleware, non-Next), pass the request's Cookie
|
|
20
|
+
* header so this client is bound to that request's session.
|
|
21
|
+
*/
|
|
22
|
+
static getSessionClient(baseUrlOrConfig?: string | SSIClientConfig, cookieHeader?: string | null): SessionClient;
|
|
11
23
|
isLoggedIn(): Promise<boolean>;
|
|
12
24
|
warmup(): void;
|
|
13
25
|
getTtl(): Promise<number | null>;
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.SessionClient = void 0;
|
|
4
|
+
const clientConfig_1 = require("../../clientConfig");
|
|
5
|
+
/** Session cookie name; must match SessionManager (SSI_COOKIE_NAME env or default). */
|
|
6
|
+
const SESSION_COOKIE_NAME = process.env.SSI_COOKIE_NAME ?? "ssi_session";
|
|
4
7
|
class SessionClient {
|
|
5
8
|
static normalizeBaseUrl(baseUrl) {
|
|
6
9
|
if (!baseUrl) {
|
|
@@ -30,16 +33,21 @@ class SessionClient {
|
|
|
30
33
|
}
|
|
31
34
|
this.initPromise = this.loadClaims();
|
|
32
35
|
}
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
36
|
+
/**
|
|
37
|
+
* Returns a new SessionClient for this request. Call once per request (or per user
|
|
38
|
+
* context) so session is not shared across requests.
|
|
39
|
+
*
|
|
40
|
+
* Accepts a base URL string or an SSIClientConfig (e.g. from getClientConfig());
|
|
41
|
+
* when given config, uses config.apiUrl ?? config.baseUrl.
|
|
42
|
+
*
|
|
43
|
+
* In Next.js server components/route handlers you can omit cookieHeader: the client
|
|
44
|
+
* will automatically use the current request's session cookie (via next/headers).
|
|
45
|
+
* In other server contexts (e.g. middleware, non-Next), pass the request's Cookie
|
|
46
|
+
* header so this client is bound to that request's session.
|
|
47
|
+
*/
|
|
48
|
+
static getSessionClient(baseUrlOrConfig, cookieHeader) {
|
|
49
|
+
const baseUrl = typeof baseUrlOrConfig === 'string' ? baseUrlOrConfig : (0, clientConfig_1.getSessionClientBaseUrl)(baseUrlOrConfig);
|
|
50
|
+
return new SessionClient(baseUrl, cookieHeader);
|
|
43
51
|
}
|
|
44
52
|
async isLoggedIn() {
|
|
45
53
|
await this.initPromise;
|
|
@@ -87,14 +95,13 @@ class SessionClient {
|
|
|
87
95
|
return init;
|
|
88
96
|
}
|
|
89
97
|
try {
|
|
90
|
-
// Dynamic import of next/headers - only available in Next.js server context
|
|
91
|
-
// @ts-expect-error - next/headers types are available at runtime but TypeScript can't resolve them during package build
|
|
98
|
+
// Dynamic import of next/headers - only available in Next.js server context (request-scoped)
|
|
92
99
|
const { cookies } = await import("next/headers");
|
|
93
100
|
const cookieStore = await cookies();
|
|
94
|
-
const
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
101
|
+
const sessionCookie = cookieStore.get(SESSION_COOKIE_NAME);
|
|
102
|
+
const serializedCookies = sessionCookie
|
|
103
|
+
? `${sessionCookie.name}=${sessionCookie.value}`
|
|
104
|
+
: "";
|
|
98
105
|
if (!serializedCookies) {
|
|
99
106
|
return init;
|
|
100
107
|
}
|
|
@@ -141,5 +148,4 @@ class SessionClient {
|
|
|
141
148
|
}
|
|
142
149
|
}
|
|
143
150
|
exports.SessionClient = SessionClient;
|
|
144
|
-
SessionClient.instances = new Map();
|
|
145
151
|
exports.default = SessionClient;
|
package/lib/index.d.ts
CHANGED
package/lib/index.js
CHANGED
|
@@ -16,6 +16,8 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
|
16
16
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
17
|
exports.SSISubscribedPlanApi = exports.SSISubscribedLimitApi = exports.SSISubscribedFeatureApi = exports.SessionClient = void 0;
|
|
18
18
|
__exportStar(require("./auth.server"), exports);
|
|
19
|
+
__exportStar(require("./requestConfig"), exports);
|
|
20
|
+
__exportStar(require("./clientConfig"), exports);
|
|
19
21
|
__exportStar(require("./stateStore"), exports);
|
|
20
22
|
__exportStar(require("./storage/SSIStorage"), exports);
|
|
21
23
|
__exportStar(require("./storage/types"), exports);
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { AuthConfig } from './auth.server';
|
|
2
|
+
/**
|
|
3
|
+
* SSI-related config that host apps typically derive from the current request
|
|
4
|
+
* (e.g. via getRequestConfig(req)) and pass to the library.
|
|
5
|
+
*
|
|
6
|
+
* Use this type when building a "request config" in your app so SessionManager,
|
|
7
|
+
* AuthServer, UsageApi, and SubscribedPlanManager get a consistent, typed shape.
|
|
8
|
+
*
|
|
9
|
+
* - **ssiIssuerBaseUrl**, **ssiRedirectUri**, **ssiClientId**: passed to
|
|
10
|
+
* AuthServer and SessionManager.fromEnv(cookieHeader, config).
|
|
11
|
+
* - **ssiApiUrl**: base URL for SSI Platform API (UsageApi, SubscribedPlanManager,
|
|
12
|
+
* SSIProjectApi, etc.).
|
|
13
|
+
* - **baseUrl** / **apiUrl**: optional; for app use (e.g. redirect after login).
|
|
14
|
+
* The library does not use these; they are included so apps can use this type
|
|
15
|
+
* as the SSI subset of a larger RequestConfig.
|
|
16
|
+
*/
|
|
17
|
+
export type SSIRequestConfig = {
|
|
18
|
+
baseUrl?: string | null;
|
|
19
|
+
apiUrl?: string | null;
|
|
20
|
+
ssiApiUrl: string | null;
|
|
21
|
+
ssiIssuerBaseUrl: string | null;
|
|
22
|
+
ssiRedirectUri: string | null;
|
|
23
|
+
ssiClientId: string | null;
|
|
24
|
+
};
|
|
25
|
+
/**
|
|
26
|
+
* Get the base URL from environment or request headers.
|
|
27
|
+
* If NEXT_PUBLIC_APP_URL is set, returns it; otherwise derives from
|
|
28
|
+
* x-forwarded-proto, x-forwarded-host, origin, or host.
|
|
29
|
+
*/
|
|
30
|
+
export declare function getBaseUrl(req: Request): string | null;
|
|
31
|
+
/** When baseUrl is a single URL or comma-separated list, return the first URL. */
|
|
32
|
+
export declare function firstBaseUrl(baseUrl: string | null): string | null;
|
|
33
|
+
/** App API base URL: NEXT_PUBLIC_API_URL or baseUrl. */
|
|
34
|
+
export declare function getBaseApiUrl(req: Request): string | null;
|
|
35
|
+
/** SSI client id: SSI_CLIENT_ID env or subdomain on known Cereal Stack domains. */
|
|
36
|
+
export declare function getSsiClientId(baseUrl: string): string | null;
|
|
37
|
+
/** SSI issuer base URL: SSI_ISSUER_BASE_URL env or derived from host (getSsiApiUrl). */
|
|
38
|
+
export declare function getIssuerBaseUrl(baseUrl: string): string | null;
|
|
39
|
+
/**
|
|
40
|
+
* SSI Platform API base URL: SSI_API_BASE_URL env or derived from host
|
|
41
|
+
* (e.g. https://<subdomain>.cerealstackdev.com on known domains).
|
|
42
|
+
*/
|
|
43
|
+
export declare function getSsiApiUrl(baseUrl: string): string | null;
|
|
44
|
+
/** SSI redirect URI: SSI_REDIRECT_URI env or `${baseUrl}/api/v1/auth/callback`. */
|
|
45
|
+
export declare function getSsiRedirectUri(baseUrl: string): string | null;
|
|
46
|
+
export declare function getRequestConfig(req: Request): SSIRequestConfig;
|
|
47
|
+
export declare function getRequestConfig(headers: Headers): SSIRequestConfig;
|
|
48
|
+
/**
|
|
49
|
+
* Union type accepted anywhere AuthConfig is accepted (AuthServer, SessionManager).
|
|
50
|
+
*/
|
|
51
|
+
export type AuthConfigInput = AuthConfig | SSIRequestConfig;
|
|
52
|
+
/**
|
|
53
|
+
* Converts SSIRequestConfig to AuthConfig for use with AuthServer and SessionManager.
|
|
54
|
+
* Drops null/undefined so the library can fall back to env vars where appropriate.
|
|
55
|
+
*/
|
|
56
|
+
export declare function toAuthConfig(rc: SSIRequestConfig): AuthConfig;
|
|
57
|
+
/**
|
|
58
|
+
* Normalizes AuthConfigInput to AuthConfig. Use internally so AuthServer and SessionManager
|
|
59
|
+
* accept both AuthConfig and SSIRequestConfig.
|
|
60
|
+
*/
|
|
61
|
+
export declare function normalizeAuthConfig(input: AuthConfigInput | undefined): AuthConfig | undefined;
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// requestConfig.ts
|
|
3
|
+
// Types and helpers for config that host apps derive per-request and pass into AuthServer, SessionManager, and SSI API clients.
|
|
4
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
5
|
+
exports.getBaseUrl = getBaseUrl;
|
|
6
|
+
exports.firstBaseUrl = firstBaseUrl;
|
|
7
|
+
exports.getBaseApiUrl = getBaseApiUrl;
|
|
8
|
+
exports.getSsiClientId = getSsiClientId;
|
|
9
|
+
exports.getIssuerBaseUrl = getIssuerBaseUrl;
|
|
10
|
+
exports.getSsiApiUrl = getSsiApiUrl;
|
|
11
|
+
exports.getSsiRedirectUri = getSsiRedirectUri;
|
|
12
|
+
exports.getRequestConfig = getRequestConfig;
|
|
13
|
+
exports.toAuthConfig = toAuthConfig;
|
|
14
|
+
exports.normalizeAuthConfig = normalizeAuthConfig;
|
|
15
|
+
// --- Helpers used by getRequestConfig (env: NEXT_PUBLIC_APP_URL, SSI_*, request headers) ---
|
|
16
|
+
/**
|
|
17
|
+
* Get the base URL from environment or request headers.
|
|
18
|
+
* If NEXT_PUBLIC_APP_URL is set, returns it; otherwise derives from
|
|
19
|
+
* x-forwarded-proto, x-forwarded-host, origin, or host.
|
|
20
|
+
*/
|
|
21
|
+
function getBaseUrl(req) {
|
|
22
|
+
const fromEnv = process.env.NEXT_PUBLIC_APP_URL?.trim();
|
|
23
|
+
if (fromEnv)
|
|
24
|
+
return fromEnv;
|
|
25
|
+
const proto = req.headers.get('x-forwarded-proto') || 'https';
|
|
26
|
+
const forwardedHost = req.headers.get('x-forwarded-host');
|
|
27
|
+
if (forwardedHost)
|
|
28
|
+
return `${proto}://${forwardedHost}`;
|
|
29
|
+
const origin = req.headers.get('origin');
|
|
30
|
+
if (origin)
|
|
31
|
+
return origin;
|
|
32
|
+
const host = req.headers.get('host');
|
|
33
|
+
const hostWithoutPort = host ? host.split(':')[0] : undefined;
|
|
34
|
+
if (hostWithoutPort)
|
|
35
|
+
return `${proto}://${hostWithoutPort}`;
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
/** When baseUrl is a single URL or comma-separated list, return the first URL. */
|
|
39
|
+
function firstBaseUrl(baseUrl) {
|
|
40
|
+
if (!baseUrl)
|
|
41
|
+
return null;
|
|
42
|
+
const first = baseUrl.split(',')[0].trim();
|
|
43
|
+
return first || null;
|
|
44
|
+
}
|
|
45
|
+
/** App API base URL: NEXT_PUBLIC_API_URL or baseUrl. */
|
|
46
|
+
function getBaseApiUrl(req) {
|
|
47
|
+
return process.env.NEXT_PUBLIC_API_URL || getBaseUrl(req) || null;
|
|
48
|
+
}
|
|
49
|
+
/** SSI client id: SSI_CLIENT_ID env or subdomain on known Cereal Stack domains. */
|
|
50
|
+
function getSsiClientId(baseUrl) {
|
|
51
|
+
if (process.env.SSI_CLIENT_ID)
|
|
52
|
+
return process.env.SSI_CLIENT_ID;
|
|
53
|
+
try {
|
|
54
|
+
const url = new URL(baseUrl);
|
|
55
|
+
url.pathname = url.pathname.replace(/\/$/, '');
|
|
56
|
+
const hostname = url.hostname;
|
|
57
|
+
if (hostname.endsWith('.cerealstackdev.com') || hostname.endsWith('.cerealstackqa.com') || hostname.endsWith('.cerealstack.com')) {
|
|
58
|
+
const domainParts = hostname.split('.');
|
|
59
|
+
const subdomain = domainParts[domainParts.length - 3];
|
|
60
|
+
return subdomain ?? null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
// ignore
|
|
65
|
+
}
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
/** SSI issuer base URL: SSI_ISSUER_BASE_URL env or derived from host (getSsiApiUrl). */
|
|
69
|
+
function getIssuerBaseUrl(baseUrl) {
|
|
70
|
+
return process.env.SSI_ISSUER_BASE_URL || getSsiApiUrl(baseUrl) || null;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* SSI Platform API base URL: SSI_API_BASE_URL env or derived from host
|
|
74
|
+
* (e.g. https://<subdomain>.cerealstackdev.com on known domains).
|
|
75
|
+
*/
|
|
76
|
+
function getSsiApiUrl(baseUrl) {
|
|
77
|
+
if (process.env.SSI_API_BASE_URL)
|
|
78
|
+
return process.env.SSI_API_BASE_URL;
|
|
79
|
+
try {
|
|
80
|
+
const url = new URL(baseUrl);
|
|
81
|
+
url.pathname = url.pathname.replace(/\/$/, '');
|
|
82
|
+
const hostname = url.hostname;
|
|
83
|
+
if (hostname.endsWith('.cerealstackdev.com') || hostname.endsWith('.cerealstackqa.com') || hostname.endsWith('.cerealstack.com')) {
|
|
84
|
+
const domainParts = hostname.split('.');
|
|
85
|
+
const subdomain = domainParts[domainParts.length - 3];
|
|
86
|
+
const domain = domainParts[domainParts.length - 2] + '.' + domainParts[domainParts.length - 1];
|
|
87
|
+
return `https://${subdomain}.${domain}`;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
// ignore
|
|
92
|
+
}
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
/** SSI redirect URI: SSI_REDIRECT_URI env or `${baseUrl}/api/v1/auth/callback`. */
|
|
96
|
+
function getSsiRedirectUri(baseUrl) {
|
|
97
|
+
return process.env.SSI_REDIRECT_URI || `${baseUrl}/api/v1/auth/callback`;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Build SSIRequestConfig from the current request (or headers).
|
|
101
|
+
* Uses env vars (NEXT_PUBLIC_APP_URL, SSI_*, etc.) and request headers for base URL and SSI URLs.
|
|
102
|
+
* Apps can use this as-is or extend the result with app-specific fields (e.g. prisma, dbUrl).
|
|
103
|
+
*/
|
|
104
|
+
function getRequestConfig(arg) {
|
|
105
|
+
const req = arg instanceof Headers ? new Request('http://local', { headers: arg }) : arg;
|
|
106
|
+
const rawBaseUrl = getBaseUrl(req);
|
|
107
|
+
const baseUrl = firstBaseUrl(rawBaseUrl);
|
|
108
|
+
const fallback = baseUrl || '';
|
|
109
|
+
return {
|
|
110
|
+
baseUrl: baseUrl ?? null,
|
|
111
|
+
apiUrl: getBaseApiUrl(req),
|
|
112
|
+
ssiApiUrl: getSsiApiUrl(fallback),
|
|
113
|
+
ssiIssuerBaseUrl: getIssuerBaseUrl(fallback),
|
|
114
|
+
ssiRedirectUri: getSsiRedirectUri(fallback),
|
|
115
|
+
ssiClientId: getSsiClientId(fallback),
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
function isSSIRequestConfig(x) {
|
|
119
|
+
return x != null && typeof x === 'object' && 'ssiIssuerBaseUrl' in x;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Converts SSIRequestConfig to AuthConfig for use with AuthServer and SessionManager.
|
|
123
|
+
* Drops null/undefined so the library can fall back to env vars where appropriate.
|
|
124
|
+
*/
|
|
125
|
+
function toAuthConfig(rc) {
|
|
126
|
+
const auth = {};
|
|
127
|
+
if (rc.ssiIssuerBaseUrl != null && rc.ssiIssuerBaseUrl !== '')
|
|
128
|
+
auth.issuerBaseUrl = rc.ssiIssuerBaseUrl;
|
|
129
|
+
if (rc.ssiRedirectUri != null && rc.ssiRedirectUri !== '')
|
|
130
|
+
auth.redirectUri = rc.ssiRedirectUri;
|
|
131
|
+
if (rc.ssiClientId != null && rc.ssiClientId !== '')
|
|
132
|
+
auth.clientId = rc.ssiClientId;
|
|
133
|
+
return auth;
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Normalizes AuthConfigInput to AuthConfig. Use internally so AuthServer and SessionManager
|
|
137
|
+
* accept both AuthConfig and SSIRequestConfig.
|
|
138
|
+
*/
|
|
139
|
+
function normalizeAuthConfig(input) {
|
|
140
|
+
if (input == null)
|
|
141
|
+
return undefined;
|
|
142
|
+
if (isSSIRequestConfig(input))
|
|
143
|
+
return toAuthConfig(input);
|
|
144
|
+
return input;
|
|
145
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { SSIStorage } from '../storage/SSIStorage';
|
|
2
2
|
import type { KVValue } from '../storage/types';
|
|
3
|
-
import { AuthServer, type TokenResponse
|
|
3
|
+
import { AuthServer, type TokenResponse } from '../auth.server';
|
|
4
|
+
import { type AuthConfigInput } from '../requestConfig';
|
|
4
5
|
export declare const sessionRoles: {
|
|
5
6
|
allRoles: string[];
|
|
6
7
|
adminRoles: string[];
|
|
@@ -41,13 +42,22 @@ export declare class SessionManager {
|
|
|
41
42
|
private auth?;
|
|
42
43
|
private _sessionId?;
|
|
43
44
|
private authConfig?;
|
|
45
|
+
private bearerSession?;
|
|
46
|
+
private pendingBearerToken?;
|
|
44
47
|
private static inFlightRefresh;
|
|
45
48
|
private freshnessOnce;
|
|
46
|
-
|
|
49
|
+
/** Accepts AuthConfig or SSIRequestConfig (e.g. from getRequestConfig(req)). */
|
|
50
|
+
constructor(storage: SSIStorage, authConfig?: AuthConfigInput);
|
|
51
|
+
/**
|
|
52
|
+
* Create a session from environment: cookie or Authorization: Bearer JWT.
|
|
53
|
+
* When a Request is passed and contains "Authorization: Bearer <token>", the token is verified
|
|
54
|
+
* (valid and not expired); a session id is generated but not cached. Cookie is used only when
|
|
55
|
+
* no Bearer header is present.
|
|
56
|
+
*/
|
|
47
57
|
static fromEnv(): SessionManager;
|
|
48
|
-
static fromEnv(cookieHeader: string | null, authConfig?:
|
|
49
|
-
static fromEnv(request: Request, authConfig?:
|
|
50
|
-
static fromEnv(authConfig:
|
|
58
|
+
static fromEnv(cookieHeader: string | null, authConfig?: AuthConfigInput): SessionManager;
|
|
59
|
+
static fromEnv(request: Request, authConfig?: AuthConfigInput): SessionManager;
|
|
60
|
+
static fromEnv(authConfig: AuthConfigInput): SessionManager;
|
|
51
61
|
/**
|
|
52
62
|
* Async version that attempts to auto-detect the cookie header from Next.js context
|
|
53
63
|
* when called with no arguments. Falls back gracefully if not in a Next.js context.
|
|
@@ -65,9 +75,20 @@ export declare class SessionManager {
|
|
|
65
75
|
* const session = await SessionManager.fromEnvAsync(request, { clientId: 'my-client-id' });
|
|
66
76
|
*/
|
|
67
77
|
static fromEnvAsync(): Promise<SessionManager>;
|
|
68
|
-
static fromEnvAsync(cookieHeader: string | null, authConfig?:
|
|
69
|
-
static fromEnvAsync(request: Request, authConfig?:
|
|
70
|
-
static fromEnvAsync(authConfig:
|
|
78
|
+
static fromEnvAsync(cookieHeader: string | null, authConfig?: AuthConfigInput): Promise<SessionManager>;
|
|
79
|
+
static fromEnvAsync(request: Request, authConfig?: AuthConfigInput): Promise<SessionManager>;
|
|
80
|
+
static fromEnvAsync(authConfig: AuthConfigInput): Promise<SessionManager>;
|
|
81
|
+
/**
|
|
82
|
+
* Parse Authorization: Bearer <token> from Request or Headers.
|
|
83
|
+
* Returns the JWT string or null if missing/invalid format.
|
|
84
|
+
*/
|
|
85
|
+
private static parseBearerToken;
|
|
86
|
+
/**
|
|
87
|
+
* Verify the Bearer JWT and set ephemeral session (not cached).
|
|
88
|
+
* When token is provided (async path), generates a new sessionId.
|
|
89
|
+
* When called with no args, uses pendingBearerToken (sync path, lazy verification).
|
|
90
|
+
*/
|
|
91
|
+
private verifyAndSetBearerSession;
|
|
71
92
|
private parseCookies;
|
|
72
93
|
get sessionId(): string | undefined;
|
|
73
94
|
/** Cryptographically strong, URL-safe session id */
|
|
@@ -110,11 +131,13 @@ export declare class SessionManager {
|
|
|
110
131
|
/**
|
|
111
132
|
* Get session data by key. For 'claims', decodes the id_token payload
|
|
112
133
|
* WITHOUT verification (use your verifier upstream for security).
|
|
134
|
+
* Bearer JWT sessions: returns from verified ephemeral data (not cached).
|
|
113
135
|
*/
|
|
114
136
|
getSessionData(sessionId: string, dataKey: SessionDataKey): Promise<KVValue | null>;
|
|
115
137
|
private ensureFreshOnce;
|
|
116
138
|
/**
|
|
117
139
|
* Delete all keys under this session object (id/access/refresh).
|
|
140
|
+
* For Bearer sessions, clears in-memory state only (nothing was cached).
|
|
118
141
|
*/
|
|
119
142
|
clearSession(sessionId: string): Promise<string>;
|
|
120
143
|
/**
|
|
@@ -4,6 +4,7 @@ exports.SessionManager = exports.sessionRoles = void 0;
|
|
|
4
4
|
const SSIStorage_1 = require("../storage/SSIStorage");
|
|
5
5
|
const crypto_1 = require("crypto");
|
|
6
6
|
const auth_server_1 = require("../auth.server");
|
|
7
|
+
const requestConfig_1 = require("../requestConfig");
|
|
7
8
|
exports.sessionRoles = {
|
|
8
9
|
allRoles: ['platform_admin', 'member', 'owner', 'admin', 'billing', 'readonly'],
|
|
9
10
|
adminRoles: ['platform_admin', 'owner', 'admin'],
|
|
@@ -35,33 +36,37 @@ function resolveCookieDomain() {
|
|
|
35
36
|
}
|
|
36
37
|
}
|
|
37
38
|
class SessionManager {
|
|
39
|
+
/** Accepts AuthConfig or SSIRequestConfig (e.g. from getRequestConfig(req)). */
|
|
38
40
|
constructor(storage, authConfig) {
|
|
39
41
|
// Per-request-instance guard so TTL/refresh is evaluated at most once per session
|
|
40
42
|
this.freshnessOnce = new Map();
|
|
41
43
|
this.storage = storage.withClass('session'); // pin class="session"
|
|
42
|
-
this.authConfig = authConfig;
|
|
44
|
+
this.authConfig = (0, requestConfig_1.normalizeAuthConfig)(authConfig);
|
|
43
45
|
}
|
|
44
46
|
static fromEnv(cookieHeaderOrRequestOrConfig, authConfig) {
|
|
45
|
-
// Handle case where first arg is AuthConfig (no cookie header/request)
|
|
46
47
|
let actualAuthConfig;
|
|
47
48
|
let cookieHeaderOrRequest;
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
('clientId' in cookieHeaderOrRequestOrConfig || 'issuerBaseUrl' in cookieHeaderOrRequestOrConfig)) {
|
|
52
|
-
// First arg is AuthConfig
|
|
49
|
+
const isConfigLike = (x) => x != null && typeof x === 'object' && !(x instanceof Request) &&
|
|
50
|
+
('clientId' in x || 'issuerBaseUrl' in x || 'ssiClientId' in x || 'ssiIssuerBaseUrl' in x);
|
|
51
|
+
if (cookieHeaderOrRequestOrConfig && isConfigLike(cookieHeaderOrRequestOrConfig)) {
|
|
53
52
|
actualAuthConfig = cookieHeaderOrRequestOrConfig;
|
|
54
53
|
}
|
|
55
54
|
else {
|
|
56
|
-
// First arg is cookie header/request, second is optional AuthConfig
|
|
57
55
|
cookieHeaderOrRequest = cookieHeaderOrRequestOrConfig;
|
|
58
56
|
actualAuthConfig = authConfig;
|
|
59
57
|
}
|
|
60
58
|
const session = new SessionManager(SSIStorage_1.SSIStorage.fromEnv(), actualAuthConfig);
|
|
61
59
|
if (cookieHeaderOrRequest instanceof Request) {
|
|
62
|
-
const
|
|
63
|
-
if (
|
|
64
|
-
session._sessionId = session.
|
|
60
|
+
const bearerToken = SessionManager.parseBearerToken(cookieHeaderOrRequest);
|
|
61
|
+
if (bearerToken) {
|
|
62
|
+
session._sessionId = session.generateSessionId();
|
|
63
|
+
session.pendingBearerToken = bearerToken;
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
const cookieHeader = cookieHeaderOrRequest.headers.get('cookie');
|
|
67
|
+
if (cookieHeader) {
|
|
68
|
+
session._sessionId = session.parseCookies(cookieHeader);
|
|
69
|
+
}
|
|
65
70
|
}
|
|
66
71
|
}
|
|
67
72
|
else if (cookieHeaderOrRequest) {
|
|
@@ -73,52 +78,102 @@ class SessionManager {
|
|
|
73
78
|
return session;
|
|
74
79
|
}
|
|
75
80
|
static async fromEnvAsync(cookieHeaderOrRequestOrConfig, authConfig) {
|
|
76
|
-
// Handle case where first arg is AuthConfig (no cookie header/request)
|
|
77
81
|
let actualAuthConfig;
|
|
78
82
|
let cookieHeaderOrRequest;
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
('clientId' in cookieHeaderOrRequestOrConfig || 'issuerBaseUrl' in cookieHeaderOrRequestOrConfig)) {
|
|
83
|
-
// First arg is AuthConfig
|
|
83
|
+
const isConfigLike = (x) => x != null && typeof x === 'object' && !(x instanceof Request) &&
|
|
84
|
+
('clientId' in x || 'issuerBaseUrl' in x || 'ssiClientId' in x || 'ssiIssuerBaseUrl' in x);
|
|
85
|
+
if (cookieHeaderOrRequestOrConfig && isConfigLike(cookieHeaderOrRequestOrConfig)) {
|
|
84
86
|
actualAuthConfig = cookieHeaderOrRequestOrConfig;
|
|
85
87
|
}
|
|
86
88
|
else {
|
|
87
|
-
// First arg is cookie header/request, second is optional AuthConfig
|
|
88
89
|
cookieHeaderOrRequest = cookieHeaderOrRequestOrConfig;
|
|
89
90
|
actualAuthConfig = authConfig;
|
|
90
91
|
}
|
|
91
92
|
const session = new SessionManager(SSIStorage_1.SSIStorage.fromEnv(), actualAuthConfig);
|
|
92
93
|
if (cookieHeaderOrRequest instanceof Request) {
|
|
93
|
-
const
|
|
94
|
-
if (
|
|
95
|
-
|
|
94
|
+
const bearerToken = SessionManager.parseBearerToken(cookieHeaderOrRequest);
|
|
95
|
+
if (bearerToken) {
|
|
96
|
+
await session.verifyAndSetBearerSession(bearerToken);
|
|
97
|
+
}
|
|
98
|
+
else {
|
|
99
|
+
const cookieHeader = cookieHeaderOrRequest.headers.get('cookie');
|
|
100
|
+
if (cookieHeader) {
|
|
101
|
+
session._sessionId = session.parseCookies(cookieHeader);
|
|
102
|
+
}
|
|
96
103
|
}
|
|
97
104
|
}
|
|
98
105
|
else if (cookieHeaderOrRequest) {
|
|
99
106
|
session._sessionId = session.parseCookies(cookieHeaderOrRequest);
|
|
100
107
|
}
|
|
101
108
|
else {
|
|
102
|
-
// When called with no arguments, try to auto-detect
|
|
109
|
+
// When called with no arguments, try to auto-detect from Next.js context
|
|
103
110
|
try {
|
|
104
|
-
// Dynamic import of next/headers - only available in Next.js server context
|
|
105
|
-
// @ts-expect-error - next/headers types are available at runtime but TypeScript can't resolve them during package build
|
|
106
111
|
const { headers } = await import('next/headers');
|
|
107
112
|
const headersList = await headers();
|
|
108
113
|
if (headersList && typeof headersList.get === 'function') {
|
|
109
|
-
const
|
|
110
|
-
|
|
111
|
-
|
|
114
|
+
const authHeader = headersList.get('authorization');
|
|
115
|
+
const bearerToken = authHeader?.trim().toLowerCase().startsWith('bearer ')
|
|
116
|
+
? authHeader.trim().slice(7).trim() || null
|
|
117
|
+
: null;
|
|
118
|
+
if (bearerToken) {
|
|
119
|
+
await session.verifyAndSetBearerSession(bearerToken);
|
|
120
|
+
}
|
|
121
|
+
else {
|
|
122
|
+
const cookieHeader = headersList.get('cookie');
|
|
123
|
+
if (cookieHeader) {
|
|
124
|
+
session._sessionId = session.parseCookies(cookieHeader);
|
|
125
|
+
}
|
|
112
126
|
}
|
|
113
127
|
}
|
|
114
128
|
}
|
|
115
129
|
catch {
|
|
116
130
|
// Not in Next.js context or headers() not available - silently continue
|
|
117
|
-
// This is expected when called outside of Next.js or when creating new sessions
|
|
118
131
|
}
|
|
119
132
|
}
|
|
120
133
|
return session;
|
|
121
134
|
}
|
|
135
|
+
/**
|
|
136
|
+
* Parse Authorization: Bearer <token> from Request or Headers.
|
|
137
|
+
* Returns the JWT string or null if missing/invalid format.
|
|
138
|
+
*/
|
|
139
|
+
static parseBearerToken(requestOrHeaders) {
|
|
140
|
+
const auth = requestOrHeaders instanceof Request
|
|
141
|
+
? requestOrHeaders.headers.get('authorization')
|
|
142
|
+
: requestOrHeaders.get('authorization');
|
|
143
|
+
if (!auth || typeof auth !== 'string')
|
|
144
|
+
return null;
|
|
145
|
+
const trimmed = auth.trim();
|
|
146
|
+
if (!trimmed.toLowerCase().startsWith('bearer '))
|
|
147
|
+
return null;
|
|
148
|
+
const token = trimmed.slice(7).trim();
|
|
149
|
+
return token || null;
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Verify the Bearer JWT and set ephemeral session (not cached).
|
|
153
|
+
* When token is provided (async path), generates a new sessionId.
|
|
154
|
+
* When called with no args, uses pendingBearerToken (sync path, lazy verification).
|
|
155
|
+
*/
|
|
156
|
+
async verifyAndSetBearerSession(token) {
|
|
157
|
+
const raw = token ?? this.pendingBearerToken;
|
|
158
|
+
if (!raw)
|
|
159
|
+
return;
|
|
160
|
+
const auth = this.getAuth();
|
|
161
|
+
let claims;
|
|
162
|
+
try {
|
|
163
|
+
claims = (await auth.verifyAndDecodeJwt(raw));
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
this._sessionId = undefined;
|
|
167
|
+
this.pendingBearerToken = undefined;
|
|
168
|
+
throw new Error('Invalid or expired Bearer JWT');
|
|
169
|
+
}
|
|
170
|
+
const exp = typeof claims.exp === 'number' ? claims.exp : 0;
|
|
171
|
+
const sessionId = token ? this.generateSessionId() : this._sessionId;
|
|
172
|
+
if (token)
|
|
173
|
+
this._sessionId = sessionId;
|
|
174
|
+
this.bearerSession = { sessionId, accessToken: raw, claims, exp };
|
|
175
|
+
this.pendingBearerToken = undefined;
|
|
176
|
+
}
|
|
122
177
|
parseCookies(header) {
|
|
123
178
|
const cookieName = process.env.SSI_COOKIE_NAME ?? 'ssi_session';
|
|
124
179
|
for (const part of header.split(/;\s*/)) {
|
|
@@ -219,8 +274,43 @@ class SessionManager {
|
|
|
219
274
|
/**
|
|
220
275
|
* Get session data by key. For 'claims', decodes the id_token payload
|
|
221
276
|
* WITHOUT verification (use your verifier upstream for security).
|
|
277
|
+
* Bearer JWT sessions: returns from verified ephemeral data (not cached).
|
|
222
278
|
*/
|
|
223
279
|
async getSessionData(sessionId, dataKey) {
|
|
280
|
+
if (this.bearerSession?.sessionId === sessionId) {
|
|
281
|
+
const b = this.bearerSession;
|
|
282
|
+
if (dataKey === 'claims')
|
|
283
|
+
return b.claims;
|
|
284
|
+
if (dataKey === 'access_token')
|
|
285
|
+
return b.accessToken;
|
|
286
|
+
if (dataKey === 'id_token')
|
|
287
|
+
return b.accessToken;
|
|
288
|
+
if (dataKey === 'refresh_token')
|
|
289
|
+
return null;
|
|
290
|
+
return null;
|
|
291
|
+
}
|
|
292
|
+
if (this.pendingBearerToken && this._sessionId === sessionId) {
|
|
293
|
+
try {
|
|
294
|
+
await this.verifyAndSetBearerSession();
|
|
295
|
+
}
|
|
296
|
+
catch {
|
|
297
|
+
this._sessionId = undefined;
|
|
298
|
+
this.pendingBearerToken = undefined;
|
|
299
|
+
return null;
|
|
300
|
+
}
|
|
301
|
+
if (this.bearerSession?.sessionId === sessionId) {
|
|
302
|
+
const b = this.bearerSession;
|
|
303
|
+
if (dataKey === 'claims')
|
|
304
|
+
return b.claims;
|
|
305
|
+
if (dataKey === 'access_token')
|
|
306
|
+
return b.accessToken;
|
|
307
|
+
if (dataKey === 'id_token')
|
|
308
|
+
return b.accessToken;
|
|
309
|
+
if (dataKey === 'refresh_token')
|
|
310
|
+
return null;
|
|
311
|
+
return null;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
224
314
|
const keyToCheck = dataKey === 'claims' ? 'id_token' : dataKey;
|
|
225
315
|
try {
|
|
226
316
|
await this.ensureFreshOnce(sessionId);
|
|
@@ -256,6 +346,11 @@ class SessionManager {
|
|
|
256
346
|
return value;
|
|
257
347
|
}
|
|
258
348
|
ensureFreshOnce(sessionId) {
|
|
349
|
+
if (this.bearerSession?.sessionId === sessionId)
|
|
350
|
+
return Promise.resolve();
|
|
351
|
+
if (this.pendingBearerToken && this._sessionId === sessionId) {
|
|
352
|
+
return this.verifyAndSetBearerSession();
|
|
353
|
+
}
|
|
259
354
|
const existing = this.freshnessOnce.get(sessionId);
|
|
260
355
|
if (existing)
|
|
261
356
|
return existing;
|
|
@@ -326,9 +421,17 @@ class SessionManager {
|
|
|
326
421
|
}
|
|
327
422
|
/**
|
|
328
423
|
* Delete all keys under this session object (id/access/refresh).
|
|
424
|
+
* For Bearer sessions, clears in-memory state only (nothing was cached).
|
|
329
425
|
*/
|
|
330
426
|
async clearSession(sessionId) {
|
|
331
427
|
const deleteCookieHeader = this.buildSessionCookie('', { maxAge: 0 });
|
|
428
|
+
if (this.bearerSession?.sessionId === sessionId || (this.pendingBearerToken && this._sessionId === sessionId)) {
|
|
429
|
+
this.bearerSession = undefined;
|
|
430
|
+
this.pendingBearerToken = undefined;
|
|
431
|
+
if (this._sessionId === sessionId)
|
|
432
|
+
this._sessionId = undefined;
|
|
433
|
+
return deleteCookieHeader;
|
|
434
|
+
}
|
|
332
435
|
const keys = (await this.storage.keysForObject(sessionId)) ?? [];
|
|
333
436
|
if (!keys.length)
|
|
334
437
|
return deleteCookieHeader;
|
|
@@ -355,11 +458,33 @@ class SessionManager {
|
|
|
355
458
|
}
|
|
356
459
|
/** Returns remaining TTL in seconds for this session's id_token, or null if none */
|
|
357
460
|
async getSessionTtlSeconds(sessionId) {
|
|
461
|
+
if (this.bearerSession?.sessionId === sessionId) {
|
|
462
|
+
const secs = this.bearerSession.exp - Math.floor(Date.now() / 1000);
|
|
463
|
+
return Math.max(0, secs);
|
|
464
|
+
}
|
|
465
|
+
if (this.pendingBearerToken && this._sessionId === sessionId) {
|
|
466
|
+
await this.verifyAndSetBearerSession();
|
|
467
|
+
if (this.bearerSession?.sessionId === sessionId) {
|
|
468
|
+
const secs = this.bearerSession.exp - Math.floor(Date.now() / 1000);
|
|
469
|
+
return Math.max(0, secs);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
358
472
|
return this.storage.getRemainingTtl(sessionId, 'id_token');
|
|
359
473
|
}
|
|
360
474
|
async getVerifiedClaims(sessionId) {
|
|
361
475
|
if (!sessionId)
|
|
362
476
|
return null;
|
|
477
|
+
if (this.bearerSession?.sessionId === sessionId)
|
|
478
|
+
return this.bearerSession.claims;
|
|
479
|
+
if (this.pendingBearerToken && this._sessionId === sessionId) {
|
|
480
|
+
try {
|
|
481
|
+
await this.verifyAndSetBearerSession();
|
|
482
|
+
return this.bearerSession?.sessionId === sessionId ? this.bearerSession.claims : null;
|
|
483
|
+
}
|
|
484
|
+
catch {
|
|
485
|
+
return null;
|
|
486
|
+
}
|
|
487
|
+
}
|
|
363
488
|
try {
|
|
364
489
|
await this.ensureFreshOnce(sessionId);
|
|
365
490
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@serialsubscriptions/platform-integration",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.85.4",
|
|
4
4
|
"description": "Serial Subscriptions Libraries",
|
|
5
5
|
"main": "lib/index.js",
|
|
6
6
|
"types": "lib/index.d.ts",
|
|
@@ -67,5 +67,8 @@
|
|
|
67
67
|
"peerDependencies": {
|
|
68
68
|
"next": "^15.0.0"
|
|
69
69
|
},
|
|
70
|
+
"optionalDependencies": {
|
|
71
|
+
"next": "^15.0.0"
|
|
72
|
+
},
|
|
70
73
|
"license": "MIT"
|
|
71
74
|
}
|