@ibgib/web-gib 0.0.49 → 0.0.50

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/dist/AUTO-GENERATED-version.d.mts +1 -1
  2. package/dist/AUTO-GENERATED-version.mjs +1 -1
  3. package/dist/common/settings/settings-constants.d.mts +8 -1
  4. package/dist/common/settings/settings-constants.d.mts.map +1 -1
  5. package/dist/common/settings/settings-constants.mjs +7 -0
  6. package/dist/common/settings/settings-constants.mjs.map +1 -1
  7. package/dist/common/settings/settings-helpers.d.mts.map +1 -1
  8. package/dist/common/settings/settings-helpers.mjs +3 -1
  9. package/dist/common/settings/settings-helpers.mjs.map +1 -1
  10. package/dist/common/settings/settings-types.d.mts +5 -2
  11. package/dist/common/settings/settings-types.d.mts.map +1 -1
  12. package/dist/identity/sso/sso-config-helper.d.mts +7 -0
  13. package/dist/identity/sso/sso-config-helper.d.mts.map +1 -0
  14. package/dist/identity/sso/sso-config-helper.mjs +42 -0
  15. package/dist/identity/sso/sso-config-helper.mjs.map +1 -0
  16. package/dist/identity/sso/sso-custodian-service.d.mts +125 -0
  17. package/dist/identity/sso/sso-custodian-service.d.mts.map +1 -0
  18. package/dist/identity/sso/sso-custodian-service.mjs +373 -0
  19. package/dist/identity/sso/sso-custodian-service.mjs.map +1 -0
  20. package/dist/identity/sso/sso-custodian-service.respec.d.mts +2 -0
  21. package/dist/identity/sso/sso-custodian-service.respec.d.mts.map +1 -0
  22. package/dist/identity/sso/sso-custodian-service.respec.mjs +167 -0
  23. package/dist/identity/sso/sso-custodian-service.respec.mjs.map +1 -0
  24. package/dist/identity/sso/sso-types.d.mts +40 -0
  25. package/dist/identity/sso/sso-types.d.mts.map +1 -0
  26. package/dist/identity/sso/sso-types.mjs +7 -0
  27. package/dist/identity/sso/sso-types.mjs.map +1 -0
  28. package/package.json +4 -3
  29. package/src/AUTO-GENERATED-version.mts +1 -1
  30. package/src/common/settings/settings-constants.mts +9 -0
  31. package/src/common/settings/settings-helpers.mts +4 -1
  32. package/src/common/settings/settings-types.mts +7 -2
  33. package/src/identity/sso/sso-config-helper.mts +52 -0
  34. package/src/identity/sso/sso-custodian-service.mts +453 -0
  35. package/src/identity/sso/sso-custodian-service.respec.mts +206 -0
  36. package/src/identity/sso/sso-types.mts +44 -0
  37. package/src/ui/component/README.md +53 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ibgib/web-gib",
3
- "version": "0.0.49",
3
+ "version": "0.0.50",
4
4
  "description": "Framework for creating agentic ibGib web apps. Contains plumbing for ibgib components, agentic framework (currently only Gemini implemented), web-based IndexedDB storage substrate, and more.",
5
5
  "funding": {
6
6
  "type": "individual",
@@ -14,6 +14,7 @@
14
14
  "//_orchestration": "~~ ORCHESTRATION (handled by @ibgib/build-gib) ~~",
15
15
  "//_publish": "~~ PUBLISH ~~",
16
16
  "pack": "npm pack --pack-destination=\"./published\"",
17
+ "test:node:nobuild": "node dist/respec-gib.node.mjs --inspect",
17
18
  "prepare:publish": "npm version patch && node ../../build/dist/concrete-build/build-web-gib.mjs --prod && npm run pack",
18
19
  "man:prepare:publish": "use this to patch > build > pack for publishing to npm repo"
19
20
  },
@@ -28,10 +29,10 @@
28
29
  "license": "ISC",
29
30
  "dependencies": {
30
31
  "@google/genai": "^1.33.0",
31
- "@ibgib/core-gib": "^0.1.63",
32
+ "@ibgib/core-gib": "^0.1.64",
32
33
  "@ibgib/encrypt-gib": "^0.2.37",
33
34
  "@ibgib/helper-gib": "^0.0.36",
34
- "@ibgib/ts-gib": "^0.5.32"
35
+ "@ibgib/ts-gib": "^0.5.33"
35
36
  },
36
37
  "engines": {
37
38
  "node": ">=19.0.0"
@@ -11,4 +11,4 @@
11
11
  /**
12
12
  * this is the version of this package, auto-updated in the build process
13
13
  */
14
- export const AUTO_GENERATED_VERSION = '0.0.49';
14
+ export const AUTO_GENERATED_VERSION = '0.0.50';
@@ -14,6 +14,7 @@ export const SETTINGS_TYPE_PROJECTSDROPDOWN = 'projects-dropdown';
14
14
  export const SETTINGS_TYPE_PROJECT = 'project';
15
15
  export const SETTINGS_TYPE_MINIGAME = 'minigame';
16
16
  export const SETTINGS_TYPE_CHRONOLOGYS = 'chronologys';
17
+ export const SETTINGS_TYPE_IDENTITY_MANAGER = 'identity-manager';
17
18
  /**
18
19
  * Discriminator for Settings info
19
20
  */
@@ -26,6 +27,7 @@ export type SettingsType =
26
27
  | typeof SETTINGS_TYPE_PROJECT
27
28
  | typeof SETTINGS_TYPE_MINIGAME
28
29
  | typeof SETTINGS_TYPE_CHRONOLOGYS
30
+ | typeof SETTINGS_TYPE_IDENTITY_MANAGER
29
31
  ;
30
32
  export const SettingsType = {
31
33
  general: SETTINGS_TYPE_GENERAL satisfies SettingsType,
@@ -36,6 +38,7 @@ export const SettingsType = {
36
38
  project: SETTINGS_TYPE_PROJECT satisfies SettingsType,
37
39
  minigame: SETTINGS_TYPE_MINIGAME satisfies SettingsType,
38
40
  chronologys: SETTINGS_TYPE_CHRONOLOGYS satisfies SettingsType,
41
+ identityManager: SETTINGS_TYPE_IDENTITY_MANAGER satisfies SettingsType,
39
42
  } satisfies { readonly [key: string]: SettingsType; };
40
43
  export const SETTINGS_TYPE_VALID_VALUES: SettingsType[] = Object.values(SettingsType);
41
44
  export function isSettingsType(value: any): value is SettingsType {
@@ -69,6 +72,12 @@ export const DEFAULT_SETTINGS_CHRONOLOGYS: Settings_Chronologys = {
69
72
  activeChildTjpAddr: undefined,
70
73
  }
71
74
 
75
+ export const DEFAULT_SETTINGS_IDENTITY_MANAGER = {
76
+ type: SettingsType.identityManager,
77
+ openChildTjpAddrs: [],
78
+ activeChildTjpAddr: undefined,
79
+ }
80
+
72
81
  export const DEFAULT_SETTINGS_DATA_V1: SettingsData_V1 = {
73
82
  sections: {},
74
83
  children: {},
@@ -14,7 +14,8 @@ import {
14
14
  SETTINGS_ATOM,
15
15
  DEFAULT_SETTINGS_PROJECT,
16
16
  DEFAULT_SETTINGS_MINIGAME,
17
- DEFAULT_SETTINGS_CHRONOLOGYS
17
+ DEFAULT_SETTINGS_CHRONOLOGYS,
18
+ DEFAULT_SETTINGS_IDENTITY_MANAGER
18
19
  } from "./settings-constants.mjs";
19
20
  import { IbGib_V1 } from "@ibgib/ts-gib/dist/V1/types.mjs";
20
21
  import { getIbAndGib } from "@ibgib/ts-gib/dist/helper.mjs";
@@ -110,6 +111,8 @@ export async function getDefaultSettings<TSettings extends IbGibSettings>({
110
111
  return clone(DEFAULT_SETTINGS_MINIGAME) as TSettings;
111
112
  case SettingsType.chronologys:
112
113
  return clone(DEFAULT_SETTINGS_CHRONOLOGYS) as TSettings;
114
+ case SettingsType.identityManager:
115
+ return clone(DEFAULT_SETTINGS_IDENTITY_MANAGER) as any as TSettings;
113
116
  default:
114
117
  throw new Error(`(UNEXPECTED) unknown settingsType (${settingsType})? right now, we're whitelisting only. SettingsTypes: ${SETTINGS_TYPE_VALID_VALUES} (E: 801a7ade4c0849b308d5c8f8f0318d25)`);
115
118
  }
@@ -15,7 +15,7 @@ import { IbGibAddr } from "@ibgib/ts-gib/dist/types.mjs";
15
15
 
16
16
  import {
17
17
  SETTINGS_ATOM, SETTINGS_TYPE_CHRONOLOGYS, SETTINGS_TYPE_MINIGAME, SETTINGS_TYPE_PROJECT,
18
- SETTINGS_TYPE_TEXTEDITOR, SettingsType,
18
+ SETTINGS_TYPE_TEXTEDITOR, SETTINGS_TYPE_IDENTITY_MANAGER, SettingsType,
19
19
  } from "./settings-constants.mjs";
20
20
  import { LensMode } from "../project/project-types.mjs";
21
21
 
@@ -80,6 +80,10 @@ export interface Settings_Chronologys extends SettingsBase, SettingsWithTabs {
80
80
  // activeChildTjpAddr: IbGibAddr | undefined;
81
81
  }
82
82
 
83
+ export interface Settings_IdentityManager extends SettingsBase, SettingsWithTabs {
84
+ type: typeof SETTINGS_TYPE_IDENTITY_MANAGER;
85
+ }
86
+
83
87
  /**
84
88
  * union type of known Settings interfaces for various use cases.
85
89
  *
@@ -95,7 +99,8 @@ export type IbGibSettings =
95
99
  | Settings_TextEditor
96
100
  | Settings_Project
97
101
  | Settings_Minigame
98
- | Settings_Chronologys;
102
+ | Settings_Chronologys
103
+ | Settings_IdentityManager;
99
104
 
100
105
  export interface SettingsData_V1 extends IbGibData_V1 {
101
106
  /**
@@ -0,0 +1,52 @@
1
+ import { SsoServerConfig, SsoProviderServerConfig, SsoProviderId } from './sso-types.mjs';
2
+
3
+ /**
4
+ * Loads the SSO configuration from environment variables.
5
+ * Parses active OAuth2 providers dynamically based on their environment keys.
6
+ */
7
+ export function loadSsoServerConfig(): SsoServerConfig {
8
+ const ssoServerKdfSecret = process.env.SSO_SERVER_KDF_SECRET;
9
+ const sessionSecret = process.env.SESSION_SECRET;
10
+
11
+ if (!ssoServerKdfSecret) {
12
+ throw new Error(
13
+ `SSO_SERVER_KDF_SECRET is missing from environment. (E: 840899ab4fdfd23e89cfae1889cfae12)`
14
+ );
15
+ }
16
+ if (!sessionSecret) {
17
+ throw new Error(
18
+ `SESSION_SECRET is missing from environment. (E: bfa899ab4fdfd23e89cfae23)`
19
+ );
20
+ }
21
+
22
+ const providers: Partial<Record<SsoProviderId, SsoProviderServerConfig>> = {};
23
+
24
+ // Load Google Provider if configured
25
+ if (process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET) {
26
+ providers.google = {
27
+ providerId: 'google',
28
+ clientId: process.env.GOOGLE_CLIENT_ID,
29
+ clientSecret: process.env.GOOGLE_CLIENT_SECRET,
30
+ tokenUrl: 'https://oauth2.googleapis.com/token',
31
+ userInfoUrl: 'https://openidconnect.googleapis.com/v1/userinfo',
32
+ jwksUrl: 'https://www.googleapis.com/oauth2/v3/certs'
33
+ };
34
+ }
35
+
36
+ // Load GitHub Provider if configured
37
+ if (process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET) {
38
+ providers.github = {
39
+ providerId: 'github',
40
+ clientId: process.env.GITHUB_CLIENT_ID,
41
+ clientSecret: process.env.GITHUB_CLIENT_SECRET,
42
+ tokenUrl: 'https://github.com/login/oauth/access_token',
43
+ userInfoUrl: 'https://api.github.com/user'
44
+ };
45
+ }
46
+
47
+ return {
48
+ ssoServerKdfSecret,
49
+ sessionSecret,
50
+ providers
51
+ };
52
+ }
@@ -0,0 +1,453 @@
1
+ import { createPublicKey, verify } from 'node:crypto';
2
+ import { hash, HashAlgorithm } from '@ibgib/helper-gib/dist/helpers/utils-helper.mjs';
3
+ import { kdf_recursiveSaltWrap } from '@ibgib/core-gib/dist/keystone/kdf/kdf-helpers.mjs';
4
+ import { KeystoneService_V1 } from '@ibgib/core-gib/dist/keystone/keystone-service-v1.mjs';
5
+ import { KeystoneProfileBuilder } from '@ibgib/core-gib/dist/keystone/policy/keystone-profile-builder.mjs';
6
+ import { KeystoneIbGib_V1, KeystoneChallengePool } from '@ibgib/core-gib/dist/keystone/keystone-types.mjs';
7
+ import { POOL_ID_CUSTODIAN_MANAGE, KEYSTONE_VERB_MANAGE, KEYSTONE_VERB_REVOKE } from '@ibgib/core-gib/dist/keystone/keystone-constants.mjs';
8
+ import { createStandardPoolConfig } from '@ibgib/core-gib/dist/keystone/keystone-config-builder.mjs';
9
+ import { generateOpaqueChallengeId } from '@ibgib/core-gib/dist/keystone/keystone-helpers.mjs';
10
+ import { KeystoneStrategyFactory } from '@ibgib/core-gib/dist/keystone/strategy/keystone-strategy-factory.mjs';
11
+ import {
12
+ SsoServerConfig,
13
+ SsoProviderId,
14
+ OAuthUserInfo,
15
+ SsoProviderServerConfig,
16
+ SsoProviderClientConfig
17
+ } from './sso-types.mjs';
18
+
19
+ interface JwkKey {
20
+ kty: string;
21
+ alg?: string;
22
+ use?: string;
23
+ kid: string;
24
+ n: string;
25
+ e: string;
26
+ [key: string]: any;
27
+ }
28
+
29
+ interface JwksResponse {
30
+ keys: JwkKey[];
31
+ }
32
+
33
+ /**
34
+ * Server-side service for token exchange, JWT validation, and user profile normalization.
35
+ */
36
+ export class SsoCustodianService {
37
+ private lc = `[${SsoCustodianService.name}]`;
38
+
39
+ // In-memory cache for JSON Web Key Sets (JWKS) per provider
40
+ private jwksCache: Map<SsoProviderId, { keys: JwkKey[]; expiresAt: number }> = new Map();
41
+ private jwksTtlMs = 24 * 60 * 60 * 1000; // Cache certs for 24 hours
42
+
43
+ /**
44
+ * @param config The server's global SSO configuration.
45
+ * - Visibility: Private (Contains high-entropy secrets like `ssoServerKdfSecret` and `sessionSecret`).
46
+ * - Generator: Server (Bootstrapped from node environment variables on server initialization).
47
+ */
48
+ constructor(private config: SsoServerConfig) {}
49
+
50
+ /**
51
+ * Exchanges an authorization code for an OAuth2 token and retrieves normalized user info.
52
+ *
53
+ * @param providerId The string identifier of the OAuth provider (e.g. 'google' or 'github').
54
+ * - Visibility: Public (Non-sensitive metadata string).
55
+ * - Generator: Client (Sent from browser UI selection).
56
+ *
57
+ * @param code The temporary authorization code returned by the OAuth provider.
58
+ * - Visibility: Private (Confidential short-lived bearer credential. Must be kept private).
59
+ * - Generator: Provider (Created by provider's OAuth page and sent to client redirect URI).
60
+ *
61
+ * @param redirectUri The redirect URI registered in the provider portal.
62
+ * - Visibility: Public (Configuration URL).
63
+ * - Generator: Client/Server (Derived from environmental configuration and sent by client).
64
+ */
65
+ async getOAuthUserInfo(providerId: SsoProviderId, code: string, redirectUri: string): Promise<OAuthUserInfo> {
66
+ const lc = `${this.lc}[${this.getOAuthUserInfo.name}]`;
67
+ const providerConfig = this.config.providers[providerId];
68
+ if (!providerConfig) {
69
+ throw new Error(`Provider '${providerId}' is not configured on this server. (E: c28900abfefd23e89cf23f12)`);
70
+ }
71
+
72
+ console.log(`${lc} Exchanging code for provider: ${providerId}`);
73
+
74
+ // 1. Exchange authorization code for tokens
75
+ const tokens = await this.exchangeCodeForTokens(providerConfig, code, redirectUri);
76
+
77
+ // 2. Retrieve and normalize user info
78
+ if (providerId === 'google') {
79
+ // Google returns an id_token (JWT) directly containing user info
80
+ if (!tokens.id_token) {
81
+ throw new Error(`Google token exchange did not return id_token. (E: e28900abfefd23e89cf45f23)`);
82
+ }
83
+ return this.verifyAndNormalizeGoogleToken(tokens.id_token);
84
+ } else if (providerId === 'github') {
85
+ // GitHub requires calling the /user API using the access_token
86
+ if (!tokens.access_token) {
87
+ throw new Error(`GitHub token exchange did not return access_token. (E: a28900abfefd23e89cf56f34)`);
88
+ }
89
+ return this.fetchAndNormalizeGithubUser(providerConfig, tokens.access_token);
90
+ }
91
+
92
+ throw new Error(`Unsupported provider: ${providerId}. (E: d28900abfefd23e89cf67f45)`);
93
+ }
94
+
95
+ /**
96
+ * Directly validates a Google ID Token (JWT) locally and returns normalized user info.
97
+ * Useful for client-side authentication handshakes.
98
+ *
99
+ * @param idToken The raw base64-encoded signed JSON Web Token (JWT) ID token returned by Google.
100
+ * - Visibility: Private (Sensitive bearer credential containing signature and claims. Must be kept private).
101
+ * - Generator: Provider (Generated and signed cryptographically by Google authentication servers).
102
+ */
103
+ async verifyAndNormalizeGoogleToken(idToken: string): Promise<OAuthUserInfo> {
104
+ const lc = `${this.lc}[${this.verifyAndNormalizeGoogleToken.name}]`;
105
+ const providerConfig = this.config.providers.google;
106
+ if (!providerConfig) {
107
+ throw new Error(`Google provider config is missing. (E: g28900abfefd23e89cf78f56)`);
108
+ }
109
+
110
+ // Verify signature and decode JWT payload
111
+ const payload = await this.verifyJwt(idToken, providerConfig);
112
+
113
+ // Assert claims
114
+ const nowSeconds = Math.floor(Date.now() / 1000);
115
+ if (payload.exp && payload.exp < nowSeconds) {
116
+ throw new Error(`Token has expired. (E: t28900abfefd23e89cf89f67)`);
117
+ }
118
+ if (payload.aud !== providerConfig.clientId) {
119
+ throw new Error(`Token audience mismatch. Expected ${providerConfig.clientId}, got ${payload.aud}. (E: m28900abfefd23e89cf90f78)`);
120
+ }
121
+
122
+ if (!payload.sub) {
123
+ throw new Error(`Google JWT is missing subject claim ('sub'). (E: s28900abfefd23e89cfa1f89)`);
124
+ }
125
+
126
+ return {
127
+ providerKey: `google:${payload.sub}`,
128
+ providerId: 'google',
129
+ sub: payload.sub,
130
+ email: payload.email,
131
+ name: payload.name,
132
+ picture: payload.picture
133
+ };
134
+ }
135
+
136
+ /**
137
+ * Internal: Exchange authorization code for access token / ID token.
138
+ *
139
+ * @param providerConfig Server configuration settings for the specific provider.
140
+ * - Visibility: Private (Contains provider client secrets. Must stay server-side).
141
+ * - Generator: Server (Configured from node environment variables).
142
+ *
143
+ * @param code The temporary authorization code to exchange.
144
+ * - Visibility: Private (Short-lived credential. Must be kept private).
145
+ * - Generator: Provider (Via OAuth flow).
146
+ *
147
+ * @param redirectUri The redirect URI registered for the exchange.
148
+ * - Visibility: Public (Configuration URL).
149
+ * - Generator: Client/Server (From app configuration).
150
+ */
151
+ private async exchangeCodeForTokens(
152
+ providerConfig: SsoProviderServerConfig,
153
+ code: string,
154
+ redirectUri: string
155
+ ): Promise<any> {
156
+ const body = new URLSearchParams({
157
+ client_id: providerConfig.clientId,
158
+ client_secret: providerConfig.clientSecret,
159
+ code,
160
+ redirect_uri: redirectUri,
161
+ grant_type: 'authorization_code'
162
+ });
163
+
164
+ const headers: Record<string, string> = {
165
+ 'Content-Type': 'application/x-www-form-urlencoded'
166
+ };
167
+
168
+ // GitHub requires an Accept header to return JSON instead of URL-encoded string
169
+ if (providerConfig.providerId === 'github') {
170
+ headers['Accept'] = 'application/json';
171
+ }
172
+
173
+ const res = await fetch(providerConfig.tokenUrl, {
174
+ method: 'POST',
175
+ headers,
176
+ body: body.toString()
177
+ });
178
+
179
+ if (!res.ok) {
180
+ const errText = await res.text();
181
+ throw new Error(`Token exchange failed (HTTP ${res.status}): ${errText}`);
182
+ }
183
+
184
+ return res.json();
185
+ }
186
+
187
+ /**
188
+ * Internal: Fetch GitHub user profile and normalize.
189
+ *
190
+ * @param providerConfig Server configuration settings for GitHub.
191
+ * - Visibility: Private (Contains GitHub client secret).
192
+ * - Generator: Server (From environment config).
193
+ *
194
+ * @param accessToken The active OAuth2 access token to authorize the API call.
195
+ * - Visibility: Private (Bearer token. Must be kept private).
196
+ * - Generator: Provider (GitHub token endpoint).
197
+ */
198
+ private async fetchAndNormalizeGithubUser(
199
+ providerConfig: SsoProviderServerConfig,
200
+ accessToken: string
201
+ ): Promise<OAuthUserInfo> {
202
+ const res = await fetch(providerConfig.userInfoUrl, {
203
+ headers: {
204
+ 'Authorization': `Bearer ${accessToken}`,
205
+ 'User-Agent': 'space-gib-auth-agent' // GitHub API requires User-Agent
206
+ }
207
+ });
208
+
209
+ if (!res.ok) {
210
+ const errText = await res.text();
211
+ throw new Error(`Failed to fetch GitHub user info (HTTP ${res.status}): ${errText}`);
212
+ }
213
+
214
+ const rawUser = await res.json();
215
+
216
+ if (!rawUser.id) {
217
+ throw new Error(`GitHub user info response missing 'id' field.`);
218
+ }
219
+
220
+ const sub = String(rawUser.id);
221
+
222
+ return {
223
+ providerKey: `github:${sub}`,
224
+ providerId: 'github',
225
+ sub,
226
+ email: rawUser.email || undefined,
227
+ name: rawUser.name || rawUser.login || undefined,
228
+ picture: rawUser.avatar_url || undefined
229
+ };
230
+ }
231
+
232
+ /**
233
+ * Internal: Decodes and cryptographically verifies a JWT signature using JWKS public keys.
234
+ *
235
+ * @param token The raw base64-encoded JWT token to verify.
236
+ * - Visibility: Private (Sensitive token. Must be kept private).
237
+ * - Generator: Provider (Google / OIDC server).
238
+ *
239
+ * @param providerConfig The target provider server settings containing `jwksUrl`.
240
+ * - Visibility: Private (Contains secrets).
241
+ * - Generator: Server (From environment config).
242
+ */
243
+ public async verifyJwt(token: string, providerConfig: SsoProviderServerConfig): Promise<any> {
244
+ const parts = token.split('.');
245
+ if (parts.length !== 3) {
246
+ throw new Error('JWT must have 3 parts separated by dots.');
247
+ }
248
+
249
+ const [headerB64, payloadB64, signatureB64] = parts;
250
+
251
+ // 1. Decode header and select algorithm/key ID
252
+ let header: any;
253
+ try {
254
+ header = JSON.parse(Buffer.from(headerB64, 'base64url').toString('utf-8'));
255
+ } catch {
256
+ throw new Error('Failed to parse JWT header.');
257
+ }
258
+
259
+ if (header.alg !== 'RS256') {
260
+ throw new Error(`Only RS256 algorithm is supported, got: ${header.alg}`);
261
+ }
262
+
263
+ const kid = header.kid;
264
+ if (!kid) {
265
+ throw new Error('JWT header missing key ID (kid).');
266
+ }
267
+
268
+ // 2. Fetch public keys (JWKS)
269
+ const keys = await this.getJwksKeys(providerConfig);
270
+ const jwk = keys.find(key => key.kid === kid);
271
+ if (!jwk) {
272
+ throw new Error(`JWK public key matching kid '${kid}' not found.`);
273
+ }
274
+
275
+ // 3. Create Public Key object using Node native crypto
276
+ const publicKey = createPublicKey({
277
+ key: jwk as any,
278
+ format: 'jwk'
279
+ });
280
+
281
+ // 4. Verify signature against header + payload data
282
+ const signature = Buffer.from(signatureB64, 'base64url');
283
+ const data = Buffer.from(`${headerB64}.${payloadB64}`);
284
+
285
+ const isValid = verify('sha256', data, publicKey, signature);
286
+ if (!isValid) {
287
+ throw new Error('Cryptographic signature verification failed.');
288
+ }
289
+
290
+ // 5. Decode and return payload
291
+ try {
292
+ return JSON.parse(Buffer.from(payloadB64, 'base64url').toString('utf-8'));
293
+ } catch {
294
+ throw new Error('Failed to parse JWT payload.');
295
+ }
296
+ }
297
+
298
+ /**
299
+ * Internal: Retrieves JWKS keys from cache or fetches them from the provider's jwksUrl.
300
+ *
301
+ * @param providerConfig The provider config containing the target `jwksUrl`.
302
+ * - Visibility: Private (Contains client secrets).
303
+ * - Generator: Server (From environment config).
304
+ */
305
+ private async getJwksKeys(providerConfig: SsoProviderServerConfig): Promise<JwkKey[]> {
306
+ const providerId = providerConfig.providerId;
307
+ const cached = this.jwksCache.get(providerId);
308
+
309
+ if (cached && cached.expiresAt > Date.now()) {
310
+ return cached.keys;
311
+ }
312
+
313
+ if (!providerConfig.jwksUrl) {
314
+ throw new Error(`Provider '${providerId}' does not define a jwksUrl for JWT signature validation.`);
315
+ }
316
+
317
+ const res = await fetch(providerConfig.jwksUrl);
318
+ if (!res.ok) {
319
+ throw new Error(`Failed to fetch JWKS from ${providerConfig.jwksUrl} (HTTP ${res.status})`);
320
+ }
321
+
322
+ const body = (await res.json()) as JwksResponse;
323
+ if (!body.keys || !Array.isArray(body.keys)) {
324
+ throw new Error(`Invalid JWKS response structure from ${providerConfig.jwksUrl}`);
325
+ }
326
+
327
+ this.jwksCache.set(providerId, {
328
+ keys: body.keys,
329
+ expiresAt: Date.now() + this.jwksTtlMs
330
+ });
331
+
332
+ return body.keys;
333
+ }
334
+
335
+ /**
336
+ * Derives a server-delegate master secret deterministically using the server's private KDF secret,
337
+ * the providerKey, and the user's public nonce.
338
+ *
339
+ * @param providerKey The unique key identifying the provider and user sub ID (e.g. `google:123...`).
340
+ * - Visibility: Public (Identifier saved on parent's delegate claim, safe for log/ledger storage).
341
+ * - Generator: Provider/Server (Derived by mapping provider user sub ID after authentication).
342
+ *
343
+ * @param userNonce The public, high-entropy nonce generated by the client to salt the KDF derivation.
344
+ * - Visibility: Public (Salt value stored on parent's delegate claim, safe for ledger storage).
345
+ * - Generator: Client (Generated offline on user's device).
346
+ */
347
+ async deriveServerDelegateSecret(providerKey: string, userNonce: string): Promise<string> {
348
+ const salt = await hash({
349
+ s: `${providerKey}:${userNonce}`,
350
+ algorithm: HashAlgorithm.sha_512
351
+ });
352
+ return await kdf_recursiveSaltWrap({
353
+ masterSecret: this.config.ssoServerKdfSecret,
354
+ salt,
355
+ rounds: 1000,
356
+ algorithm: HashAlgorithm.sha_512
357
+ });
358
+ }
359
+
360
+ /**
361
+ * Creates a public custodian challenge pool (custodian-manage) derived deterministically
362
+ * from the server's private KDF secret, providerKey, and userNonce.
363
+ *
364
+ * @param providerKey The unique key identifying the provider and user sub ID (e.g. `google:123...`).
365
+ * - Visibility: Public (Mapped identifier, safe for ledger).
366
+ * - Generator: Provider/Server (Derived after successful token exchange).
367
+ *
368
+ * @param userNonce The public, high-entropy nonce.
369
+ * - Visibility: Public (Derivation salt, safe for ledger).
370
+ * - Generator: Client (Generated on user's device).
371
+ */
372
+ async createCustodianChallengePool(
373
+ providerKey: string,
374
+ userNonce: string,
375
+ ): Promise<KeystoneChallengePool> {
376
+ const custodianSecret = await this.deriveServerDelegateSecret(providerKey, userNonce);
377
+
378
+ const poolSalt = await hash({
379
+ s: `${providerKey}:${userNonce}:salt`,
380
+ algorithm: HashAlgorithm.sha_256
381
+ });
382
+ const poolSaltShort = poolSalt.substring(0, 16);
383
+
384
+ const config = createStandardPoolConfig({
385
+ id: POOL_ID_CUSTODIAN_MANAGE,
386
+ salt: poolSaltShort
387
+ });
388
+ config.allowedVerbs = [KEYSTONE_VERB_MANAGE, KEYSTONE_VERB_REVOKE];
389
+
390
+ const strategy = KeystoneStrategyFactory.create({ config });
391
+ const poolSecret = await strategy.derivePoolSecret({ masterSecret: custodianSecret });
392
+ const challenges: { [id: string]: any } = {};
393
+ const timestamp = Date.now().toString();
394
+
395
+ for (let i = 0; i < config.behavior.size; i++) {
396
+ const challengeId = await generateOpaqueChallengeId({
397
+ salt: config.salt,
398
+ timestamp,
399
+ index: i
400
+ });
401
+ const solution = await strategy.generateSolution({
402
+ poolSecret,
403
+ poolId: config.id,
404
+ challengeId
405
+ });
406
+ challenges[challengeId] = await strategy.generateChallenge({ solution });
407
+ }
408
+
409
+ return {
410
+ id: POOL_ID_CUSTODIAN_MANAGE,
411
+ config,
412
+ challenges,
413
+ isForeign: true,
414
+ metadata: {
415
+ owner: 'custodian',
416
+ providerKey
417
+ }
418
+ };
419
+ }
420
+
421
+ /**
422
+ * Maps private server configuration to safe public client configurations.
423
+ *
424
+ * @param hostUrl The root URL host address of the server (e.g. `https://space-gib.localhost` or `https://ibgib.space`).
425
+ * - Visibility: Public (Non-sensitive hostname configuration).
426
+ * - Generator: Server (Extracted from the HTTP request context headers/protocol).
427
+ */
428
+ getSsoClientConfigs(hostUrl: string): SsoProviderClientConfig[] {
429
+ const configs: SsoProviderClientConfig[] = [];
430
+
431
+ if (this.config.providers.google) {
432
+ configs.push({
433
+ providerId: 'google',
434
+ clientId: this.config.providers.google.clientId,
435
+ authUrl: 'https://accounts.google.com/o/oauth2/v2/auth',
436
+ redirectUri: `${hostUrl}/`,
437
+ scope: 'openid email profile'
438
+ });
439
+ }
440
+
441
+ if (this.config.providers.github) {
442
+ configs.push({
443
+ providerId: 'github',
444
+ clientId: this.config.providers.github.clientId,
445
+ authUrl: 'https://github.com/login/oauth/authorize',
446
+ redirectUri: `${hostUrl}/`,
447
+ scope: 'read:user user:email'
448
+ });
449
+ }
450
+
451
+ return configs;
452
+ }
453
+ }