@promptbook/cli 0.112.0-101 → 0.112.0-103
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/apps/agents-server/package.json +1 -1
- package/apps/agents-server/scripts/prerender-homepage.js +76 -1
- package/apps/agents-server/src/app/actions.ts +0 -6
- package/apps/agents-server/src/app/admin/about/page.tsx +1 -1
- package/apps/agents-server/src/app/admin/image-generator-test/ImageAttachmentsEditor.tsx +11 -6
- package/apps/agents-server/src/app/admin/login-methods/shibboleth/page.tsx +365 -0
- package/apps/agents-server/src/app/admin/metadata/MetadataClient.tsx +13 -15
- package/apps/agents-server/src/app/admin/servers/ServersRegistryTable.tsx +3 -3
- package/apps/agents-server/src/app/admin/servers/useCreateServerWizard.ts +13 -14
- package/apps/agents-server/src/app/admin/update/UpdateClient.tsx +12 -3
- package/apps/agents-server/src/app/admin/usage/UsageClientTimelineChart.tsx +1 -1
- package/apps/agents-server/src/app/admin/users/[userId]/UserDetailClient.tsx +21 -14
- package/apps/agents-server/src/app/agents/[agentName]/chat/AgentChatPageLayout.tsx +2 -2
- package/apps/agents-server/src/app/agents/[agentName]/chat/AgentChatSidebarDefault.tsx +11 -7
- package/apps/agents-server/src/app/api/admin/cli-access/route.ts +27 -123
- package/apps/agents-server/src/app/api/admin/code-runners/authentication/route.ts +33 -125
- package/apps/agents-server/src/app/api/auth/login/route.ts +0 -10
- package/apps/agents-server/src/app/api/auth/shibboleth/acs/route.ts +77 -57
- package/apps/agents-server/src/app/api/auth/shibboleth/login/route.ts +57 -33
- package/apps/agents-server/src/app/api/auth/shibboleth/metadata/route.ts +4 -29
- package/apps/agents-server/src/app/api/auth/shibboleth/status/route.ts +17 -0
- package/apps/agents-server/src/app/api/upload/route.ts +148 -209
- package/apps/agents-server/src/app/api/users/[username]/route.ts +1 -1
- package/apps/agents-server/src/app/api/users/route.ts +5 -5
- package/apps/agents-server/src/app/dashboard/page.tsx +1 -1
- package/apps/agents-server/src/app/docs/[docId]/page.tsx +1 -1
- package/apps/agents-server/src/app/docs/page.tsx +1 -1
- package/apps/agents-server/src/app/globals.css +100 -0
- package/apps/agents-server/src/app/layout.tsx +7 -0
- package/apps/agents-server/src/app/recycle-bin/page.tsx +1 -1
- package/apps/agents-server/src/app/system/settings/KeybindingsSettingsClient.tsx +13 -7
- package/apps/agents-server/src/components/AdminTerminal/useAdminTerminalSession.ts +29 -1
- package/apps/agents-server/src/components/AgentProfile/AgentProfile.tsx +3 -3
- package/apps/agents-server/src/components/AgentProfile/AgentProfileImage.tsx +8 -2
- package/apps/agents-server/src/components/DocsToolbar/DocsToolbar.tsx +4 -4
- package/apps/agents-server/src/components/DocumentationContent/DocumentationContent.tsx +9 -9
- package/apps/agents-server/src/components/Footer/Footer.tsx +7 -7
- package/apps/agents-server/src/components/Header/Header.tsx +24 -4
- package/apps/agents-server/src/components/Header/HeaderTypes.ts +6 -0
- package/apps/agents-server/src/components/Header/buildHeaderSystemMenuItems.ts +51 -1
- package/apps/agents-server/src/components/Homepage/Card.tsx +1 -1
- package/apps/agents-server/src/components/Homepage/Section.tsx +3 -1
- package/apps/agents-server/src/components/LayoutWrapper/LayoutWrapper.tsx +12 -1
- package/apps/agents-server/src/components/LoginForm/LoginForm.tsx +100 -149
- package/apps/agents-server/src/components/Skeleton/ConsolePageLoadingSkeleton.tsx +1 -1
- package/apps/agents-server/src/components/Skeleton/DocumentationRouteLoadingSkeleton.tsx +1 -1
- package/apps/agents-server/src/components/Skeleton/HomepageLoadingSkeleton.tsx +1 -1
- package/apps/agents-server/src/components/UsersList/UsersList.tsx +20 -4
- package/apps/agents-server/src/components/UsersList/useUsersAdmin.ts +3 -0
- package/apps/agents-server/src/constants/shibbolethAuth.ts +139 -0
- package/apps/agents-server/src/database/metadataDefaults.ts +54 -80
- package/apps/agents-server/src/database/migrate.ts +30 -1
- package/apps/agents-server/src/database/migrations/2026-06-0100-shibboleth-auth.sql +136 -0
- package/apps/agents-server/src/database/sqlite/$provideLocalSqliteSupabase.ts +88 -36
- package/apps/agents-server/src/languages/ServerTranslationKeys.ts +4 -2
- package/apps/agents-server/src/languages/translations/czech.yaml +4 -2
- package/apps/agents-server/src/languages/translations/english.yaml +5 -3
- package/apps/agents-server/src/tools/$provideCdnForServer.ts +54 -11
- package/apps/agents-server/src/utils/cdn/classes/DigitalOceanSpaces.ts +18 -2
- package/apps/agents-server/src/utils/cdn/classes/TrackedFilesStorage.ts +6 -5
- package/apps/agents-server/src/utils/cdn/interfaces/IFilesStorage.ts +5 -0
- package/apps/agents-server/src/utils/chatExport/renderHtmlToPdfOnServer.ts +11 -0
- package/apps/agents-server/src/utils/createAdminTerminalRouteHandlers.ts +264 -0
- package/apps/agents-server/src/utils/shareTargetPayloads.ts +19 -66
- package/apps/agents-server/src/utils/shibbolethAuthentication.ts +729 -621
- package/apps/agents-server/src/utils/upload/createBookEditorUploadHandler.ts +19 -28
- package/apps/agents-server/src/utils/upload/uploadFileToServer.ts +113 -0
- package/esm/index.es.js +194 -35
- package/esm/index.es.js.map +1 -1
- package/esm/scripts/run-codex-prompts/common/waitForPause.d.ts +12 -0
- package/esm/scripts/run-codex-prompts/main/runPromptRound.d.ts +2 -1
- package/esm/scripts/run-codex-prompts/ui/buildCoderRunUiFrame.d.ts +1 -0
- package/esm/scripts/run-codex-prompts/ui/buildRunUiFrameShared.d.ts +1 -1
- package/esm/src/book-components/Chat/MarkdownContent/MarkdownContent.d.ts +1 -0
- package/esm/src/version.d.ts +1 -1
- package/package.json +2 -2
- package/src/book-components/Chat/MarkdownContent/MarkdownContent.tsx +63 -4
- package/src/other/templates/getTemplatesPipelineCollection.ts +730 -739
- package/src/version.ts +2 -2
- package/src/versions.txt +2 -0
- package/umd/index.umd.js +194 -35
- package/umd/index.umd.js.map +1 -1
- package/umd/scripts/run-codex-prompts/common/waitForPause.d.ts +12 -0
- package/umd/scripts/run-codex-prompts/main/runPromptRound.d.ts +2 -1
- package/umd/scripts/run-codex-prompts/ui/buildCoderRunUiFrame.d.ts +1 -0
- package/umd/scripts/run-codex-prompts/ui/buildRunUiFrameShared.d.ts +1 -1
- package/umd/src/book-components/Chat/MarkdownContent/MarkdownContent.d.ts +1 -0
- package/umd/src/version.d.ts +1 -1
- package/apps/agents-server/src/app/api/auth/methods/route.ts +0 -44
- package/apps/agents-server/src/constants/authenticationMethods.ts +0 -74
- package/apps/agents-server/src/constants/shibbolethAuthentication.ts +0 -107
|
@@ -1,841 +1,949 @@
|
|
|
1
|
-
import {
|
|
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
|
|
33
|
-
import {
|
|
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
|
-
*
|
|
24
|
+
* Persistent NameID format recommended for Shibboleth integrations.
|
|
37
25
|
*/
|
|
38
|
-
const
|
|
26
|
+
const SHIBBOLETH_PERSISTENT_NAME_ID_FORMAT = 'urn:oasis:names:tc:SAML:2.0:nameid-format:persistent';
|
|
39
27
|
|
|
40
28
|
/**
|
|
41
|
-
*
|
|
29
|
+
* SAML HTTP Redirect binding URL.
|
|
42
30
|
*/
|
|
43
|
-
const
|
|
31
|
+
const SAML_HTTP_REDIRECT_BINDING = 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect';
|
|
44
32
|
|
|
45
33
|
/**
|
|
46
|
-
*
|
|
34
|
+
* Timeout for loading remote Identity Provider metadata.
|
|
47
35
|
*/
|
|
48
|
-
const
|
|
36
|
+
const SHIBBOLETH_IDENTITY_PROVIDER_METADATA_TIMEOUT_MS = 10_000;
|
|
49
37
|
|
|
50
38
|
/**
|
|
51
|
-
*
|
|
39
|
+
* Clock skew accepted when validating SAML assertion timestamps.
|
|
52
40
|
*/
|
|
53
|
-
const
|
|
41
|
+
const SHIBBOLETH_ACCEPTED_CLOCK_SKEW_MS = 120_000;
|
|
54
42
|
|
|
55
43
|
/**
|
|
56
|
-
*
|
|
44
|
+
* Placeholder password hash for database users that can only sign in via Shibboleth.
|
|
57
45
|
*/
|
|
58
|
-
const
|
|
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
|
-
*
|
|
49
|
+
* Marker stored on database users that authenticate with username/password only.
|
|
67
50
|
*/
|
|
68
|
-
const
|
|
51
|
+
const LOCAL_AUTHENTICATION_PROVIDER = 'LOCAL';
|
|
69
52
|
|
|
70
53
|
/**
|
|
71
|
-
*
|
|
72
|
-
* can validate the same AuthnRequest IDs.
|
|
54
|
+
* Marker stored on database users that authenticate with Shibboleth only.
|
|
73
55
|
*/
|
|
74
|
-
const
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
*
|
|
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
|
-
* @
|
|
135
|
+
* @private internal Shibboleth authentication record
|
|
100
136
|
*/
|
|
101
|
-
export type
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
*
|
|
143
|
+
* Parsed Shibboleth user attributes extracted from SAML profile data.
|
|
118
144
|
*
|
|
119
|
-
* @
|
|
120
|
-
*/
|
|
121
|
-
export type
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
readonly
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
*
|
|
158
|
+
* Service Provider URLs derived for the current request.
|
|
166
159
|
*
|
|
167
|
-
* @
|
|
168
|
-
*/
|
|
169
|
-
export type
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
readonly
|
|
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
|
-
*
|
|
170
|
+
* Parsed Identity Provider metadata needed by Node-SAML.
|
|
198
171
|
*
|
|
199
|
-
* @
|
|
172
|
+
* @private internal Shibboleth authentication configuration
|
|
200
173
|
*/
|
|
201
|
-
export type
|
|
174
|
+
export type ShibbolethIdentityProviderMetadata = {
|
|
175
|
+
readonly singleSignOnServiceUrl: string;
|
|
176
|
+
readonly signingCertificates: ReadonlyArray<string>;
|
|
177
|
+
};
|
|
202
178
|
|
|
203
179
|
/**
|
|
204
|
-
*
|
|
180
|
+
* Resolved Shibboleth authentication configuration status.
|
|
205
181
|
*
|
|
206
|
-
* @
|
|
207
|
-
*/
|
|
208
|
-
export type
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
readonly
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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
|
-
*
|
|
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
|
-
* @
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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
|
-
*
|
|
262
|
+
* Extracts audit metadata from an incoming request.
|
|
254
263
|
*
|
|
255
|
-
* @param request - Incoming request
|
|
256
|
-
* @returns
|
|
264
|
+
* @param request - Incoming route-handler request.
|
|
265
|
+
* @returns Request details safe to store in the Shibboleth audit log.
|
|
257
266
|
*
|
|
258
|
-
* @
|
|
259
|
-
*/
|
|
260
|
-
export
|
|
261
|
-
const
|
|
262
|
-
|
|
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
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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
|
-
|
|
307
|
-
|
|
308
|
-
!callbackUrl ||
|
|
309
|
-
!entryPoint ||
|
|
310
|
-
resolvedIdpCertificates.length === 0
|
|
307
|
+
options.isIdentityProviderMetadataValidationEnabled === true &&
|
|
308
|
+
(identityProviderMetadataUrl || identityProviderMetadataXml)
|
|
311
309
|
) {
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
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
|
-
|
|
324
|
-
isConfigured:
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
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
|
-
*
|
|
333
|
+
* Creates a Node-SAML client for the resolved Shibboleth configuration.
|
|
338
334
|
*
|
|
339
|
-
* @param 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
|
-
* @
|
|
338
|
+
* @private internal Shibboleth authentication helper
|
|
343
339
|
*/
|
|
344
|
-
export function createShibbolethSamlClient(configuration:
|
|
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
|
-
|
|
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
|
|
362
|
+
* Creates Service Provider metadata XML for the current Agents Server deployment.
|
|
366
363
|
*
|
|
367
|
-
* @param
|
|
368
|
-
* @returns
|
|
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
|
-
* @
|
|
367
|
+
* @private internal Shibboleth authentication helper
|
|
371
368
|
*/
|
|
372
|
-
export function
|
|
373
|
-
|
|
374
|
-
|
|
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
|
-
|
|
377
|
-
|
|
378
|
-
|
|
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
|
-
*
|
|
382
|
+
* Records one Shibboleth authentication attempt.
|
|
385
383
|
*
|
|
386
|
-
* @param
|
|
387
|
-
* @returns Safe local redirect path.
|
|
384
|
+
* @param options - Attempt details.
|
|
388
385
|
*
|
|
389
|
-
* @
|
|
386
|
+
* @private internal Shibboleth authentication audit helper
|
|
390
387
|
*/
|
|
391
|
-
export function
|
|
392
|
-
|
|
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
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
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
|
-
*
|
|
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
|
-
* @
|
|
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
|
-
* @
|
|
422
|
+
* @private internal Shibboleth authentication user helper
|
|
414
423
|
*/
|
|
415
|
-
export async function
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
const
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
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
|
-
|
|
432
|
-
|
|
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 (
|
|
444
|
-
|
|
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 (!
|
|
452
|
-
|
|
441
|
+
if (!user) {
|
|
442
|
+
user = await insertShibbolethUser(profileAttributes, now);
|
|
443
|
+
} else {
|
|
444
|
+
user = await updateLinkedShibbolethUser(user, profileAttributes, now);
|
|
453
445
|
}
|
|
454
446
|
|
|
455
|
-
|
|
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
|
-
|
|
474
|
-
|
|
475
|
-
isNewUser: true,
|
|
450
|
+
user,
|
|
451
|
+
profileAttributes,
|
|
476
452
|
};
|
|
477
453
|
}
|
|
478
454
|
|
|
479
455
|
/**
|
|
480
|
-
*
|
|
456
|
+
* Resolves the prefixed Shibboleth identity table name without relying on generated schema typings.
|
|
481
457
|
*
|
|
482
|
-
* @
|
|
483
|
-
* @param details - Safe structured details.
|
|
458
|
+
* @returns Prefixed table name.
|
|
484
459
|
*
|
|
485
|
-
* @
|
|
460
|
+
* @private internal Shibboleth authentication table helper
|
|
486
461
|
*/
|
|
487
|
-
export function
|
|
488
|
-
|
|
489
|
-
|
|
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
|
-
*
|
|
468
|
+
* Resolves the prefixed Shibboleth authentication attempt table name without relying on generated schema typings.
|
|
496
469
|
*
|
|
497
|
-
* @
|
|
498
|
-
* @param usernameAttribute - Configured username attribute.
|
|
499
|
-
* @returns Safe diagnostic payload.
|
|
470
|
+
* @returns Prefixed table name.
|
|
500
471
|
*
|
|
501
|
-
* @
|
|
472
|
+
* @private internal Shibboleth authentication table helper
|
|
502
473
|
*/
|
|
503
|
-
export function
|
|
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
|
-
|
|
506
|
-
):
|
|
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
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
526
|
-
|
|
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
|
-
|
|
530
|
-
|
|
531
|
-
|
|
544
|
+
origin,
|
|
545
|
+
entityId: configuredEntityId || metadataUrl,
|
|
546
|
+
assertionConsumerServiceUrl,
|
|
547
|
+
metadataUrl,
|
|
532
548
|
};
|
|
533
549
|
}
|
|
534
550
|
|
|
535
551
|
/**
|
|
536
|
-
*
|
|
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
|
-
|
|
544
|
-
const
|
|
545
|
-
|
|
546
|
-
|
|
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
|
-
|
|
550
|
-
|
|
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
|
-
|
|
554
|
-
|
|
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
|
-
|
|
559
|
-
|
|
589
|
+
return {
|
|
590
|
+
singleSignOnServiceUrl,
|
|
591
|
+
signingCertificates,
|
|
592
|
+
};
|
|
560
593
|
}
|
|
561
594
|
|
|
562
595
|
/**
|
|
563
|
-
*
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
*
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
)
|
|
571
|
-
|
|
572
|
-
|
|
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
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
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
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
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
|
-
*
|
|
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
|
|
618
|
-
const
|
|
619
|
-
|
|
620
|
-
|
|
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
|
-
|
|
624
|
-
|
|
625
|
-
return pemMatches.map(formatShibbolethCertificate);
|
|
626
|
-
}
|
|
659
|
+
return null;
|
|
660
|
+
}
|
|
627
661
|
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
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
|
-
*
|
|
636
|
-
*
|
|
637
|
-
* @param certificate - Raw certificate value.
|
|
638
|
-
* @returns PEM certificate.
|
|
673
|
+
* Converts a profile value into one string.
|
|
639
674
|
*/
|
|
640
|
-
function
|
|
641
|
-
|
|
642
|
-
|
|
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
|
-
|
|
647
|
-
|
|
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
|
|
689
|
+
return null;
|
|
650
690
|
}
|
|
651
691
|
|
|
652
692
|
/**
|
|
653
|
-
*
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
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
|
|
716
|
+
return sanitizedAttributes;
|
|
675
717
|
}
|
|
676
718
|
|
|
677
719
|
/**
|
|
678
|
-
*
|
|
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
|
|
685
|
-
|
|
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
|
-
|
|
693
|
-
|
|
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
|
|
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
|
|
707
|
-
if (!
|
|
748
|
+
async function findShibbolethIdentityByNameId(nameId: string | null): Promise<ShibbolethUserIdentityRow | null> {
|
|
749
|
+
if (!nameId) {
|
|
708
750
|
return null;
|
|
709
751
|
}
|
|
710
752
|
|
|
711
|
-
const
|
|
712
|
-
const
|
|
713
|
-
|
|
714
|
-
|
|
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
|
-
|
|
718
|
-
|
|
719
|
-
? childValue.map((item) => findFirstAttribute(item, attributeName)).find(Boolean)
|
|
720
|
-
: findFirstAttribute(childValue, attributeName);
|
|
765
|
+
return (data as ShibbolethUserIdentityRow | null) || null;
|
|
766
|
+
}
|
|
721
767
|
|
|
722
|
-
|
|
723
|
-
|
|
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
|
|
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
|
|
738
|
-
|
|
739
|
-
|
|
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
|
-
|
|
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
|
|
765
|
-
*
|
|
766
|
-
* @param value - Parsed XML subtree.
|
|
767
|
-
* @returns Preferred SSO location.
|
|
806
|
+
* Finds one user row by email.
|
|
768
807
|
*/
|
|
769
|
-
function
|
|
770
|
-
const
|
|
771
|
-
const
|
|
772
|
-
|
|
773
|
-
|
|
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
|
|
820
|
+
return (data as UserRowWithShibbolethColumns | null) || null;
|
|
776
821
|
}
|
|
777
822
|
|
|
778
823
|
/**
|
|
779
|
-
* Finds
|
|
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
|
|
786
|
-
|
|
787
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
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
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
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
|
-
*
|
|
906
|
+
* Creates or updates the Shibboleth identity link.
|
|
832
907
|
*/
|
|
833
|
-
function
|
|
834
|
-
|
|
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
|
-
|
|
837
|
-
|
|
838
|
-
|
|
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
|
}
|