@promptbook/cli 0.112.0-101 → 0.112.0-102

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 (78) hide show
  1. package/apps/agents-server/README.md +6 -0
  2. package/apps/agents-server/package.json +1 -1
  3. package/apps/agents-server/scripts/prerender-homepage.js +76 -1
  4. package/apps/agents-server/src/app/actions.ts +0 -6
  5. package/apps/agents-server/src/app/admin/about/page.tsx +1 -1
  6. package/apps/agents-server/src/app/admin/login-methods/shibboleth/page.tsx +365 -0
  7. package/apps/agents-server/src/app/admin/servers/ServersRegistryTable.tsx +3 -3
  8. package/apps/agents-server/src/app/admin/update/UpdateClient.tsx +12 -3
  9. package/apps/agents-server/src/app/admin/usage/UsageClientTimelineChart.tsx +1 -1
  10. package/apps/agents-server/src/app/admin/users/[userId]/UserDetailClient.tsx +21 -14
  11. package/apps/agents-server/src/app/agents/[agentName]/chat/AgentChatPageLayout.tsx +2 -2
  12. package/apps/agents-server/src/app/agents/[agentName]/chat/AgentChatSidebarDefault.tsx +11 -7
  13. package/apps/agents-server/src/app/api/admin/cli-access/route.ts +27 -123
  14. package/apps/agents-server/src/app/api/admin/code-runners/authentication/route.ts +33 -125
  15. package/apps/agents-server/src/app/api/auth/login/route.ts +0 -10
  16. package/apps/agents-server/src/app/api/auth/shibboleth/acs/route.ts +77 -57
  17. package/apps/agents-server/src/app/api/auth/shibboleth/login/route.ts +57 -33
  18. package/apps/agents-server/src/app/api/auth/shibboleth/metadata/route.ts +4 -29
  19. package/apps/agents-server/src/app/api/auth/shibboleth/status/route.ts +17 -0
  20. package/apps/agents-server/src/app/api/upload/route.ts +230 -18
  21. package/apps/agents-server/src/app/api/users/[username]/route.ts +1 -1
  22. package/apps/agents-server/src/app/api/users/route.ts +5 -5
  23. package/apps/agents-server/src/app/dashboard/page.tsx +1 -1
  24. package/apps/agents-server/src/app/docs/[docId]/page.tsx +1 -1
  25. package/apps/agents-server/src/app/docs/page.tsx +1 -1
  26. package/apps/agents-server/src/app/globals.css +100 -0
  27. package/apps/agents-server/src/app/layout.tsx +7 -0
  28. package/apps/agents-server/src/app/recycle-bin/page.tsx +1 -1
  29. package/apps/agents-server/src/app/system/settings/KeybindingsSettingsClient.tsx +13 -7
  30. package/apps/agents-server/src/components/AdminTerminal/useAdminTerminalSession.ts +29 -1
  31. package/apps/agents-server/src/components/AgentProfile/AgentProfile.tsx +3 -3
  32. package/apps/agents-server/src/components/AgentProfile/AgentProfileImage.tsx +8 -2
  33. package/apps/agents-server/src/components/DocsToolbar/DocsToolbar.tsx +4 -4
  34. package/apps/agents-server/src/components/DocumentationContent/DocumentationContent.tsx +9 -9
  35. package/apps/agents-server/src/components/Footer/Footer.tsx +7 -7
  36. package/apps/agents-server/src/components/Header/Header.tsx +24 -4
  37. package/apps/agents-server/src/components/Header/HeaderTypes.ts +6 -0
  38. package/apps/agents-server/src/components/Header/buildHeaderSystemMenuItems.ts +51 -1
  39. package/apps/agents-server/src/components/Homepage/Card.tsx +1 -1
  40. package/apps/agents-server/src/components/Homepage/Section.tsx +3 -1
  41. package/apps/agents-server/src/components/LayoutWrapper/LayoutWrapper.tsx +12 -1
  42. package/apps/agents-server/src/components/LoginForm/LoginForm.tsx +100 -149
  43. package/apps/agents-server/src/components/Skeleton/ConsolePageLoadingSkeleton.tsx +1 -1
  44. package/apps/agents-server/src/components/Skeleton/DocumentationRouteLoadingSkeleton.tsx +1 -1
  45. package/apps/agents-server/src/components/Skeleton/HomepageLoadingSkeleton.tsx +1 -1
  46. package/apps/agents-server/src/components/UsersList/UsersList.tsx +20 -4
  47. package/apps/agents-server/src/components/UsersList/useUsersAdmin.ts +3 -0
  48. package/apps/agents-server/src/constants/shibbolethAuth.ts +139 -0
  49. package/apps/agents-server/src/database/metadataDefaults.ts +54 -80
  50. package/apps/agents-server/src/database/migrate.ts +30 -1
  51. package/apps/agents-server/src/database/migrations/2026-06-0100-shibboleth-auth.sql +136 -0
  52. package/apps/agents-server/src/database/sqlite/$provideLocalSqliteSupabase.ts +88 -36
  53. package/apps/agents-server/src/languages/ServerTranslationKeys.ts +4 -2
  54. package/apps/agents-server/src/languages/translations/czech.yaml +4 -2
  55. package/apps/agents-server/src/languages/translations/english.yaml +5 -3
  56. package/apps/agents-server/src/tools/$provideCdnForServer.ts +69 -23
  57. package/apps/agents-server/src/utils/cdn/classes/DigitalOceanSpaces.ts +54 -6
  58. package/apps/agents-server/src/utils/cdn/classes/TrackedFilesStorage.ts +4 -6
  59. package/apps/agents-server/src/utils/cdn/resolveCdnStorageProvider.ts +40 -0
  60. package/apps/agents-server/src/utils/chatExport/renderHtmlToPdfOnServer.ts +11 -0
  61. package/apps/agents-server/src/utils/createAdminTerminalRouteHandlers.ts +264 -0
  62. package/apps/agents-server/src/utils/shareTargetPayloads.ts +11 -10
  63. package/apps/agents-server/src/utils/shibbolethAuthentication.ts +729 -621
  64. package/apps/agents-server/src/utils/upload/createBookEditorUploadHandler.ts +137 -19
  65. package/esm/index.es.js +1 -1
  66. package/esm/src/book-components/Chat/MarkdownContent/MarkdownContent.d.ts +1 -0
  67. package/esm/src/version.d.ts +1 -1
  68. package/package.json +2 -2
  69. package/src/book-components/Chat/MarkdownContent/MarkdownContent.tsx +65 -4
  70. package/src/other/templates/getTemplatesPipelineCollection.ts +788 -719
  71. package/src/version.ts +2 -2
  72. package/src/versions.txt +1 -0
  73. package/umd/index.umd.js +1 -1
  74. package/umd/src/book-components/Chat/MarkdownContent/MarkdownContent.d.ts +1 -0
  75. package/umd/src/version.d.ts +1 -1
  76. package/apps/agents-server/src/app/api/auth/methods/route.ts +0 -44
  77. package/apps/agents-server/src/constants/authenticationMethods.ts +0 -74
  78. package/apps/agents-server/src/constants/shibbolethAuthentication.ts +0 -107
@@ -1,841 +1,949 @@
1
- import { randomBytes } from 'crypto';
2
- import {
3
- SAML,
4
- ValidateInResponseTo,
5
- generateServiceProviderMetadata,
6
- type CacheItem,
7
- type CacheProvider,
8
- type Profile,
9
- } from '@node-saml/node-saml';
10
- import { XMLParser } from 'fast-xml-parser';
11
- import { spaceTrim } from 'spacetrim';
12
- import { AUTHENTICATION_METHODS_METADATA_KEY, isAuthenticationMethodEnabled } from '../constants/authenticationMethods';
13
- import {
14
- DEFAULT_SHIBBOLETH_PROVIDER_LABEL,
15
- DEFAULT_SHIBBOLETH_USERNAME_ATTRIBUTE,
16
- SHIBBOLETH_AUTHENTICATION_METADATA_KEYS,
17
- SHIBBOLETH_AUTO_CREATE_USERS_METADATA_KEY,
18
- SHIBBOLETH_CALLBACK_PATH,
19
- SHIBBOLETH_CALLBACK_URL_METADATA_KEY,
20
- SHIBBOLETH_ENTITY_ID_METADATA_KEY,
21
- SHIBBOLETH_IDP_CERTIFICATE_METADATA_KEY,
22
- SHIBBOLETH_IDP_ENTRYPOINT_METADATA_KEY,
23
- SHIBBOLETH_IDP_ISSUER_METADATA_KEY,
24
- SHIBBOLETH_IDP_METADATA_URL_METADATA_KEY,
25
- SHIBBOLETH_PROVIDER_LABEL_METADATA_KEY,
26
- SHIBBOLETH_SP_METADATA_PATH,
27
- SHIBBOLETH_USERNAME_ATTRIBUTE_METADATA_KEY,
28
- } from '../constants/shibbolethAuthentication';
1
+ import { SAML, ValidateInResponseTo, generateServiceProviderMetadata, type Profile } from '@node-saml/node-saml';
2
+ import { DOMParser } from '@xmldom/xmldom';
29
3
  import { $getTableName } from '../database/$getTableName';
30
4
  import { $provideSupabaseForServer } from '../database/$provideSupabaseForServer';
5
+ import type { AgentsServerDatabase, Json } from '../database/schema';
31
6
  import { getMetadataMap } from '../database/getMetadata';
32
- import type { AgentsServerDatabase } from '../database/schema';
33
- import { hashPassword } from './auth';
7
+ import { $provideServer } from '../tools/$provideServer';
8
+ import {
9
+ DEFAULT_SHIBBOLETH_DISPLAY_NAME_ATTRIBUTE_NAMES,
10
+ DEFAULT_SHIBBOLETH_EMAIL_ATTRIBUTE_NAMES,
11
+ DEFAULT_SHIBBOLETH_UNSTRUCTURED_NAME_ATTRIBUTE_NAMES,
12
+ IS_SHIBBOLETH_AUTH_ACTIVE_METADATA_KEY,
13
+ SHIBBOLETH_AUTHENTICATION_METADATA_KEYS,
14
+ SHIBBOLETH_DISPLAY_NAME_ATTRIBUTE_NAMES_METADATA_KEY,
15
+ SHIBBOLETH_EMAIL_ATTRIBUTE_NAMES_METADATA_KEY,
16
+ SHIBBOLETH_IDENTITY_PROVIDER_METADATA_URL_METADATA_KEY,
17
+ SHIBBOLETH_IDENTITY_PROVIDER_METADATA_XML_METADATA_KEY,
18
+ SHIBBOLETH_SERVICE_PROVIDER_ENTITY_ID_METADATA_KEY,
19
+ SHIBBOLETH_UNSTRUCTURED_NAME_ATTRIBUTE_NAMES_METADATA_KEY,
20
+ parseShibbolethBooleanMetadata,
21
+ } from '../constants/shibbolethAuth';
34
22
 
35
23
  /**
36
- * Maximum time SAML request identifiers are retained for InResponseTo validation.
24
+ * Persistent NameID format recommended for Shibboleth integrations.
37
25
  */
38
- const SHIBBOLETH_REQUEST_ID_EXPIRATION_PERIOD_MS = 8 * 60 * 60 * 1000;
26
+ const SHIBBOLETH_PERSISTENT_NAME_ID_FORMAT = 'urn:oasis:names:tc:SAML:2.0:nameid-format:persistent';
39
27
 
40
28
  /**
41
- * Clock skew accepted while validating Shibboleth assertion validity timestamps.
29
+ * SAML HTTP Redirect binding URL.
42
30
  */
43
- const SHIBBOLETH_ACCEPTED_CLOCK_SKEW_MS = 60 * 1000;
31
+ const SAML_HTTP_REDIRECT_BINDING = 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect';
44
32
 
45
33
  /**
46
- * SAML NameID format requested from Shibboleth.
34
+ * Timeout for loading remote Identity Provider metadata.
47
35
  */
48
- const SHIBBOLETH_IDENTIFIER_FORMAT = 'urn:oasis:names:tc:SAML:2.0:nameid-format:persistent';
36
+ const SHIBBOLETH_IDENTITY_PROVIDER_METADATA_TIMEOUT_MS = 10_000;
49
37
 
50
38
  /**
51
- * Metadata fetch timeout used when loading Shibboleth IdP metadata.
39
+ * Clock skew accepted when validating SAML assertion timestamps.
52
40
  */
53
- const SHIBBOLETH_METADATA_FETCH_TIMEOUT_MS = 10 * 1000;
41
+ const SHIBBOLETH_ACCEPTED_CLOCK_SKEW_MS = 120_000;
54
42
 
55
43
  /**
56
- * XML parser used for Shibboleth IdP metadata.
44
+ * Placeholder password hash for database users that can only sign in via Shibboleth.
57
45
  */
58
- const SHIBBOLETH_METADATA_XML_PARSER = new XMLParser({
59
- attributeNamePrefix: '@_',
60
- ignoreAttributes: false,
61
- removeNSPrefix: true,
62
- trimValues: true,
63
- });
46
+ const SHIBBOLETH_PASSWORDLESS_USER_PASSWORD_HASH = 'shibboleth-passwordless-user';
64
47
 
65
48
  /**
66
- * Process-local cache used by node-saml for InResponseTo validation.
49
+ * Marker stored on database users that authenticate with username/password only.
67
50
  */
68
- const SHIBBOLETH_REQUEST_ID_CACHE = new Map<string, CacheItem>();
51
+ const LOCAL_AUTHENTICATION_PROVIDER = 'LOCAL';
69
52
 
70
53
  /**
71
- * Cache provider shared across SAML instances so the login route and ACS route
72
- * can validate the same AuthnRequest IDs.
54
+ * Marker stored on database users that authenticate with Shibboleth only.
73
55
  */
74
- const SHIBBOLETH_REQUEST_ID_CACHE_PROVIDER: CacheProvider = {
75
- async saveAsync(key, value) {
76
- pruneExpiredShibbolethRequestIds();
77
- const cacheItem = { value, createdAt: Date.now() };
78
- SHIBBOLETH_REQUEST_ID_CACHE.set(key, cacheItem);
79
- return cacheItem;
80
- },
81
- async getAsync(key) {
82
- pruneExpiredShibbolethRequestIds();
83
- return SHIBBOLETH_REQUEST_ID_CACHE.get(key)?.value ?? null;
84
- },
85
- async removeAsync(key) {
86
- if (!key) {
87
- return null;
88
- }
56
+ const SHIBBOLETH_AUTHENTICATION_PROVIDER = 'SHIBBOLETH';
89
57
 
90
- const value = SHIBBOLETH_REQUEST_ID_CACHE.get(key)?.value ?? null;
91
- SHIBBOLETH_REQUEST_ID_CACHE.delete(key);
92
- return value;
93
- },
58
+ /**
59
+ * Marker stored on database users that authenticate with username/password and Shibboleth.
60
+ */
61
+ const LOCAL_AND_SHIBBOLETH_AUTHENTICATION_PROVIDER = 'LOCAL_AND_SHIBBOLETH';
62
+
63
+ /**
64
+ * User table row with Shibboleth profile columns added by migration.
65
+ */
66
+ type UserRowWithShibbolethColumns = AgentsServerDatabase['public']['Tables']['User']['Row'] & {
67
+ readonly email?: string | null;
68
+ readonly displayName?: string | null;
69
+ readonly authenticationProvider?: string | null;
94
70
  };
95
71
 
96
72
  /**
97
- * Parsed Shibboleth IdP metadata fields used by SAML configuration.
73
+ * User insert shape with Shibboleth profile columns added by migration.
74
+ */
75
+ type UserInsertWithShibbolethColumns = AgentsServerDatabase['public']['Tables']['User']['Insert'] & {
76
+ readonly email?: string | null;
77
+ readonly displayName?: string | null;
78
+ readonly authenticationProvider?: string | null;
79
+ };
80
+
81
+ /**
82
+ * User update shape with Shibboleth profile columns added by migration.
83
+ */
84
+ type UserUpdateWithShibbolethColumns = AgentsServerDatabase['public']['Tables']['User']['Update'] & {
85
+ readonly email?: string | null;
86
+ readonly displayName?: string | null;
87
+ readonly authenticationProvider?: string | null;
88
+ };
89
+
90
+ /**
91
+ * Prefixed Shibboleth identity row.
92
+ *
93
+ * @private internal Shibboleth authentication record
94
+ */
95
+ export type ShibbolethUserIdentityRow = {
96
+ readonly id: number;
97
+ readonly createdAt: string;
98
+ readonly updatedAt: string;
99
+ readonly userId: number;
100
+ readonly email: string;
101
+ readonly displayName: string | null;
102
+ readonly nameId: string | null;
103
+ readonly nameIdFormat: string | null;
104
+ readonly unstructuredName: string | null;
105
+ readonly eduPersonPrincipalName: string | null;
106
+ readonly rawAttributes: Json | null;
107
+ readonly lastLoggedInAt: string | null;
108
+ readonly loginCount: number;
109
+ };
110
+
111
+ /**
112
+ * Prefixed Shibboleth authentication attempt row.
113
+ *
114
+ * @private internal Shibboleth authentication record
115
+ */
116
+ export type ShibbolethAuthenticationAttemptRow = {
117
+ readonly id: number;
118
+ readonly createdAt: string;
119
+ readonly stage: string;
120
+ readonly status: string;
121
+ readonly userId: number | null;
122
+ readonly email: string | null;
123
+ readonly displayName: string | null;
124
+ readonly nameId: string | null;
125
+ readonly relayState: string | null;
126
+ readonly ip: string | null;
127
+ readonly userAgent: string | null;
128
+ readonly errorMessage: string | null;
129
+ readonly rawAttributes: Json | null;
130
+ };
131
+
132
+ /**
133
+ * Request metadata recorded with Shibboleth authentication attempts.
98
134
  *
99
- * @public exported from `apps/agents-server`
135
+ * @private internal Shibboleth authentication record
100
136
  */
101
- export type ShibbolethIdentityProviderMetadata = {
102
- /**
103
- * Identity Provider entity ID.
104
- */
105
- readonly entityId: string | null;
106
- /**
107
- * Preferred HTTP-Redirect SSO endpoint.
108
- */
109
- readonly entryPoint: string | null;
110
- /**
111
- * Signing certificates exposed by the Identity Provider metadata.
112
- */
113
- readonly certificates: ReadonlyArray<string>;
137
+ export type ShibbolethRequestDetails = {
138
+ readonly ip: string | null;
139
+ readonly userAgent: string | null;
114
140
  };
115
141
 
116
142
  /**
117
- * Enabled and fully configured Shibboleth authentication settings.
143
+ * Parsed Shibboleth user attributes extracted from SAML profile data.
118
144
  *
119
- * @public exported from `apps/agents-server`
120
- */
121
- export type EnabledShibbolethConfiguration = {
122
- /**
123
- * Whether Shibboleth is enabled by metadata.
124
- */
125
- readonly isEnabled: true;
126
- /**
127
- * Whether the enabled method has all required IdP settings.
128
- */
129
- readonly isConfigured: true;
130
- /**
131
- * User-facing provider label.
132
- */
133
- readonly providerLabel: string;
134
- /**
135
- * SAML Service Provider entity ID.
136
- */
137
- readonly issuer: string;
138
- /**
139
- * SAML Assertion Consumer Service URL.
140
- */
141
- readonly callbackUrl: string;
142
- /**
143
- * Shibboleth IdP SSO URL.
144
- */
145
- readonly entryPoint: string;
146
- /**
147
- * Shibboleth IdP signing certificates.
148
- */
149
- readonly idpCertificates: ReadonlyArray<string>;
150
- /**
151
- * Expected Shibboleth IdP entity ID, when known.
152
- */
153
- readonly idpIssuer: string | null;
154
- /**
155
- * SAML profile attribute used as Agents Server username.
156
- */
157
- readonly usernameAttribute: string;
158
- /**
159
- * Whether unknown Shibboleth users are created automatically.
160
- */
161
- readonly isAutoCreateUsers: boolean;
145
+ * @private internal Shibboleth authentication record
146
+ */
147
+ export type ShibbolethProfileAttributes = {
148
+ readonly email: string;
149
+ readonly displayName: string | null;
150
+ readonly nameId: string | null;
151
+ readonly nameIdFormat: string | null;
152
+ readonly unstructuredName: string | null;
153
+ readonly eduPersonPrincipalName: string | null;
154
+ readonly rawAttributes: Json;
162
155
  };
163
156
 
164
157
  /**
165
- * Disabled or incomplete Shibboleth authentication settings.
158
+ * Service Provider URLs derived for the current request.
166
159
  *
167
- * @public exported from `apps/agents-server`
168
- */
169
- export type InactiveShibbolethConfiguration = {
170
- /**
171
- * Whether Shibboleth is enabled by metadata.
172
- */
173
- readonly isEnabled: boolean;
174
- /**
175
- * Whether the enabled method has all required IdP settings.
176
- */
177
- readonly isConfigured: false;
178
- /**
179
- * User-facing provider label.
180
- */
181
- readonly providerLabel: string;
182
- /**
183
- * SAML Service Provider entity ID, if resolvable.
184
- */
185
- readonly issuer: string | null;
186
- /**
187
- * SAML Assertion Consumer Service URL, if resolvable.
188
- */
189
- readonly callbackUrl: string | null;
190
- /**
191
- * Missing metadata keys or derived settings.
192
- */
193
- readonly missingConfiguration: ReadonlyArray<string>;
160
+ * @private internal Shibboleth authentication configuration
161
+ */
162
+ export type ShibbolethServiceProviderUrls = {
163
+ readonly origin: string;
164
+ readonly entityId: string;
165
+ readonly assertionConsumerServiceUrl: string;
166
+ readonly metadataUrl: string;
194
167
  };
195
168
 
196
169
  /**
197
- * Resolved Shibboleth authentication settings.
170
+ * Parsed Identity Provider metadata needed by Node-SAML.
198
171
  *
199
- * @public exported from `apps/agents-server`
172
+ * @private internal Shibboleth authentication configuration
200
173
  */
201
- export type ShibbolethConfiguration = EnabledShibbolethConfiguration | InactiveShibbolethConfiguration;
174
+ export type ShibbolethIdentityProviderMetadata = {
175
+ readonly singleSignOnServiceUrl: string;
176
+ readonly signingCertificates: ReadonlyArray<string>;
177
+ };
202
178
 
203
179
  /**
204
- * Result of resolving a Shibboleth profile into a local Agents Server user.
180
+ * Resolved Shibboleth authentication configuration status.
205
181
  *
206
- * @public exported from `apps/agents-server`
207
- */
208
- export type ShibbolethUserResolution = {
209
- /**
210
- * Local Agents Server username.
211
- */
212
- readonly username: string;
213
- /**
214
- * Whether the local user has admin privileges.
215
- */
216
- readonly isAdmin: boolean;
217
- /**
218
- * Whether a new local user row was created for this Shibboleth login.
219
- */
220
- readonly isNewUser: boolean;
182
+ * @private internal Shibboleth authentication configuration
183
+ */
184
+ export type ShibbolethAuthenticationConfiguration = {
185
+ readonly isActive: boolean;
186
+ readonly isConfigured: boolean;
187
+ readonly errors: ReadonlyArray<string>;
188
+ readonly identityProviderMetadataUrl: string | null;
189
+ readonly isIdentityProviderMetadataXmlConfigured: boolean;
190
+ readonly serviceProviderUrls: ShibbolethServiceProviderUrls;
191
+ readonly identityProviderMetadata: ShibbolethIdentityProviderMetadata | null;
192
+ };
193
+
194
+ /**
195
+ * Options for resolving Shibboleth authentication configuration.
196
+ */
197
+ type ResolveShibbolethAuthenticationConfigurationOptions = {
198
+ readonly requestUrl: string;
199
+ readonly isIdentityProviderMetadataValidationEnabled?: boolean;
200
+ };
201
+
202
+ /**
203
+ * Options for recording a Shibboleth authentication attempt.
204
+ */
205
+ type RecordShibbolethAuthenticationAttemptOptions = {
206
+ readonly stage: string;
207
+ readonly status: string;
208
+ readonly requestDetails?: ShibbolethRequestDetails;
209
+ readonly userId?: number | null;
210
+ readonly email?: string | null;
211
+ readonly displayName?: string | null;
212
+ readonly nameId?: string | null;
213
+ readonly relayState?: string | null;
214
+ readonly errorMessage?: string | null;
215
+ readonly rawAttributes?: Json | null;
221
216
  };
222
217
 
223
218
  /**
224
- * Error thrown when Shibboleth metadata is enabled but incomplete.
219
+ * Result of linking a SAML profile to an Agents Server user.
220
+ */
221
+ type LinkedShibbolethUser = {
222
+ readonly user: UserRowWithShibbolethColumns;
223
+ readonly profileAttributes: ShibbolethProfileAttributes;
224
+ };
225
+
226
+ /**
227
+ * Minimal XML node shape used to avoid mixing browser DOM and xmldom typings.
228
+ */
229
+ type XmlNodeWithElements = {
230
+ readonly getElementsByTagName: (tagName: string) => ArrayLike<XmlElementWithAttributes>;
231
+ };
232
+
233
+ /**
234
+ * Minimal XML element shape used by the Shibboleth metadata parser.
235
+ */
236
+ type XmlElementWithAttributes = XmlNodeWithElements & {
237
+ readonly localName?: string | null;
238
+ readonly nodeName: string;
239
+ readonly textContent?: string | null;
240
+ readonly getAttribute: (attributeName: string) => string | null;
241
+ };
242
+
243
+ /**
244
+ * Returns a safe relative URL to redirect to after Shibboleth finishes.
225
245
  *
226
- * @public exported from `apps/agents-server`
227
- */
228
- export class ShibbolethConfigurationError extends Error {
229
- /**
230
- * Missing metadata keys or derived settings.
231
- */
232
- public readonly missingConfiguration: ReadonlyArray<string>;
233
-
234
- /**
235
- * Creates a Shibboleth configuration error.
236
- *
237
- * @param missingConfiguration - Missing metadata keys or derived settings.
238
- */
239
- public constructor(missingConfiguration: ReadonlyArray<string>) {
240
- super(
241
- spaceTrim(`
242
- Shibboleth authentication is enabled, but its configuration is incomplete.
243
-
244
- Missing configuration: \`${missingConfiguration.join('`, `')}\`
245
- `),
246
- );
247
- this.name = 'ShibbolethConfigurationError';
248
- this.missingConfiguration = missingConfiguration;
246
+ * @param relayState - Raw RelayState value from the request or SAML response.
247
+ * @returns Safe relative URL.
248
+ *
249
+ * @private internal Shibboleth authentication helper
250
+ */
251
+ export function sanitizeShibbolethRelayState(relayState: string | null | undefined): string {
252
+ const trimmedRelayState = (relayState || '').trim();
253
+
254
+ if (!trimmedRelayState || !trimmedRelayState.startsWith('/') || trimmedRelayState.startsWith('//')) {
255
+ return '/';
249
256
  }
257
+
258
+ return trimmedRelayState;
250
259
  }
251
260
 
252
261
  /**
253
- * Loads Shibboleth configuration from Agents Server metadata.
262
+ * Extracts audit metadata from an incoming request.
254
263
  *
255
- * @param request - Incoming request used to derive default public URLs.
256
- * @returns Resolved Shibboleth configuration.
264
+ * @param request - Incoming route-handler request.
265
+ * @returns Request details safe to store in the Shibboleth audit log.
257
266
  *
258
- * @public exported from `apps/agents-server`
259
- */
260
- export async function loadShibbolethConfiguration(request?: Request): Promise<ShibbolethConfiguration> {
261
- const metadata = await getMetadataMap([
262
- AUTHENTICATION_METHODS_METADATA_KEY,
263
- ...SHIBBOLETH_AUTHENTICATION_METADATA_KEYS,
264
- ]);
265
- const providerLabel = metadata[SHIBBOLETH_PROVIDER_LABEL_METADATA_KEY]?.trim() || DEFAULT_SHIBBOLETH_PROVIDER_LABEL;
266
- const publicBaseUrl = resolveShibbolethPublicBaseUrl(request);
267
- const issuer =
268
- metadata[SHIBBOLETH_ENTITY_ID_METADATA_KEY]?.trim() ||
269
- (publicBaseUrl ? `${publicBaseUrl}${SHIBBOLETH_SP_METADATA_PATH}` : null);
270
- const callbackUrl =
271
- metadata[SHIBBOLETH_CALLBACK_URL_METADATA_KEY]?.trim() ||
272
- (publicBaseUrl ? `${publicBaseUrl}${SHIBBOLETH_CALLBACK_PATH}` : null);
273
- const isEnabled = isAuthenticationMethodEnabled(metadata[AUTHENTICATION_METHODS_METADATA_KEY], 'SHIBBOLETH');
274
-
275
- if (!isEnabled) {
276
- return {
277
- isEnabled: false,
278
- isConfigured: false,
279
- providerLabel,
280
- issuer,
281
- callbackUrl,
282
- missingConfiguration: [],
283
- };
284
- }
267
+ * @private internal Shibboleth authentication helper
268
+ */
269
+ export function getShibbolethRequestDetails(request: Request): ShibbolethRequestDetails {
270
+ const forwardedFor = request.headers.get('x-forwarded-for');
271
+ const ip = forwardedFor?.split(',')[0]?.trim() || request.headers.get('x-real-ip') || null;
285
272
 
286
- const identityProviderMetadata = await loadShibbolethIdentityProviderMetadata(
287
- metadata[SHIBBOLETH_IDP_METADATA_URL_METADATA_KEY],
288
- );
289
- const entryPoint =
290
- metadata[SHIBBOLETH_IDP_ENTRYPOINT_METADATA_KEY]?.trim() || identityProviderMetadata.entryPoint || null;
291
- const idpCertificates = parseShibbolethCertificates(metadata[SHIBBOLETH_IDP_CERTIFICATE_METADATA_KEY]);
292
- const resolvedIdpCertificates =
293
- idpCertificates.length > 0 ? idpCertificates : identityProviderMetadata.certificates;
294
- const idpIssuer = metadata[SHIBBOLETH_IDP_ISSUER_METADATA_KEY]?.trim() || identityProviderMetadata.entityId || null;
295
- const usernameAttribute =
296
- metadata[SHIBBOLETH_USERNAME_ATTRIBUTE_METADATA_KEY]?.trim() || DEFAULT_SHIBBOLETH_USERNAME_ATTRIBUTE;
297
- const isAutoCreateUsers = (metadata[SHIBBOLETH_AUTO_CREATE_USERS_METADATA_KEY] ?? 'true') !== 'false';
298
- const missingConfiguration = [
299
- issuer ? null : SHIBBOLETH_ENTITY_ID_METADATA_KEY,
300
- callbackUrl ? null : SHIBBOLETH_CALLBACK_URL_METADATA_KEY,
301
- entryPoint ? null : SHIBBOLETH_IDP_ENTRYPOINT_METADATA_KEY,
302
- resolvedIdpCertificates.length > 0 ? null : SHIBBOLETH_IDP_CERTIFICATE_METADATA_KEY,
303
- ].filter((value): value is string => value !== null);
273
+ return {
274
+ ip,
275
+ userAgent: request.headers.get('user-agent'),
276
+ };
277
+ }
278
+
279
+ /**
280
+ * Resolves Shibboleth configuration from metadata for a route-handler request.
281
+ *
282
+ * @param options - Request URL and validation options.
283
+ * @returns Shibboleth configuration status and parsed IdP metadata when validation is enabled.
284
+ *
285
+ * @private internal Shibboleth authentication helper
286
+ */
287
+ export async function resolveShibbolethAuthenticationConfiguration(
288
+ options: ResolveShibbolethAuthenticationConfigurationOptions,
289
+ ): Promise<ShibbolethAuthenticationConfiguration> {
290
+ const metadata = await getMetadataMap(SHIBBOLETH_AUTHENTICATION_METADATA_KEYS);
291
+ const isActive = parseShibbolethBooleanMetadata(metadata[IS_SHIBBOLETH_AUTH_ACTIVE_METADATA_KEY]);
292
+ const identityProviderMetadataUrl =
293
+ (metadata[SHIBBOLETH_IDENTITY_PROVIDER_METADATA_URL_METADATA_KEY] || '').trim() || null;
294
+ const identityProviderMetadataXml =
295
+ (metadata[SHIBBOLETH_IDENTITY_PROVIDER_METADATA_XML_METADATA_KEY] || '').trim() || null;
296
+ const serviceProviderUrls = resolveShibbolethServiceProviderUrls(options.requestUrl, metadata);
297
+ const errors: string[] = [];
298
+ let identityProviderMetadata: ShibbolethIdentityProviderMetadata | null = null;
299
+
300
+ if (!identityProviderMetadataUrl && !identityProviderMetadataXml) {
301
+ errors.push(
302
+ `Set ${SHIBBOLETH_IDENTITY_PROVIDER_METADATA_URL_METADATA_KEY} or ${SHIBBOLETH_IDENTITY_PROVIDER_METADATA_XML_METADATA_KEY}.`,
303
+ );
304
+ }
304
305
 
305
306
  if (
306
- missingConfiguration.length > 0 ||
307
- !issuer ||
308
- !callbackUrl ||
309
- !entryPoint ||
310
- resolvedIdpCertificates.length === 0
307
+ options.isIdentityProviderMetadataValidationEnabled === true &&
308
+ (identityProviderMetadataUrl || identityProviderMetadataXml)
311
309
  ) {
312
- return {
313
- isEnabled: true,
314
- isConfigured: false,
315
- providerLabel,
316
- issuer,
317
- callbackUrl,
318
- missingConfiguration,
319
- };
310
+ try {
311
+ const metadataXml =
312
+ identityProviderMetadataXml || (await fetchIdentityProviderMetadataXml(identityProviderMetadataUrl!));
313
+ identityProviderMetadata = parseIdentityProviderMetadataXml(metadataXml);
314
+ } catch (error) {
315
+ errors.push(
316
+ error instanceof Error ? error.message : 'Failed to load Shibboleth Identity Provider metadata.',
317
+ );
318
+ }
320
319
  }
321
320
 
322
321
  return {
323
- isEnabled: true,
324
- isConfigured: true,
325
- providerLabel,
326
- issuer,
327
- callbackUrl,
328
- entryPoint,
329
- idpCertificates: resolvedIdpCertificates,
330
- idpIssuer,
331
- usernameAttribute,
332
- isAutoCreateUsers,
322
+ isActive,
323
+ isConfigured: errors.length === 0,
324
+ errors,
325
+ identityProviderMetadataUrl,
326
+ isIdentityProviderMetadataXmlConfigured: Boolean(identityProviderMetadataXml),
327
+ serviceProviderUrls,
328
+ identityProviderMetadata,
333
329
  };
334
330
  }
335
331
 
336
332
  /**
337
- * Builds a node-saml client from resolved Shibboleth configuration.
333
+ * Creates a Node-SAML client for the resolved Shibboleth configuration.
338
334
  *
339
- * @param configuration - Enabled Shibboleth configuration.
340
- * @returns Configured SAML client.
335
+ * @param configuration - Resolved Shibboleth configuration with parsed IdP metadata.
336
+ * @returns Configured Node-SAML client.
341
337
  *
342
- * @public exported from `apps/agents-server`
338
+ * @private internal Shibboleth authentication helper
343
339
  */
344
- export function createShibbolethSamlClient(configuration: EnabledShibbolethConfiguration): SAML {
340
+ export function createShibbolethSamlClient(configuration: ShibbolethAuthenticationConfiguration): SAML {
341
+ if (!configuration.identityProviderMetadata) {
342
+ throw new Error('Shibboleth Identity Provider metadata is not loaded.');
343
+ }
344
+
345
345
  return new SAML({
346
+ callbackUrl: configuration.serviceProviderUrls.assertionConsumerServiceUrl,
347
+ entryPoint: configuration.identityProviderMetadata.singleSignOnServiceUrl,
348
+ issuer: configuration.serviceProviderUrls.entityId,
349
+ audience: configuration.serviceProviderUrls.entityId,
350
+ idpCert: [...configuration.identityProviderMetadata.signingCertificates],
351
+ identifierFormat: SHIBBOLETH_PERSISTENT_NAME_ID_FORMAT,
352
+ wantAssertionsSigned: true,
353
+ wantAuthnResponseSigned: false,
346
354
  acceptedClockSkewMs: SHIBBOLETH_ACCEPTED_CLOCK_SKEW_MS,
347
- callbackUrl: configuration.callbackUrl,
348
- digestAlgorithm: 'sha256',
355
+ validateInResponseTo: ValidateInResponseTo.never,
349
356
  disableRequestedAuthnContext: true,
350
- entryPoint: configuration.entryPoint,
351
- identifierFormat: SHIBBOLETH_IDENTIFIER_FORMAT,
352
- idpCert: [...configuration.idpCertificates],
353
- idpIssuer: configuration.idpIssuer ?? undefined,
354
- issuer: configuration.issuer,
355
- requestIdExpirationPeriodMs: SHIBBOLETH_REQUEST_ID_EXPIRATION_PERIOD_MS,
356
357
  signatureAlgorithm: 'sha256',
357
- validateInResponseTo: ValidateInResponseTo.always,
358
- wantAssertionsSigned: true,
359
- wantAuthnResponseSigned: false,
360
- cacheProvider: SHIBBOLETH_REQUEST_ID_CACHE_PROVIDER,
361
358
  });
362
359
  }
363
360
 
364
361
  /**
365
- * Creates SAML Service Provider metadata XML for the configured server.
362
+ * Creates Service Provider metadata XML for the current Agents Server deployment.
366
363
  *
367
- * @param configuration - Shibboleth configuration with issuer and callback URL.
368
- * @returns SAML SP metadata XML.
364
+ * @param requestUrl - URL of the current metadata route request.
365
+ * @returns Service Provider metadata XML suitable for the Shibboleth IdP admin.
369
366
  *
370
- * @public exported from `apps/agents-server`
367
+ * @private internal Shibboleth authentication helper
371
368
  */
372
- export function createShibbolethServiceProviderMetadata(
373
- configuration: Pick<EnabledShibbolethConfiguration, 'issuer' | 'callbackUrl'>,
374
- ): string {
369
+ export async function createShibbolethServiceProviderMetadataXml(requestUrl: string): Promise<string> {
370
+ const metadata = await getMetadataMap(SHIBBOLETH_AUTHENTICATION_METADATA_KEYS);
371
+ const serviceProviderUrls = resolveShibbolethServiceProviderUrls(requestUrl, metadata);
372
+
375
373
  return generateServiceProviderMetadata({
376
- callbackUrl: configuration.callbackUrl,
377
- identifierFormat: SHIBBOLETH_IDENTIFIER_FORMAT,
378
- issuer: configuration.issuer,
374
+ issuer: serviceProviderUrls.entityId,
375
+ callbackUrl: serviceProviderUrls.assertionConsumerServiceUrl,
376
+ identifierFormat: SHIBBOLETH_PERSISTENT_NAME_ID_FORMAT,
379
377
  wantAssertionsSigned: true,
380
378
  });
381
379
  }
382
380
 
383
381
  /**
384
- * Resolves a valid local redirect path from SAML RelayState.
382
+ * Records one Shibboleth authentication attempt.
385
383
  *
386
- * @param relayState - Raw RelayState value.
387
- * @returns Safe local redirect path.
384
+ * @param options - Attempt details.
388
385
  *
389
- * @public exported from `apps/agents-server`
386
+ * @private internal Shibboleth authentication audit helper
390
387
  */
391
- export function resolveSafeShibbolethRelayState(relayState: string | null | undefined): string {
392
- const trimmedRelayState = relayState?.trim() || '';
393
-
394
- if (!trimmedRelayState || !trimmedRelayState.startsWith('/') || trimmedRelayState.startsWith('//')) {
395
- return '/';
396
- }
397
-
388
+ export async function recordShibbolethAuthenticationAttempt(
389
+ options: RecordShibbolethAuthenticationAttemptOptions,
390
+ ): Promise<void> {
398
391
  try {
399
- const parsedUrl = new URL(trimmedRelayState, 'https://promptbook.local');
400
- return `${parsedUrl.pathname}${parsedUrl.search}${parsedUrl.hash}`;
401
- } catch {
402
- return '/';
392
+ const supabase = $provideSupabaseForServer();
393
+ const tableName = await getShibbolethAuthenticationAttemptTableName();
394
+ const { error } = await supabase.from(tableName).insert({
395
+ stage: options.stage,
396
+ status: options.status,
397
+ userId: options.userId ?? null,
398
+ email: options.email ?? null,
399
+ displayName: options.displayName ?? null,
400
+ nameId: options.nameId ?? null,
401
+ relayState: options.relayState ?? null,
402
+ ip: options.requestDetails?.ip ?? null,
403
+ userAgent: options.requestDetails?.userAgent ?? null,
404
+ errorMessage: options.errorMessage ?? null,
405
+ rawAttributes: options.rawAttributes ?? null,
406
+ } as never);
407
+
408
+ if (error) {
409
+ console.error('Failed to record Shibboleth authentication attempt:', error);
410
+ }
411
+ } catch (error) {
412
+ console.error('Failed to record Shibboleth authentication attempt:', error);
403
413
  }
404
414
  }
405
415
 
406
416
  /**
407
- * Resolves the local Agents Server user represented by a validated Shibboleth profile.
417
+ * Links a validated SAML profile to an Agents Server user, creating a passwordless user when needed.
408
418
  *
409
- * @param profile - Validated SAML profile.
410
- * @param configuration - Enabled Shibboleth configuration.
411
- * @returns Local user resolution.
419
+ * @param profile - Validated SAML profile from Node-SAML.
420
+ * @returns Linked database user and extracted Shibboleth attributes.
412
421
  *
413
- * @public exported from `apps/agents-server`
422
+ * @private internal Shibboleth authentication user helper
414
423
  */
415
- export async function resolveShibbolethUser(
416
- profile: Profile,
417
- configuration: Pick<EnabledShibbolethConfiguration, 'usernameAttribute' | 'isAutoCreateUsers'>,
418
- ): Promise<ShibbolethUserResolution> {
419
- const username = resolveShibbolethUsername(profile, configuration.usernameAttribute);
420
-
421
- if (!username) {
422
- throw new Error(
423
- spaceTrim(`
424
- Shibboleth profile does not contain a usable username.
425
-
426
- Checked configured attribute \`${configuration.usernameAttribute}\` and common fallback attributes.
427
- `),
428
- );
429
- }
424
+ export async function findOrCreateShibbolethUser(profile: Profile): Promise<LinkedShibbolethUser> {
425
+ const metadata = await getMetadataMap(SHIBBOLETH_AUTHENTICATION_METADATA_KEYS);
426
+ const profileAttributes = extractShibbolethProfileAttributes(profile, metadata);
427
+ const now = new Date().toISOString();
428
+ const existingIdentity =
429
+ (await findShibbolethIdentityByNameId(profileAttributes.nameId)) ||
430
+ (await findShibbolethIdentityByEmail(profileAttributes.email));
431
+ let user = existingIdentity ? await findUserRowById(existingIdentity.userId) : null;
430
432
 
431
- const supabase = $provideSupabaseForServer();
432
- const userTable = await $getTableName('User');
433
- const { data: existingUser, error: existingUserError } = await supabase
434
- .from(userTable)
435
- .select('username, isAdmin')
436
- .eq('username', username)
437
- .maybeSingle<Pick<AgentsServerDatabase['public']['Tables']['User']['Row'], 'username' | 'isAdmin'>>();
438
-
439
- if (existingUserError) {
440
- throw new Error(`Failed to load Shibboleth user \`${username}\`: ${existingUserError.message}`);
433
+ if (!user) {
434
+ user = await findUserRowByEmail(profileAttributes.email);
441
435
  }
442
436
 
443
- if (existingUser) {
444
- return {
445
- username: existingUser.username,
446
- isAdmin: existingUser.isAdmin,
447
- isNewUser: false,
448
- };
437
+ if (!user) {
438
+ user = await findUserRowByUsername(profileAttributes.email);
449
439
  }
450
440
 
451
- if (!configuration.isAutoCreateUsers) {
452
- throw new Error(`Shibboleth user \`${username}\` does not exist and automatic user creation is disabled.`);
441
+ if (!user) {
442
+ user = await insertShibbolethUser(profileAttributes, now);
443
+ } else {
444
+ user = await updateLinkedShibbolethUser(user, profileAttributes, now);
453
445
  }
454
446
 
455
- const passwordHash = await hashPassword(randomBytes(32).toString('hex'));
456
- const { data: createdUser, error: createdUserError } = await supabase
457
- .from(userTable)
458
- .insert({
459
- username,
460
- passwordHash,
461
- isAdmin: false,
462
- createdAt: new Date().toISOString(),
463
- updatedAt: new Date().toISOString(),
464
- })
465
- .select('username, isAdmin')
466
- .single<Pick<AgentsServerDatabase['public']['Tables']['User']['Row'], 'username' | 'isAdmin'>>();
467
-
468
- if (createdUserError) {
469
- throw new Error(`Failed to create Shibboleth user \`${username}\`: ${createdUserError.message}`);
470
- }
447
+ await upsertShibbolethIdentity(user, profileAttributes, existingIdentity, now);
471
448
 
472
449
  return {
473
- username: createdUser.username,
474
- isAdmin: createdUser.isAdmin,
475
- isNewUser: true,
450
+ user,
451
+ profileAttributes,
476
452
  };
477
453
  }
478
454
 
479
455
  /**
480
- * Logs one Shibboleth authentication event with consistent metadata.
456
+ * Resolves the prefixed Shibboleth identity table name without relying on generated schema typings.
481
457
  *
482
- * @param event - Event identifier.
483
- * @param details - Safe structured details.
458
+ * @returns Prefixed table name.
484
459
  *
485
- * @public exported from `apps/agents-server`
460
+ * @private internal Shibboleth authentication table helper
486
461
  */
487
- export function logShibbolethAuthenticationEvent(event: string, details: Record<string, unknown> = {}): void {
488
- console.info('[agents-server:shibboleth]', {
489
- event,
490
- ...details,
491
- });
462
+ export async function getShibbolethUserIdentityTableName(): Promise<string> {
463
+ const { tablePrefix } = await $provideServer();
464
+ return `${tablePrefix}ShibbolethUserIdentity`;
492
465
  }
493
466
 
494
467
  /**
495
- * Extracts safe profile diagnostics for Shibboleth logging.
468
+ * Resolves the prefixed Shibboleth authentication attempt table name without relying on generated schema typings.
496
469
  *
497
- * @param profile - Validated SAML profile.
498
- * @param usernameAttribute - Configured username attribute.
499
- * @returns Safe diagnostic payload.
470
+ * @returns Prefixed table name.
500
471
  *
501
- * @public exported from `apps/agents-server`
472
+ * @private internal Shibboleth authentication table helper
502
473
  */
503
- export function createShibbolethProfileLogDetails(
474
+ export async function getShibbolethAuthenticationAttemptTableName(): Promise<string> {
475
+ const { tablePrefix } = await $provideServer();
476
+ return `${tablePrefix}ShibbolethAuthenticationAttempt`;
477
+ }
478
+
479
+ /**
480
+ * Extracts required Shibboleth profile attributes from a validated SAML profile.
481
+ */
482
+ function extractShibbolethProfileAttributes(
504
483
  profile: Profile,
505
- usernameAttribute: string,
506
- ): Record<string, unknown> {
484
+ metadata: Readonly<Record<string, string | null>>,
485
+ ): ShibbolethProfileAttributes {
486
+ const rawAttributes = sanitizeShibbolethRawAttributes(profile);
487
+ const email = getFirstProfileAttribute(
488
+ profile,
489
+ parseAttributeNames(
490
+ metadata[SHIBBOLETH_EMAIL_ATTRIBUTE_NAMES_METADATA_KEY],
491
+ DEFAULT_SHIBBOLETH_EMAIL_ATTRIBUTE_NAMES,
492
+ ),
493
+ )?.toLowerCase();
494
+
495
+ if (!email) {
496
+ throw new Error('Shibboleth login did not provide an email attribute.');
497
+ }
498
+
499
+ const displayName =
500
+ getFirstProfileAttribute(
501
+ profile,
502
+ parseAttributeNames(
503
+ metadata[SHIBBOLETH_DISPLAY_NAME_ATTRIBUTE_NAMES_METADATA_KEY],
504
+ DEFAULT_SHIBBOLETH_DISPLAY_NAME_ATTRIBUTE_NAMES,
505
+ ),
506
+ ) || null;
507
+ const unstructuredName =
508
+ getFirstProfileAttribute(
509
+ profile,
510
+ parseAttributeNames(
511
+ metadata[SHIBBOLETH_UNSTRUCTURED_NAME_ATTRIBUTE_NAMES_METADATA_KEY],
512
+ DEFAULT_SHIBBOLETH_UNSTRUCTURED_NAME_ATTRIBUTE_NAMES,
513
+ ),
514
+ ) || null;
515
+ const eduPersonPrincipalName =
516
+ getFirstProfileAttribute(profile, ['eduPersonPrincipalName', 'urn:oid:1.3.6.1.4.1.5923.1.1.1.6']) || null;
517
+
507
518
  return {
508
- issuer: profile.issuer,
509
- nameIDFormat: profile.nameIDFormat,
510
- configuredUsernameAttribute: usernameAttribute,
511
- availableAttributes: Object.keys(profile)
512
- .filter((key) => !['getAssertion', 'getAssertionXml', 'getSamlResponseXml'].includes(key))
513
- .sort(),
519
+ email,
520
+ displayName,
521
+ nameId: getStringOrNull(profile.nameID),
522
+ nameIdFormat: getStringOrNull(profile.nameIDFormat),
523
+ unstructuredName,
524
+ eduPersonPrincipalName,
525
+ rawAttributes,
514
526
  };
515
527
  }
516
528
 
517
529
  /**
518
- * Parses Shibboleth IdP metadata XML.
519
- *
520
- * @param xml - Raw IdP metadata XML.
521
- * @returns Parsed metadata fields.
522
- *
523
- * @public exported from `apps/agents-server`
530
+ * Resolves SP URLs from request URL, environment, and metadata.
524
531
  */
525
- export function parseShibbolethIdentityProviderMetadata(xml: string): ShibbolethIdentityProviderMetadata {
526
- const parsedXml = SHIBBOLETH_METADATA_XML_PARSER.parse(xml) as unknown;
532
+ function resolveShibbolethServiceProviderUrls(
533
+ requestUrl: string,
534
+ metadata: Readonly<Record<string, string | null>>,
535
+ ): ShibbolethServiceProviderUrls {
536
+ const requestOrigin = new URL(requestUrl).origin;
537
+ const configuredSiteUrl = (process.env.NEXT_PUBLIC_SITE_URL || '').trim();
538
+ const origin = configuredSiteUrl ? new URL(configuredSiteUrl).origin : requestOrigin;
539
+ const assertionConsumerServiceUrl = new URL('/api/auth/shibboleth/acs', origin).toString();
540
+ const metadataUrl = new URL('/api/auth/shibboleth/metadata', origin).toString();
541
+ const configuredEntityId = (metadata[SHIBBOLETH_SERVICE_PROVIDER_ENTITY_ID_METADATA_KEY] || '').trim();
527
542
 
528
543
  return {
529
- entityId: findFirstAttribute(parsedXml, 'entityID'),
530
- entryPoint: findPreferredSingleSignOnServiceLocation(parsedXml),
531
- certificates: findAllValuesByKey(parsedXml, 'X509Certificate').map(formatShibbolethCertificate),
544
+ origin,
545
+ entityId: configuredEntityId || metadataUrl,
546
+ assertionConsumerServiceUrl,
547
+ metadataUrl,
532
548
  };
533
549
  }
534
550
 
535
551
  /**
536
- * Resolves the public base URL used for Shibboleth defaults.
537
- *
538
- * @param request - Incoming request.
539
- * @returns Public base URL without a trailing slash.
540
- *
541
- * @public exported from `apps/agents-server`
552
+ * Loads remote IdP metadata XML.
542
553
  */
543
- export function resolveShibbolethPublicBaseUrl(request?: Request): string | null {
544
- const configuredSiteUrl = normalizeShibbolethUrl(process.env.NEXT_PUBLIC_SITE_URL);
545
- if (configuredSiteUrl) {
546
- return configuredSiteUrl;
554
+ async function fetchIdentityProviderMetadataXml(identityProviderMetadataUrl: string): Promise<string> {
555
+ const response = await fetch(identityProviderMetadataUrl, {
556
+ cache: 'no-store',
557
+ signal: AbortSignal.timeout(SHIBBOLETH_IDENTITY_PROVIDER_METADATA_TIMEOUT_MS),
558
+ });
559
+
560
+ if (!response.ok) {
561
+ throw new Error(
562
+ `Failed to load Shibboleth Identity Provider metadata: ${response.status} ${response.statusText}.`,
563
+ );
547
564
  }
548
565
 
549
- if (!request) {
550
- return null;
566
+ return response.text();
567
+ }
568
+
569
+ /**
570
+ * Parses IdP metadata XML into the fields needed for SAML login.
571
+ */
572
+ function parseIdentityProviderMetadataXml(metadataXml: string): ShibbolethIdentityProviderMetadata {
573
+ const document = new DOMParser().parseFromString(metadataXml, 'application/xml') as unknown as XmlNodeWithElements;
574
+ const singleSignOnServices = getElementsByLocalName(document, 'SingleSignOnService');
575
+ const redirectSingleSignOnService =
576
+ singleSignOnServices.find((element) => element.getAttribute('Binding') === SAML_HTTP_REDIRECT_BINDING) ||
577
+ singleSignOnServices[0];
578
+ const singleSignOnServiceUrl = redirectSingleSignOnService?.getAttribute('Location') || '';
579
+ const signingCertificates = getSigningCertificates(document);
580
+
581
+ if (!singleSignOnServiceUrl) {
582
+ throw new Error('Shibboleth Identity Provider metadata is missing SingleSignOnService Location.');
551
583
  }
552
584
 
553
- const host = request.headers.get('x-forwarded-host') || request.headers.get('host');
554
- if (!host) {
555
- return null;
585
+ if (signingCertificates.length === 0) {
586
+ throw new Error('Shibboleth Identity Provider metadata is missing a signing certificate.');
556
587
  }
557
588
 
558
- const protocol = (request.headers.get('x-forwarded-proto') || 'https').split(',')[0]?.trim() || 'https';
559
- return normalizeShibbolethUrl(`${protocol}://${host}`);
589
+ return {
590
+ singleSignOnServiceUrl,
591
+ signingCertificates,
592
+ };
560
593
  }
561
594
 
562
595
  /**
563
- * Loads and parses remote Shibboleth IdP metadata.
564
- *
565
- * @param metadataUrl - Raw metadata URL.
566
- * @returns Parsed metadata or an empty fallback.
567
- */
568
- async function loadShibbolethIdentityProviderMetadata(
569
- metadataUrl: string | null | undefined,
570
- ): Promise<ShibbolethIdentityProviderMetadata> {
571
- const normalizedMetadataUrl = normalizeShibbolethUrl(metadataUrl);
572
- if (!normalizedMetadataUrl) {
573
- return {
574
- entityId: null,
575
- entryPoint: null,
576
- certificates: [],
577
- };
578
- }
596
+ * Finds all XML elements with the given local name.
597
+ */
598
+ function getElementsByLocalName(root: XmlNodeWithElements, localName: string): Array<XmlElementWithAttributes> {
599
+ return Array.from(root.getElementsByTagName('*')).filter(
600
+ (element) =>
601
+ element.localName === localName ||
602
+ element.nodeName === localName ||
603
+ element.nodeName.endsWith(`:${localName}`),
604
+ );
605
+ }
579
606
 
580
- try {
581
- const response = await fetch(normalizedMetadataUrl, {
582
- signal: AbortSignal.timeout(SHIBBOLETH_METADATA_FETCH_TIMEOUT_MS),
583
- });
584
-
585
- if (!response.ok) {
586
- logShibbolethAuthenticationEvent('idp_metadata_fetch_failed', {
587
- metadataUrl: normalizedMetadataUrl,
588
- status: response.status,
589
- });
590
- return {
591
- entityId: null,
592
- entryPoint: null,
593
- certificates: [],
594
- };
595
- }
607
+ /**
608
+ * Extracts signing certificates from IdP metadata XML.
609
+ */
610
+ function getSigningCertificates(document: XmlNodeWithElements): string[] {
611
+ const signingKeyDescriptors = getElementsByLocalName(document, 'KeyDescriptor').filter((element) => {
612
+ const use = element.getAttribute('use');
613
+ return !use || use === 'signing';
614
+ });
615
+ const certificateElements = signingKeyDescriptors.flatMap((element) =>
616
+ getElementsByLocalName(element, 'X509Certificate'),
617
+ );
618
+ const fallbackCertificateElements =
619
+ certificateElements.length > 0 ? certificateElements : getElementsByLocalName(document, 'X509Certificate');
620
+
621
+ return Array.from(
622
+ new Set(
623
+ fallbackCertificateElements
624
+ .map((element) => formatPemCertificate(element.textContent || ''))
625
+ .filter(Boolean),
626
+ ),
627
+ );
628
+ }
596
629
 
597
- return parseShibbolethIdentityProviderMetadata(await response.text());
598
- } catch (error) {
599
- logShibbolethAuthenticationEvent('idp_metadata_fetch_error', {
600
- metadataUrl: normalizedMetadataUrl,
601
- error: error instanceof Error ? error.message : String(error),
602
- });
603
- return {
604
- entityId: null,
605
- entryPoint: null,
606
- certificates: [],
607
- };
630
+ /**
631
+ * Formats an XML X.509 certificate value as PEM.
632
+ */
633
+ function formatPemCertificate(certificate: string): string {
634
+ const base64Certificate = certificate
635
+ .replace(/-----BEGIN CERTIFICATE-----/gu, '')
636
+ .replace(/-----END CERTIFICATE-----/gu, '')
637
+ .replace(/\s+/gu, '');
638
+
639
+ if (!base64Certificate) {
640
+ return '';
608
641
  }
642
+
643
+ const wrappedCertificate = base64Certificate.match(/.{1,64}/gu)?.join('\n') || base64Certificate;
644
+ return `-----BEGIN CERTIFICATE-----\n${wrappedCertificate}\n-----END CERTIFICATE-----`;
609
645
  }
610
646
 
611
647
  /**
612
- * Parses one metadata field containing one or more certificates.
613
- *
614
- * @param value - Raw certificate metadata.
615
- * @returns Normalized certificates.
648
+ * Finds the first non-empty profile attribute from a list of accepted names.
616
649
  */
617
- function parseShibbolethCertificates(value: string | null | undefined): ReadonlyArray<string> {
618
- const trimmedValue = value?.trim() || '';
619
- if (!trimmedValue) {
620
- return [];
650
+ function getFirstProfileAttribute(profile: Profile, attributeNames: ReadonlyArray<string>): string | null {
651
+ for (const attributeName of attributeNames) {
652
+ const value = profile[attributeName];
653
+ const stringValue = getFirstStringValue(value);
654
+ if (stringValue) {
655
+ return stringValue;
656
+ }
621
657
  }
622
658
 
623
- const pemMatches = trimmedValue.match(/-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/gu);
624
- if (pemMatches && pemMatches.length > 0) {
625
- return pemMatches.map(formatShibbolethCertificate);
626
- }
659
+ return null;
660
+ }
627
661
 
628
- return trimmedValue
629
- .split(/\n\s*\n/u)
630
- .map(formatShibbolethCertificate)
662
+ /**
663
+ * Parses metadata-defined attribute names.
664
+ */
665
+ function parseAttributeNames(value: string | null | undefined, fallback: string): string[] {
666
+ return (value || fallback)
667
+ .split(/[\s,]+/u)
668
+ .map((attributeName) => attributeName.trim())
631
669
  .filter(Boolean);
632
670
  }
633
671
 
634
672
  /**
635
- * Normalizes a certificate to PEM form.
636
- *
637
- * @param certificate - Raw certificate value.
638
- * @returns PEM certificate.
673
+ * Converts a profile value into one string.
639
674
  */
640
- function formatShibbolethCertificate(certificate: string): string {
641
- const trimmedCertificate = certificate.trim().replace(/\\n/gu, '\n');
642
- if (trimmedCertificate.includes('-----BEGIN CERTIFICATE-----')) {
643
- return trimmedCertificate;
675
+ function getFirstStringValue(value: unknown): string | null {
676
+ if (typeof value === 'string') {
677
+ return value.trim() || null;
644
678
  }
645
679
 
646
- const compactCertificate = trimmedCertificate.replace(/[\s\r\n]+/gu, '');
647
- const lines = compactCertificate.match(/.{1,64}/gu) || [];
680
+ if (Array.isArray(value)) {
681
+ for (const item of value) {
682
+ const stringValue = getFirstStringValue(item);
683
+ if (stringValue) {
684
+ return stringValue;
685
+ }
686
+ }
687
+ }
648
688
 
649
- return ['-----BEGIN CERTIFICATE-----', ...lines, '-----END CERTIFICATE-----'].join('\n');
689
+ return null;
650
690
  }
651
691
 
652
692
  /**
653
- * Resolves the local username from a validated SAML profile.
654
- *
655
- * @param profile - Validated SAML profile.
656
- * @param usernameAttribute - Configured username attribute.
657
- * @returns Local username or `null`.
658
- */
659
- function resolveShibbolethUsername(profile: Profile, usernameAttribute: string): string | null {
660
- const rawUsername =
661
- pickProfileAttribute(profile, usernameAttribute) ||
662
- pickProfileAttribute(profile, 'mail') ||
663
- pickProfileAttribute(profile, 'email') ||
664
- pickProfileAttribute(profile, 'urn:oid:0.9.2342.19200300.100.1.3') ||
665
- pickProfileAttribute(profile, 'eduPersonPrincipalName') ||
666
- pickProfileAttribute(profile, 'unstructuredName') ||
667
- profile.nameID;
668
-
669
- const normalizedUsername = rawUsername.trim();
670
- if (!normalizedUsername) {
671
- return null;
693
+ * Converts an unknown value into a string or null.
694
+ */
695
+ function getStringOrNull(value: unknown): string | null {
696
+ return typeof value === 'string' && value.trim() ? value.trim() : null;
697
+ }
698
+
699
+ /**
700
+ * Builds a JSON-safe SAML profile snapshot for audit records.
701
+ */
702
+ function sanitizeShibbolethRawAttributes(profile: Profile): Json {
703
+ const sanitizedAttributes: Record<string, Json> = {};
704
+
705
+ for (const [key, value] of Object.entries(profile)) {
706
+ if (typeof value === 'function') {
707
+ continue;
708
+ }
709
+
710
+ const sanitizedValue = sanitizeJsonValue(value);
711
+ if (sanitizedValue !== undefined) {
712
+ sanitizedAttributes[key] = sanitizedValue;
713
+ }
672
714
  }
673
715
 
674
- return normalizedUsername.includes('@') ? normalizedUsername.toLowerCase() : normalizedUsername;
716
+ return sanitizedAttributes;
675
717
  }
676
718
 
677
719
  /**
678
- * Reads the first string-like value of one SAML profile attribute.
679
- *
680
- * @param profile - Validated SAML profile.
681
- * @param attributeName - Attribute name.
682
- * @returns Attribute value or empty string.
720
+ * Converts unknown profile values into JSON-safe values.
683
721
  */
684
- function pickProfileAttribute(profile: Profile, attributeName: string): string {
685
- const value = profile[attributeName];
686
-
687
- if (typeof value === 'string') {
722
+ function sanitizeJsonValue(value: unknown): Json | undefined {
723
+ if (value === null || typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
688
724
  return value;
689
725
  }
690
726
 
691
727
  if (Array.isArray(value)) {
692
- const firstString = value.find((item): item is string => typeof item === 'string');
693
- return firstString || '';
728
+ return value.map((item) => sanitizeJsonValue(item)).filter((item): item is Json => item !== undefined);
729
+ }
730
+
731
+ if (typeof value === 'object') {
732
+ const result: Record<string, Json> = {};
733
+ for (const [key, nestedValue] of Object.entries(value)) {
734
+ const sanitizedValue = sanitizeJsonValue(nestedValue);
735
+ if (sanitizedValue !== undefined) {
736
+ result[key] = sanitizedValue;
737
+ }
738
+ }
739
+ return result;
694
740
  }
695
741
 
696
- return '';
742
+ return undefined;
697
743
  }
698
744
 
699
745
  /**
700
- * Finds the first XML attribute value with the given local name.
701
- *
702
- * @param value - Parsed XML subtree.
703
- * @param attributeName - Local attribute name.
704
- * @returns Attribute value or `null`.
746
+ * Finds one Shibboleth identity by persistent NameID.
705
747
  */
706
- function findFirstAttribute(value: unknown, attributeName: string): string | null {
707
- if (!value || typeof value !== 'object') {
748
+ async function findShibbolethIdentityByNameId(nameId: string | null): Promise<ShibbolethUserIdentityRow | null> {
749
+ if (!nameId) {
708
750
  return null;
709
751
  }
710
752
 
711
- const record = value as Record<string, unknown>;
712
- const directValue = record[`@_${attributeName}`];
713
- if (typeof directValue === 'string' && directValue.trim()) {
714
- return directValue.trim();
753
+ const supabase = $provideSupabaseForServer();
754
+ const tableName = await getShibbolethUserIdentityTableName();
755
+ const { data, error } = await supabase
756
+ .from(tableName)
757
+ .select('*')
758
+ .eq('nameId' as never, nameId as never)
759
+ .maybeSingle();
760
+
761
+ if (error) {
762
+ throw new Error(`Failed to resolve Shibboleth identity by NameID: ${error.message}`);
715
763
  }
716
764
 
717
- for (const childValue of Object.values(record)) {
718
- const nestedValue = Array.isArray(childValue)
719
- ? childValue.map((item) => findFirstAttribute(item, attributeName)).find(Boolean)
720
- : findFirstAttribute(childValue, attributeName);
765
+ return (data as ShibbolethUserIdentityRow | null) || null;
766
+ }
721
767
 
722
- if (nestedValue) {
723
- return nestedValue;
724
- }
768
+ /**
769
+ * Finds one Shibboleth identity by email.
770
+ */
771
+ async function findShibbolethIdentityByEmail(email: string): Promise<ShibbolethUserIdentityRow | null> {
772
+ const supabase = $provideSupabaseForServer();
773
+ const tableName = await getShibbolethUserIdentityTableName();
774
+ const { data, error } = await supabase
775
+ .from(tableName)
776
+ .select('*')
777
+ .eq('email' as never, email as never)
778
+ .maybeSingle();
779
+
780
+ if (error) {
781
+ throw new Error(`Failed to resolve Shibboleth identity by email: ${error.message}`);
725
782
  }
726
783
 
727
- return null;
784
+ return (data as ShibbolethUserIdentityRow | null) || null;
728
785
  }
729
786
 
730
787
  /**
731
- * Finds all parsed XML text values for a key name.
732
- *
733
- * @param value - Parsed XML subtree.
734
- * @param key - Local XML key name.
735
- * @returns Matching text values.
788
+ * Finds one user row by database id.
736
789
  */
737
- function findAllValuesByKey(value: unknown, key: string): string[] {
738
- if (!value || typeof value !== 'object') {
739
- return [];
790
+ async function findUserRowById(userId: number): Promise<UserRowWithShibbolethColumns | null> {
791
+ const supabase = $provideSupabaseForServer();
792
+ const { data, error } = await supabase
793
+ .from(await $getTableName('User'))
794
+ .select('*')
795
+ .eq('id', userId)
796
+ .maybeSingle();
797
+
798
+ if (error) {
799
+ throw new Error(`Failed to resolve Shibboleth user by id: ${error.message}`);
740
800
  }
741
801
 
742
- const record = value as Record<string, unknown>;
743
- const directValue = record[key];
744
- const directValues =
745
- typeof directValue === 'string'
746
- ? [directValue]
747
- : Array.isArray(directValue)
748
- ? directValue.filter((item): item is string => typeof item === 'string')
749
- : [];
750
-
751
- return [
752
- ...directValues,
753
- ...Object.entries(record)
754
- .filter(([childKey]) => childKey !== key)
755
- .flatMap(([, childValue]) =>
756
- Array.isArray(childValue)
757
- ? childValue.flatMap((item) => findAllValuesByKey(item, key))
758
- : findAllValuesByKey(childValue, key),
759
- ),
760
- ];
802
+ return (data as UserRowWithShibbolethColumns | null) || null;
761
803
  }
762
804
 
763
805
  /**
764
- * Finds the preferred SAML SingleSignOnService location.
765
- *
766
- * @param value - Parsed XML subtree.
767
- * @returns Preferred SSO location.
806
+ * Finds one user row by email.
768
807
  */
769
- function findPreferredSingleSignOnServiceLocation(value: unknown): string | null {
770
- const services = findAllObjectsByKey(value, 'SingleSignOnService');
771
- const preferredService =
772
- services.find((service) => String(service['@_Binding'] || '').includes('HTTP-Redirect')) || services[0];
773
- const location = preferredService?.['@_Location'];
808
+ async function findUserRowByEmail(email: string): Promise<UserRowWithShibbolethColumns | null> {
809
+ const supabase = $provideSupabaseForServer();
810
+ const { data, error } = await supabase
811
+ .from(await $getTableName('User'))
812
+ .select('*')
813
+ .eq('email' as never, email as never)
814
+ .maybeSingle();
815
+
816
+ if (error) {
817
+ throw new Error(`Failed to resolve Shibboleth user by email: ${error.message}`);
818
+ }
774
819
 
775
- return typeof location === 'string' && location.trim() ? location.trim() : null;
820
+ return (data as UserRowWithShibbolethColumns | null) || null;
776
821
  }
777
822
 
778
823
  /**
779
- * Finds all parsed XML objects for a key name.
780
- *
781
- * @param value - Parsed XML subtree.
782
- * @param key - Local XML key name.
783
- * @returns Matching objects.
824
+ * Finds one user row by username.
784
825
  */
785
- function findAllObjectsByKey(value: unknown, key: string): Array<Record<string, unknown>> {
786
- if (!value || typeof value !== 'object') {
787
- return [];
826
+ async function findUserRowByUsername(username: string): Promise<UserRowWithShibbolethColumns | null> {
827
+ const supabase = $provideSupabaseForServer();
828
+ const { data, error } = await supabase
829
+ .from(await $getTableName('User'))
830
+ .select('*')
831
+ .eq('username', username)
832
+ .maybeSingle();
833
+
834
+ if (error) {
835
+ throw new Error(`Failed to resolve Shibboleth user by username: ${error.message}`);
788
836
  }
789
837
 
790
- const record = value as Record<string, unknown>;
791
- const directValue = record[key];
792
- const directObjects = Array.isArray(directValue)
793
- ? directValue.filter((item): item is Record<string, unknown> => Boolean(item) && typeof item === 'object')
794
- : directValue && typeof directValue === 'object'
795
- ? [directValue as Record<string, unknown>]
796
- : [];
797
-
798
- return [
799
- ...directObjects,
800
- ...Object.entries(record)
801
- .filter(([childKey]) => childKey !== key)
802
- .flatMap(([, childValue]) =>
803
- Array.isArray(childValue)
804
- ? childValue.flatMap((item) => findAllObjectsByKey(item, key))
805
- : findAllObjectsByKey(childValue, key),
806
- ),
807
- ];
838
+ return (data as UserRowWithShibbolethColumns | null) || null;
808
839
  }
809
840
 
810
841
  /**
811
- * Normalizes a URL-like metadata value.
812
- *
813
- * @param value - Raw URL value.
814
- * @returns URL without trailing slash, or `null`.
842
+ * Inserts a new passwordless Shibboleth user.
815
843
  */
816
- function normalizeShibbolethUrl(value: string | null | undefined): string | null {
817
- const trimmedValue = value?.trim() || '';
818
- if (!trimmedValue) {
819
- return null;
844
+ async function insertShibbolethUser(
845
+ profileAttributes: ShibbolethProfileAttributes,
846
+ now: string,
847
+ ): Promise<UserRowWithShibbolethColumns> {
848
+ const supabase = $provideSupabaseForServer();
849
+ const userInsert: UserInsertWithShibbolethColumns = {
850
+ username: profileAttributes.email,
851
+ passwordHash: SHIBBOLETH_PASSWORDLESS_USER_PASSWORD_HASH,
852
+ isAdmin: false,
853
+ email: profileAttributes.email,
854
+ displayName: profileAttributes.displayName,
855
+ authenticationProvider: SHIBBOLETH_AUTHENTICATION_PROVIDER,
856
+ createdAt: now,
857
+ updatedAt: now,
858
+ };
859
+ const { data, error } = await supabase
860
+ .from(await $getTableName('User'))
861
+ .insert(userInsert)
862
+ .select('*')
863
+ .single();
864
+
865
+ if (error) {
866
+ throw new Error(`Failed to create Shibboleth user: ${error.message}`);
820
867
  }
821
868
 
822
- try {
823
- const url = new URL(trimmedValue);
824
- return url.toString().replace(/\/$/u, '');
825
- } catch {
826
- return null;
869
+ return data as UserRowWithShibbolethColumns;
870
+ }
871
+
872
+ /**
873
+ * Updates Shibboleth profile columns on an existing user row.
874
+ */
875
+ async function updateLinkedShibbolethUser(
876
+ user: UserRowWithShibbolethColumns,
877
+ profileAttributes: ShibbolethProfileAttributes,
878
+ now: string,
879
+ ): Promise<UserRowWithShibbolethColumns> {
880
+ const supabase = $provideSupabaseForServer();
881
+ const nextAuthenticationProvider =
882
+ user.authenticationProvider === LOCAL_AUTHENTICATION_PROVIDER || !user.authenticationProvider
883
+ ? LOCAL_AND_SHIBBOLETH_AUTHENTICATION_PROVIDER
884
+ : user.authenticationProvider;
885
+ const userUpdate: UserUpdateWithShibbolethColumns = {
886
+ email: profileAttributes.email,
887
+ displayName: profileAttributes.displayName,
888
+ authenticationProvider: nextAuthenticationProvider,
889
+ updatedAt: now,
890
+ };
891
+ const { data, error } = await supabase
892
+ .from(await $getTableName('User'))
893
+ .update(userUpdate)
894
+ .eq('id', user.id)
895
+ .select('*')
896
+ .single();
897
+
898
+ if (error) {
899
+ throw new Error(`Failed to update Shibboleth user: ${error.message}`);
827
900
  }
901
+
902
+ return data as UserRowWithShibbolethColumns;
828
903
  }
829
904
 
830
905
  /**
831
- * Removes expired SAML request IDs from the process-local cache.
906
+ * Creates or updates the Shibboleth identity link.
832
907
  */
833
- function pruneExpiredShibbolethRequestIds(): void {
834
- const now = Date.now();
908
+ async function upsertShibbolethIdentity(
909
+ user: UserRowWithShibbolethColumns,
910
+ profileAttributes: ShibbolethProfileAttributes,
911
+ existingIdentity: ShibbolethUserIdentityRow | null,
912
+ now: string,
913
+ ): Promise<void> {
914
+ const supabase = $provideSupabaseForServer();
915
+ const tableName = await getShibbolethUserIdentityTableName();
916
+ const identityPayload = {
917
+ userId: user.id,
918
+ email: profileAttributes.email,
919
+ displayName: profileAttributes.displayName,
920
+ nameId: profileAttributes.nameId,
921
+ nameIdFormat: profileAttributes.nameIdFormat,
922
+ unstructuredName: profileAttributes.unstructuredName,
923
+ eduPersonPrincipalName: profileAttributes.eduPersonPrincipalName,
924
+ rawAttributes: profileAttributes.rawAttributes,
925
+ lastLoggedInAt: now,
926
+ loginCount: Number(existingIdentity?.loginCount || 0) + 1,
927
+ updatedAt: now,
928
+ };
835
929
 
836
- for (const [key, item] of SHIBBOLETH_REQUEST_ID_CACHE.entries()) {
837
- if (now - item.createdAt > SHIBBOLETH_REQUEST_ID_EXPIRATION_PERIOD_MS) {
838
- SHIBBOLETH_REQUEST_ID_CACHE.delete(key);
930
+ if (existingIdentity) {
931
+ const { error } = await supabase
932
+ .from(tableName)
933
+ .update(identityPayload as never)
934
+ .eq('id' as never, existingIdentity.id as never);
935
+ if (error) {
936
+ throw new Error(`Failed to update Shibboleth identity: ${error.message}`);
839
937
  }
938
+ return;
939
+ }
940
+
941
+ const { error } = await supabase.from(tableName).insert({
942
+ ...identityPayload,
943
+ createdAt: now,
944
+ } as never);
945
+
946
+ if (error) {
947
+ throw new Error(`Failed to create Shibboleth identity: ${error.message}`);
840
948
  }
841
949
  }