@hammadj/better-auth-sso 1.5.0-beta.9
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/.turbo/turbo-build.log +116 -0
- package/LICENSE.md +20 -0
- package/dist/client.d.mts +10 -0
- package/dist/client.mjs +15 -0
- package/dist/client.mjs.map +1 -0
- package/dist/index.d.mts +738 -0
- package/dist/index.mjs +2953 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +87 -0
- package/src/client.ts +29 -0
- package/src/constants.ts +58 -0
- package/src/domain-verification.test.ts +551 -0
- package/src/index.ts +265 -0
- package/src/linking/index.ts +2 -0
- package/src/linking/org-assignment.test.ts +325 -0
- package/src/linking/org-assignment.ts +176 -0
- package/src/linking/types.ts +10 -0
- package/src/oidc/discovery.test.ts +1157 -0
- package/src/oidc/discovery.ts +494 -0
- package/src/oidc/errors.ts +92 -0
- package/src/oidc/index.ts +31 -0
- package/src/oidc/types.ts +219 -0
- package/src/oidc.test.ts +688 -0
- package/src/providers.test.ts +1326 -0
- package/src/routes/domain-verification.ts +275 -0
- package/src/routes/providers.ts +565 -0
- package/src/routes/schemas.ts +96 -0
- package/src/routes/sso.ts +2750 -0
- package/src/saml/algorithms.test.ts +449 -0
- package/src/saml/algorithms.ts +338 -0
- package/src/saml/assertions.test.ts +239 -0
- package/src/saml/assertions.ts +62 -0
- package/src/saml/index.ts +13 -0
- package/src/saml/parser.ts +56 -0
- package/src/saml-state.ts +78 -0
- package/src/saml.test.ts +4319 -0
- package/src/types.ts +365 -0
- package/src/utils.test.ts +103 -0
- package/src/utils.ts +81 -0
- package/tsconfig.json +14 -0
- package/tsdown.config.ts +9 -0
- package/vitest.config.ts +3 -0
|
@@ -0,0 +1,2750 @@
|
|
|
1
|
+
import { BetterFetchError, betterFetch } from "@better-fetch/fetch";
|
|
2
|
+
import type { User, Verification } from "better-auth";
|
|
3
|
+
import {
|
|
4
|
+
createAuthorizationURL,
|
|
5
|
+
generateState,
|
|
6
|
+
HIDE_METADATA,
|
|
7
|
+
parseState,
|
|
8
|
+
validateAuthorizationCode,
|
|
9
|
+
validateToken,
|
|
10
|
+
} from "better-auth";
|
|
11
|
+
import {
|
|
12
|
+
APIError,
|
|
13
|
+
createAuthEndpoint,
|
|
14
|
+
getSessionFromCtx,
|
|
15
|
+
sessionMiddleware,
|
|
16
|
+
} from "better-auth/api";
|
|
17
|
+
import { setSessionCookie } from "better-auth/cookies";
|
|
18
|
+
import { generateRandomString } from "better-auth/crypto";
|
|
19
|
+
import { handleOAuthUserInfo } from "better-auth/oauth2";
|
|
20
|
+
import { XMLParser } from "fast-xml-parser";
|
|
21
|
+
import { decodeJwt } from "jose";
|
|
22
|
+
import * as saml from "samlify";
|
|
23
|
+
import type { BindingContext } from "samlify/types/src/entity";
|
|
24
|
+
import type { IdentityProvider } from "samlify/types/src/entity-idp";
|
|
25
|
+
import type { FlowResult } from "samlify/types/src/flow";
|
|
26
|
+
import z from "zod/v4";
|
|
27
|
+
|
|
28
|
+
interface AuthnRequestRecord {
|
|
29
|
+
id: string;
|
|
30
|
+
providerId: string;
|
|
31
|
+
createdAt: number;
|
|
32
|
+
expiresAt: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
import {
|
|
36
|
+
AUTHN_REQUEST_KEY_PREFIX,
|
|
37
|
+
DEFAULT_ASSERTION_TTL_MS,
|
|
38
|
+
DEFAULT_AUTHN_REQUEST_TTL_MS,
|
|
39
|
+
DEFAULT_CLOCK_SKEW_MS,
|
|
40
|
+
DEFAULT_MAX_SAML_METADATA_SIZE,
|
|
41
|
+
DEFAULT_MAX_SAML_RESPONSE_SIZE,
|
|
42
|
+
USED_ASSERTION_KEY_PREFIX,
|
|
43
|
+
} from "../constants";
|
|
44
|
+
import { assignOrganizationFromProvider } from "../linking";
|
|
45
|
+
import type { HydratedOIDCConfig } from "../oidc";
|
|
46
|
+
import {
|
|
47
|
+
DiscoveryError,
|
|
48
|
+
discoverOIDCConfig,
|
|
49
|
+
mapDiscoveryErrorToAPIError,
|
|
50
|
+
} from "../oidc";
|
|
51
|
+
import {
|
|
52
|
+
validateConfigAlgorithms,
|
|
53
|
+
validateSAMLAlgorithms,
|
|
54
|
+
validateSingleAssertion,
|
|
55
|
+
} from "../saml";
|
|
56
|
+
import { generateRelayState, parseRelayState } from "../saml-state";
|
|
57
|
+
import type { OIDCConfig, SAMLConfig, SSOOptions, SSOProvider } from "../types";
|
|
58
|
+
import { domainMatches, safeJsonParse, validateEmailDomain } from "../utils";
|
|
59
|
+
|
|
60
|
+
export interface TimestampValidationOptions {
|
|
61
|
+
clockSkew?: number;
|
|
62
|
+
requireTimestamps?: boolean;
|
|
63
|
+
logger?: {
|
|
64
|
+
warn: (message: string, data?: Record<string, unknown>) => void;
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Conditions extracted from SAML assertion */
|
|
69
|
+
export interface SAMLConditions {
|
|
70
|
+
notBefore?: string;
|
|
71
|
+
notOnOrAfter?: string;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Validates SAML assertion timestamp conditions (NotBefore/NotOnOrAfter).
|
|
76
|
+
* Prevents acceptance of expired or future-dated assertions.
|
|
77
|
+
* @throws {APIError} If timestamps are invalid, expired, or not yet valid
|
|
78
|
+
*/
|
|
79
|
+
export function validateSAMLTimestamp(
|
|
80
|
+
conditions: SAMLConditions | undefined,
|
|
81
|
+
options: TimestampValidationOptions = {},
|
|
82
|
+
): void {
|
|
83
|
+
const clockSkew = options.clockSkew ?? DEFAULT_CLOCK_SKEW_MS;
|
|
84
|
+
const hasTimestamps = conditions?.notBefore || conditions?.notOnOrAfter;
|
|
85
|
+
|
|
86
|
+
if (!hasTimestamps) {
|
|
87
|
+
if (options.requireTimestamps) {
|
|
88
|
+
throw new APIError("BAD_REQUEST", {
|
|
89
|
+
message: "SAML assertion missing required timestamp conditions",
|
|
90
|
+
details:
|
|
91
|
+
"Assertions must include NotBefore and/or NotOnOrAfter conditions",
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
// Log warning for missing timestamps when not required
|
|
95
|
+
options.logger?.warn(
|
|
96
|
+
"SAML assertion accepted without timestamp conditions",
|
|
97
|
+
{ hasConditions: !!conditions },
|
|
98
|
+
);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const now = Date.now();
|
|
103
|
+
|
|
104
|
+
if (conditions?.notBefore) {
|
|
105
|
+
const notBeforeTime = new Date(conditions.notBefore).getTime();
|
|
106
|
+
if (Number.isNaN(notBeforeTime)) {
|
|
107
|
+
throw new APIError("BAD_REQUEST", {
|
|
108
|
+
message: "SAML assertion has invalid NotBefore timestamp",
|
|
109
|
+
details: `Unable to parse NotBefore value: ${conditions.notBefore}`,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
if (now < notBeforeTime - clockSkew) {
|
|
113
|
+
throw new APIError("BAD_REQUEST", {
|
|
114
|
+
message: "SAML assertion is not yet valid",
|
|
115
|
+
details: `Current time is before NotBefore (with ${clockSkew}ms clock skew tolerance)`,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (conditions?.notOnOrAfter) {
|
|
121
|
+
const notOnOrAfterTime = new Date(conditions.notOnOrAfter).getTime();
|
|
122
|
+
if (Number.isNaN(notOnOrAfterTime)) {
|
|
123
|
+
throw new APIError("BAD_REQUEST", {
|
|
124
|
+
message: "SAML assertion has invalid NotOnOrAfter timestamp",
|
|
125
|
+
details: `Unable to parse NotOnOrAfter value: ${conditions.notOnOrAfter}`,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
if (now > notOnOrAfterTime + clockSkew) {
|
|
129
|
+
throw new APIError("BAD_REQUEST", {
|
|
130
|
+
message: "SAML assertion has expired",
|
|
131
|
+
details: `Current time is after NotOnOrAfter (with ${clockSkew}ms clock skew tolerance)`,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Extracts the Assertion ID from a SAML response XML.
|
|
139
|
+
* Returns null if the assertion ID cannot be found.
|
|
140
|
+
*/
|
|
141
|
+
function extractAssertionId(samlContent: string): string | null {
|
|
142
|
+
try {
|
|
143
|
+
const parser = new XMLParser({
|
|
144
|
+
ignoreAttributes: false,
|
|
145
|
+
attributeNamePrefix: "@_",
|
|
146
|
+
removeNSPrefix: true,
|
|
147
|
+
});
|
|
148
|
+
const parsed = parser.parse(samlContent);
|
|
149
|
+
|
|
150
|
+
const response = parsed.Response || parsed["samlp:Response"];
|
|
151
|
+
if (!response) return null;
|
|
152
|
+
|
|
153
|
+
const rawAssertion = response.Assertion || response["saml:Assertion"];
|
|
154
|
+
const assertion = Array.isArray(rawAssertion)
|
|
155
|
+
? rawAssertion[0]
|
|
156
|
+
: rawAssertion;
|
|
157
|
+
if (!assertion) return null;
|
|
158
|
+
|
|
159
|
+
return assertion["@_ID"] || null;
|
|
160
|
+
} catch {
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const spMetadataQuerySchema = z.object({
|
|
166
|
+
providerId: z.string(),
|
|
167
|
+
format: z.enum(["xml", "json"]).default("xml"),
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
type RelayState = Awaited<ReturnType<typeof parseRelayState>>;
|
|
171
|
+
|
|
172
|
+
export const spMetadata = () => {
|
|
173
|
+
return createAuthEndpoint(
|
|
174
|
+
"/sso/saml2/sp/metadata",
|
|
175
|
+
{
|
|
176
|
+
method: "GET",
|
|
177
|
+
query: spMetadataQuerySchema,
|
|
178
|
+
metadata: {
|
|
179
|
+
openapi: {
|
|
180
|
+
operationId: "getSSOServiceProviderMetadata",
|
|
181
|
+
summary: "Get Service Provider metadata",
|
|
182
|
+
description: "Returns the SAML metadata for the Service Provider",
|
|
183
|
+
responses: {
|
|
184
|
+
"200": {
|
|
185
|
+
description: "SAML metadata in XML format",
|
|
186
|
+
},
|
|
187
|
+
},
|
|
188
|
+
},
|
|
189
|
+
},
|
|
190
|
+
},
|
|
191
|
+
async (ctx) => {
|
|
192
|
+
const provider = await ctx.context.adapter.findOne<{
|
|
193
|
+
id: string;
|
|
194
|
+
samlConfig: string;
|
|
195
|
+
}>({
|
|
196
|
+
model: "ssoProvider",
|
|
197
|
+
where: [
|
|
198
|
+
{
|
|
199
|
+
field: "providerId",
|
|
200
|
+
value: ctx.query.providerId,
|
|
201
|
+
},
|
|
202
|
+
],
|
|
203
|
+
});
|
|
204
|
+
if (!provider) {
|
|
205
|
+
throw new APIError("NOT_FOUND", {
|
|
206
|
+
message: "No provider found for the given providerId",
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const parsedSamlConfig = safeJsonParse<SAMLConfig>(provider.samlConfig);
|
|
211
|
+
if (!parsedSamlConfig) {
|
|
212
|
+
throw new APIError("BAD_REQUEST", {
|
|
213
|
+
message: "Invalid SAML configuration",
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
const sp = parsedSamlConfig.spMetadata.metadata
|
|
217
|
+
? saml.ServiceProvider({
|
|
218
|
+
metadata: parsedSamlConfig.spMetadata.metadata,
|
|
219
|
+
})
|
|
220
|
+
: saml.SPMetadata({
|
|
221
|
+
entityID:
|
|
222
|
+
parsedSamlConfig.spMetadata?.entityID || parsedSamlConfig.issuer,
|
|
223
|
+
assertionConsumerService: [
|
|
224
|
+
{
|
|
225
|
+
Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
|
|
226
|
+
Location:
|
|
227
|
+
parsedSamlConfig.callbackUrl ||
|
|
228
|
+
`${ctx.context.baseURL}/sso/saml2/sp/acs/${provider.id}`,
|
|
229
|
+
},
|
|
230
|
+
],
|
|
231
|
+
wantMessageSigned: parsedSamlConfig.wantAssertionsSigned || false,
|
|
232
|
+
authnRequestsSigned: parsedSamlConfig.authnRequestsSigned || false,
|
|
233
|
+
nameIDFormat: parsedSamlConfig.identifierFormat
|
|
234
|
+
? [parsedSamlConfig.identifierFormat]
|
|
235
|
+
: undefined,
|
|
236
|
+
});
|
|
237
|
+
return new Response(sp.getMetadata(), {
|
|
238
|
+
headers: {
|
|
239
|
+
"Content-Type": "application/xml",
|
|
240
|
+
},
|
|
241
|
+
});
|
|
242
|
+
},
|
|
243
|
+
);
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
const ssoProviderBodySchema = z.object({
|
|
247
|
+
providerId: z.string({}).meta({
|
|
248
|
+
description:
|
|
249
|
+
"The ID of the provider. This is used to identify the provider during login and callback",
|
|
250
|
+
}),
|
|
251
|
+
issuer: z.string({}).meta({
|
|
252
|
+
description: "The issuer of the provider",
|
|
253
|
+
}),
|
|
254
|
+
domain: z.string({}).meta({
|
|
255
|
+
description:
|
|
256
|
+
"The domain(s) of the provider. For enterprise multi-domain SSO where a single IdP serves multiple email domains, use comma-separated values (e.g., 'company.com,subsidiary.com,acquired-company.com')",
|
|
257
|
+
}),
|
|
258
|
+
oidcConfig: z
|
|
259
|
+
.object({
|
|
260
|
+
clientId: z.string({}).meta({
|
|
261
|
+
description: "The client ID",
|
|
262
|
+
}),
|
|
263
|
+
clientSecret: z.string({}).meta({
|
|
264
|
+
description: "The client secret",
|
|
265
|
+
}),
|
|
266
|
+
authorizationEndpoint: z
|
|
267
|
+
.string({})
|
|
268
|
+
.meta({
|
|
269
|
+
description: "The authorization endpoint",
|
|
270
|
+
})
|
|
271
|
+
.optional(),
|
|
272
|
+
tokenEndpoint: z
|
|
273
|
+
.string({})
|
|
274
|
+
.meta({
|
|
275
|
+
description: "The token endpoint",
|
|
276
|
+
})
|
|
277
|
+
.optional(),
|
|
278
|
+
userInfoEndpoint: z
|
|
279
|
+
.string({})
|
|
280
|
+
.meta({
|
|
281
|
+
description: "The user info endpoint",
|
|
282
|
+
})
|
|
283
|
+
.optional(),
|
|
284
|
+
tokenEndpointAuthentication: z
|
|
285
|
+
.enum(["client_secret_post", "client_secret_basic"])
|
|
286
|
+
.optional(),
|
|
287
|
+
jwksEndpoint: z
|
|
288
|
+
.string({})
|
|
289
|
+
.meta({
|
|
290
|
+
description: "The JWKS endpoint",
|
|
291
|
+
})
|
|
292
|
+
.optional(),
|
|
293
|
+
discoveryEndpoint: z.string().optional(),
|
|
294
|
+
skipDiscovery: z
|
|
295
|
+
.boolean()
|
|
296
|
+
.meta({
|
|
297
|
+
description:
|
|
298
|
+
"Skip OIDC discovery during registration. When true, you must provide authorizationEndpoint, tokenEndpoint, and jwksEndpoint manually.",
|
|
299
|
+
})
|
|
300
|
+
.optional(),
|
|
301
|
+
scopes: z
|
|
302
|
+
.array(z.string(), {})
|
|
303
|
+
.meta({
|
|
304
|
+
description:
|
|
305
|
+
"The scopes to request. Defaults to ['openid', 'email', 'profile', 'offline_access']",
|
|
306
|
+
})
|
|
307
|
+
.optional(),
|
|
308
|
+
pkce: z
|
|
309
|
+
.boolean({})
|
|
310
|
+
.meta({
|
|
311
|
+
description: "Whether to use PKCE for the authorization flow",
|
|
312
|
+
})
|
|
313
|
+
.default(true)
|
|
314
|
+
.optional(),
|
|
315
|
+
mapping: z
|
|
316
|
+
.object({
|
|
317
|
+
id: z.string({}).meta({
|
|
318
|
+
description: "Field mapping for user ID (defaults to 'sub')",
|
|
319
|
+
}),
|
|
320
|
+
email: z.string({}).meta({
|
|
321
|
+
description: "Field mapping for email (defaults to 'email')",
|
|
322
|
+
}),
|
|
323
|
+
emailVerified: z
|
|
324
|
+
.string({})
|
|
325
|
+
.meta({
|
|
326
|
+
description:
|
|
327
|
+
"Field mapping for email verification (defaults to 'email_verified')",
|
|
328
|
+
})
|
|
329
|
+
.optional(),
|
|
330
|
+
name: z.string({}).meta({
|
|
331
|
+
description: "Field mapping for name (defaults to 'name')",
|
|
332
|
+
}),
|
|
333
|
+
image: z
|
|
334
|
+
.string({})
|
|
335
|
+
.meta({
|
|
336
|
+
description: "Field mapping for image (defaults to 'picture')",
|
|
337
|
+
})
|
|
338
|
+
.optional(),
|
|
339
|
+
extraFields: z.record(z.string(), z.any()).optional(),
|
|
340
|
+
})
|
|
341
|
+
.optional(),
|
|
342
|
+
})
|
|
343
|
+
.optional(),
|
|
344
|
+
samlConfig: z
|
|
345
|
+
.object({
|
|
346
|
+
entryPoint: z.string({}).meta({
|
|
347
|
+
description: "The entry point of the provider",
|
|
348
|
+
}),
|
|
349
|
+
cert: z.string({}).meta({
|
|
350
|
+
description: "The certificate of the provider",
|
|
351
|
+
}),
|
|
352
|
+
callbackUrl: z.string({}).meta({
|
|
353
|
+
description: "The callback URL of the provider",
|
|
354
|
+
}),
|
|
355
|
+
audience: z.string().optional(),
|
|
356
|
+
idpMetadata: z
|
|
357
|
+
.object({
|
|
358
|
+
metadata: z.string().optional(),
|
|
359
|
+
entityID: z.string().optional(),
|
|
360
|
+
cert: z.string().optional(),
|
|
361
|
+
privateKey: z.string().optional(),
|
|
362
|
+
privateKeyPass: z.string().optional(),
|
|
363
|
+
isAssertionEncrypted: z.boolean().optional(),
|
|
364
|
+
encPrivateKey: z.string().optional(),
|
|
365
|
+
encPrivateKeyPass: z.string().optional(),
|
|
366
|
+
singleSignOnService: z
|
|
367
|
+
.array(
|
|
368
|
+
z.object({
|
|
369
|
+
Binding: z.string().meta({
|
|
370
|
+
description: "The binding type for the SSO service",
|
|
371
|
+
}),
|
|
372
|
+
Location: z.string().meta({
|
|
373
|
+
description: "The URL for the SSO service",
|
|
374
|
+
}),
|
|
375
|
+
}),
|
|
376
|
+
)
|
|
377
|
+
.optional()
|
|
378
|
+
.meta({
|
|
379
|
+
description: "Single Sign-On service configuration",
|
|
380
|
+
}),
|
|
381
|
+
})
|
|
382
|
+
.optional(),
|
|
383
|
+
spMetadata: z.object({
|
|
384
|
+
metadata: z.string().optional(),
|
|
385
|
+
entityID: z.string().optional(),
|
|
386
|
+
binding: z.string().optional(),
|
|
387
|
+
privateKey: z.string().optional(),
|
|
388
|
+
privateKeyPass: z.string().optional(),
|
|
389
|
+
isAssertionEncrypted: z.boolean().optional(),
|
|
390
|
+
encPrivateKey: z.string().optional(),
|
|
391
|
+
encPrivateKeyPass: z.string().optional(),
|
|
392
|
+
}),
|
|
393
|
+
wantAssertionsSigned: z.boolean().optional(),
|
|
394
|
+
authnRequestsSigned: z.boolean().optional(),
|
|
395
|
+
signatureAlgorithm: z.string().optional(),
|
|
396
|
+
digestAlgorithm: z.string().optional(),
|
|
397
|
+
identifierFormat: z.string().optional(),
|
|
398
|
+
privateKey: z.string().optional(),
|
|
399
|
+
decryptionPvk: z.string().optional(),
|
|
400
|
+
additionalParams: z.record(z.string(), z.any()).optional(),
|
|
401
|
+
mapping: z
|
|
402
|
+
.object({
|
|
403
|
+
id: z.string({}).meta({
|
|
404
|
+
description: "Field mapping for user ID (defaults to 'nameID')",
|
|
405
|
+
}),
|
|
406
|
+
email: z.string({}).meta({
|
|
407
|
+
description: "Field mapping for email (defaults to 'email')",
|
|
408
|
+
}),
|
|
409
|
+
emailVerified: z
|
|
410
|
+
.string({})
|
|
411
|
+
.meta({
|
|
412
|
+
description: "Field mapping for email verification",
|
|
413
|
+
})
|
|
414
|
+
.optional(),
|
|
415
|
+
name: z.string({}).meta({
|
|
416
|
+
description: "Field mapping for name (defaults to 'displayName')",
|
|
417
|
+
}),
|
|
418
|
+
firstName: z
|
|
419
|
+
.string({})
|
|
420
|
+
.meta({
|
|
421
|
+
description:
|
|
422
|
+
"Field mapping for first name (defaults to 'givenName')",
|
|
423
|
+
})
|
|
424
|
+
.optional(),
|
|
425
|
+
lastName: z
|
|
426
|
+
.string({})
|
|
427
|
+
.meta({
|
|
428
|
+
description:
|
|
429
|
+
"Field mapping for last name (defaults to 'surname')",
|
|
430
|
+
})
|
|
431
|
+
.optional(),
|
|
432
|
+
extraFields: z.record(z.string(), z.any()).optional(),
|
|
433
|
+
})
|
|
434
|
+
.optional(),
|
|
435
|
+
})
|
|
436
|
+
.optional(),
|
|
437
|
+
organizationId: z
|
|
438
|
+
.string({})
|
|
439
|
+
.meta({
|
|
440
|
+
description:
|
|
441
|
+
"If organization plugin is enabled, the organization id to link the provider to",
|
|
442
|
+
})
|
|
443
|
+
.optional(),
|
|
444
|
+
overrideUserInfo: z
|
|
445
|
+
.boolean({})
|
|
446
|
+
.meta({
|
|
447
|
+
description:
|
|
448
|
+
"Override user info with the provider info. Defaults to false",
|
|
449
|
+
})
|
|
450
|
+
.default(false)
|
|
451
|
+
.optional(),
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
export const registerSSOProvider = <O extends SSOOptions>(options: O) => {
|
|
455
|
+
return createAuthEndpoint(
|
|
456
|
+
"/sso/register",
|
|
457
|
+
{
|
|
458
|
+
method: "POST",
|
|
459
|
+
body: ssoProviderBodySchema,
|
|
460
|
+
use: [sessionMiddleware],
|
|
461
|
+
metadata: {
|
|
462
|
+
openapi: {
|
|
463
|
+
operationId: "registerSSOProvider",
|
|
464
|
+
summary: "Register an OIDC provider",
|
|
465
|
+
description:
|
|
466
|
+
"This endpoint is used to register an OIDC provider. This is used to configure the provider and link it to an organization",
|
|
467
|
+
responses: {
|
|
468
|
+
"200": {
|
|
469
|
+
description: "OIDC provider created successfully",
|
|
470
|
+
content: {
|
|
471
|
+
"application/json": {
|
|
472
|
+
schema: {
|
|
473
|
+
type: "object",
|
|
474
|
+
properties: {
|
|
475
|
+
issuer: {
|
|
476
|
+
type: "string",
|
|
477
|
+
format: "uri",
|
|
478
|
+
description: "The issuer URL of the provider",
|
|
479
|
+
},
|
|
480
|
+
domain: {
|
|
481
|
+
type: "string",
|
|
482
|
+
description:
|
|
483
|
+
"The domain of the provider, used for email matching",
|
|
484
|
+
},
|
|
485
|
+
domainVerified: {
|
|
486
|
+
type: "boolean",
|
|
487
|
+
description:
|
|
488
|
+
"A boolean indicating whether the domain has been verified or not",
|
|
489
|
+
},
|
|
490
|
+
domainVerificationToken: {
|
|
491
|
+
type: "string",
|
|
492
|
+
description:
|
|
493
|
+
"Domain verification token. It can be used to prove ownership over the SSO domain",
|
|
494
|
+
},
|
|
495
|
+
oidcConfig: {
|
|
496
|
+
type: "object",
|
|
497
|
+
properties: {
|
|
498
|
+
issuer: {
|
|
499
|
+
type: "string",
|
|
500
|
+
format: "uri",
|
|
501
|
+
description: "The issuer URL of the provider",
|
|
502
|
+
},
|
|
503
|
+
pkce: {
|
|
504
|
+
type: "boolean",
|
|
505
|
+
description:
|
|
506
|
+
"Whether PKCE is enabled for the authorization flow",
|
|
507
|
+
},
|
|
508
|
+
clientId: {
|
|
509
|
+
type: "string",
|
|
510
|
+
description: "The client ID for the provider",
|
|
511
|
+
},
|
|
512
|
+
clientSecret: {
|
|
513
|
+
type: "string",
|
|
514
|
+
description: "The client secret for the provider",
|
|
515
|
+
},
|
|
516
|
+
authorizationEndpoint: {
|
|
517
|
+
type: "string",
|
|
518
|
+
format: "uri",
|
|
519
|
+
nullable: true,
|
|
520
|
+
description: "The authorization endpoint URL",
|
|
521
|
+
},
|
|
522
|
+
discoveryEndpoint: {
|
|
523
|
+
type: "string",
|
|
524
|
+
format: "uri",
|
|
525
|
+
description: "The discovery endpoint URL",
|
|
526
|
+
},
|
|
527
|
+
userInfoEndpoint: {
|
|
528
|
+
type: "string",
|
|
529
|
+
format: "uri",
|
|
530
|
+
nullable: true,
|
|
531
|
+
description: "The user info endpoint URL",
|
|
532
|
+
},
|
|
533
|
+
scopes: {
|
|
534
|
+
type: "array",
|
|
535
|
+
items: { type: "string" },
|
|
536
|
+
nullable: true,
|
|
537
|
+
description:
|
|
538
|
+
"The scopes requested from the provider",
|
|
539
|
+
},
|
|
540
|
+
tokenEndpoint: {
|
|
541
|
+
type: "string",
|
|
542
|
+
format: "uri",
|
|
543
|
+
nullable: true,
|
|
544
|
+
description: "The token endpoint URL",
|
|
545
|
+
},
|
|
546
|
+
tokenEndpointAuthentication: {
|
|
547
|
+
type: "string",
|
|
548
|
+
enum: ["client_secret_post", "client_secret_basic"],
|
|
549
|
+
nullable: true,
|
|
550
|
+
description:
|
|
551
|
+
"Authentication method for the token endpoint",
|
|
552
|
+
},
|
|
553
|
+
jwksEndpoint: {
|
|
554
|
+
type: "string",
|
|
555
|
+
format: "uri",
|
|
556
|
+
nullable: true,
|
|
557
|
+
description: "The JWKS endpoint URL",
|
|
558
|
+
},
|
|
559
|
+
mapping: {
|
|
560
|
+
type: "object",
|
|
561
|
+
nullable: true,
|
|
562
|
+
properties: {
|
|
563
|
+
id: {
|
|
564
|
+
type: "string",
|
|
565
|
+
description:
|
|
566
|
+
"Field mapping for user ID (defaults to 'sub')",
|
|
567
|
+
},
|
|
568
|
+
email: {
|
|
569
|
+
type: "string",
|
|
570
|
+
description:
|
|
571
|
+
"Field mapping for email (defaults to 'email')",
|
|
572
|
+
},
|
|
573
|
+
emailVerified: {
|
|
574
|
+
type: "string",
|
|
575
|
+
nullable: true,
|
|
576
|
+
description:
|
|
577
|
+
"Field mapping for email verification (defaults to 'email_verified')",
|
|
578
|
+
},
|
|
579
|
+
name: {
|
|
580
|
+
type: "string",
|
|
581
|
+
description:
|
|
582
|
+
"Field mapping for name (defaults to 'name')",
|
|
583
|
+
},
|
|
584
|
+
image: {
|
|
585
|
+
type: "string",
|
|
586
|
+
nullable: true,
|
|
587
|
+
description:
|
|
588
|
+
"Field mapping for image (defaults to 'picture')",
|
|
589
|
+
},
|
|
590
|
+
extraFields: {
|
|
591
|
+
type: "object",
|
|
592
|
+
additionalProperties: { type: "string" },
|
|
593
|
+
nullable: true,
|
|
594
|
+
description: "Additional field mappings",
|
|
595
|
+
},
|
|
596
|
+
},
|
|
597
|
+
required: ["id", "email", "name"],
|
|
598
|
+
},
|
|
599
|
+
},
|
|
600
|
+
required: [
|
|
601
|
+
"issuer",
|
|
602
|
+
"pkce",
|
|
603
|
+
"clientId",
|
|
604
|
+
"clientSecret",
|
|
605
|
+
"discoveryEndpoint",
|
|
606
|
+
],
|
|
607
|
+
description: "OIDC configuration for the provider",
|
|
608
|
+
},
|
|
609
|
+
organizationId: {
|
|
610
|
+
type: "string",
|
|
611
|
+
nullable: true,
|
|
612
|
+
description: "ID of the linked organization, if any",
|
|
613
|
+
},
|
|
614
|
+
userId: {
|
|
615
|
+
type: "string",
|
|
616
|
+
description:
|
|
617
|
+
"ID of the user who registered the provider",
|
|
618
|
+
},
|
|
619
|
+
providerId: {
|
|
620
|
+
type: "string",
|
|
621
|
+
description: "Unique identifier for the provider",
|
|
622
|
+
},
|
|
623
|
+
redirectURI: {
|
|
624
|
+
type: "string",
|
|
625
|
+
format: "uri",
|
|
626
|
+
description:
|
|
627
|
+
"The redirect URI for the provider callback",
|
|
628
|
+
},
|
|
629
|
+
},
|
|
630
|
+
required: [
|
|
631
|
+
"issuer",
|
|
632
|
+
"domain",
|
|
633
|
+
"oidcConfig",
|
|
634
|
+
"userId",
|
|
635
|
+
"providerId",
|
|
636
|
+
"redirectURI",
|
|
637
|
+
],
|
|
638
|
+
},
|
|
639
|
+
},
|
|
640
|
+
},
|
|
641
|
+
},
|
|
642
|
+
},
|
|
643
|
+
},
|
|
644
|
+
},
|
|
645
|
+
},
|
|
646
|
+
async (ctx) => {
|
|
647
|
+
const user = ctx.context.session?.user;
|
|
648
|
+
if (!user) {
|
|
649
|
+
throw new APIError("UNAUTHORIZED");
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
const limit =
|
|
653
|
+
typeof options?.providersLimit === "function"
|
|
654
|
+
? await options.providersLimit(user)
|
|
655
|
+
: (options?.providersLimit ?? 10);
|
|
656
|
+
|
|
657
|
+
if (!limit) {
|
|
658
|
+
throw new APIError("FORBIDDEN", {
|
|
659
|
+
message: "SSO provider registration is disabled",
|
|
660
|
+
});
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
const providers = await ctx.context.adapter.findMany({
|
|
664
|
+
model: "ssoProvider",
|
|
665
|
+
where: [{ field: "userId", value: user.id }],
|
|
666
|
+
});
|
|
667
|
+
|
|
668
|
+
if (providers.length >= limit) {
|
|
669
|
+
throw new APIError("FORBIDDEN", {
|
|
670
|
+
message: "You have reached the maximum number of SSO providers",
|
|
671
|
+
});
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
const body = ctx.body;
|
|
675
|
+
const issuerValidator = z.string().url();
|
|
676
|
+
if (issuerValidator.safeParse(body.issuer).error) {
|
|
677
|
+
throw new APIError("BAD_REQUEST", {
|
|
678
|
+
message: "Invalid issuer. Must be a valid URL",
|
|
679
|
+
});
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
if (body.samlConfig?.idpMetadata?.metadata) {
|
|
683
|
+
const maxMetadataSize =
|
|
684
|
+
options?.saml?.maxMetadataSize ?? DEFAULT_MAX_SAML_METADATA_SIZE;
|
|
685
|
+
if (
|
|
686
|
+
new TextEncoder().encode(body.samlConfig.idpMetadata.metadata)
|
|
687
|
+
.length > maxMetadataSize
|
|
688
|
+
) {
|
|
689
|
+
throw new APIError("BAD_REQUEST", {
|
|
690
|
+
message: `IdP metadata exceeds maximum allowed size (${maxMetadataSize} bytes)`,
|
|
691
|
+
});
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
if (ctx.body.organizationId) {
|
|
696
|
+
const organization = await ctx.context.adapter.findOne({
|
|
697
|
+
model: "member",
|
|
698
|
+
where: [
|
|
699
|
+
{
|
|
700
|
+
field: "userId",
|
|
701
|
+
value: user.id,
|
|
702
|
+
},
|
|
703
|
+
{
|
|
704
|
+
field: "organizationId",
|
|
705
|
+
value: ctx.body.organizationId,
|
|
706
|
+
},
|
|
707
|
+
],
|
|
708
|
+
});
|
|
709
|
+
if (!organization) {
|
|
710
|
+
throw new APIError("BAD_REQUEST", {
|
|
711
|
+
message: "You are not a member of the organization",
|
|
712
|
+
});
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
const existingProvider = await ctx.context.adapter.findOne({
|
|
717
|
+
model: "ssoProvider",
|
|
718
|
+
where: [
|
|
719
|
+
{
|
|
720
|
+
field: "providerId",
|
|
721
|
+
value: body.providerId,
|
|
722
|
+
},
|
|
723
|
+
],
|
|
724
|
+
});
|
|
725
|
+
|
|
726
|
+
if (existingProvider) {
|
|
727
|
+
ctx.context.logger.info(
|
|
728
|
+
`SSO provider creation attempt with existing providerId: ${body.providerId}`,
|
|
729
|
+
);
|
|
730
|
+
throw new APIError("UNPROCESSABLE_ENTITY", {
|
|
731
|
+
message: "SSO provider with this providerId already exists",
|
|
732
|
+
});
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
let hydratedOIDCConfig: HydratedOIDCConfig | null = null;
|
|
736
|
+
if (body.oidcConfig && !body.oidcConfig.skipDiscovery) {
|
|
737
|
+
try {
|
|
738
|
+
hydratedOIDCConfig = await discoverOIDCConfig({
|
|
739
|
+
issuer: body.issuer,
|
|
740
|
+
existingConfig: {
|
|
741
|
+
discoveryEndpoint: body.oidcConfig.discoveryEndpoint,
|
|
742
|
+
authorizationEndpoint: body.oidcConfig.authorizationEndpoint,
|
|
743
|
+
tokenEndpoint: body.oidcConfig.tokenEndpoint,
|
|
744
|
+
jwksEndpoint: body.oidcConfig.jwksEndpoint,
|
|
745
|
+
userInfoEndpoint: body.oidcConfig.userInfoEndpoint,
|
|
746
|
+
tokenEndpointAuthentication:
|
|
747
|
+
body.oidcConfig.tokenEndpointAuthentication,
|
|
748
|
+
},
|
|
749
|
+
isTrustedOrigin: (url: string) => ctx.context.isTrustedOrigin(url),
|
|
750
|
+
});
|
|
751
|
+
} catch (error) {
|
|
752
|
+
if (error instanceof DiscoveryError) {
|
|
753
|
+
throw mapDiscoveryErrorToAPIError(error);
|
|
754
|
+
}
|
|
755
|
+
throw error;
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
const buildOIDCConfig = () => {
|
|
760
|
+
if (!body.oidcConfig) return null;
|
|
761
|
+
|
|
762
|
+
if (body.oidcConfig.skipDiscovery) {
|
|
763
|
+
return JSON.stringify({
|
|
764
|
+
issuer: body.issuer,
|
|
765
|
+
clientId: body.oidcConfig.clientId,
|
|
766
|
+
clientSecret: body.oidcConfig.clientSecret,
|
|
767
|
+
authorizationEndpoint: body.oidcConfig.authorizationEndpoint,
|
|
768
|
+
tokenEndpoint: body.oidcConfig.tokenEndpoint,
|
|
769
|
+
tokenEndpointAuthentication:
|
|
770
|
+
body.oidcConfig.tokenEndpointAuthentication ||
|
|
771
|
+
"client_secret_basic",
|
|
772
|
+
jwksEndpoint: body.oidcConfig.jwksEndpoint,
|
|
773
|
+
pkce: body.oidcConfig.pkce,
|
|
774
|
+
discoveryEndpoint:
|
|
775
|
+
body.oidcConfig.discoveryEndpoint ||
|
|
776
|
+
`${body.issuer}/.well-known/openid-configuration`,
|
|
777
|
+
mapping: body.oidcConfig.mapping,
|
|
778
|
+
scopes: body.oidcConfig.scopes,
|
|
779
|
+
userInfoEndpoint: body.oidcConfig.userInfoEndpoint,
|
|
780
|
+
overrideUserInfo:
|
|
781
|
+
ctx.body.overrideUserInfo ||
|
|
782
|
+
options?.defaultOverrideUserInfo ||
|
|
783
|
+
false,
|
|
784
|
+
});
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
if (!hydratedOIDCConfig) return null;
|
|
788
|
+
|
|
789
|
+
return JSON.stringify({
|
|
790
|
+
issuer: hydratedOIDCConfig.issuer,
|
|
791
|
+
clientId: body.oidcConfig.clientId,
|
|
792
|
+
clientSecret: body.oidcConfig.clientSecret,
|
|
793
|
+
authorizationEndpoint: hydratedOIDCConfig.authorizationEndpoint,
|
|
794
|
+
tokenEndpoint: hydratedOIDCConfig.tokenEndpoint,
|
|
795
|
+
tokenEndpointAuthentication:
|
|
796
|
+
hydratedOIDCConfig.tokenEndpointAuthentication,
|
|
797
|
+
jwksEndpoint: hydratedOIDCConfig.jwksEndpoint,
|
|
798
|
+
pkce: body.oidcConfig.pkce,
|
|
799
|
+
discoveryEndpoint: hydratedOIDCConfig.discoveryEndpoint,
|
|
800
|
+
mapping: body.oidcConfig.mapping,
|
|
801
|
+
scopes: body.oidcConfig.scopes,
|
|
802
|
+
userInfoEndpoint: hydratedOIDCConfig.userInfoEndpoint,
|
|
803
|
+
overrideUserInfo:
|
|
804
|
+
ctx.body.overrideUserInfo ||
|
|
805
|
+
options?.defaultOverrideUserInfo ||
|
|
806
|
+
false,
|
|
807
|
+
});
|
|
808
|
+
};
|
|
809
|
+
|
|
810
|
+
if (body.samlConfig) {
|
|
811
|
+
validateConfigAlgorithms(
|
|
812
|
+
{
|
|
813
|
+
signatureAlgorithm: body.samlConfig.signatureAlgorithm,
|
|
814
|
+
digestAlgorithm: body.samlConfig.digestAlgorithm,
|
|
815
|
+
},
|
|
816
|
+
options?.saml?.algorithms,
|
|
817
|
+
);
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
const provider = await ctx.context.adapter.create<
|
|
821
|
+
Record<string, any>,
|
|
822
|
+
SSOProvider<O>
|
|
823
|
+
>({
|
|
824
|
+
model: "ssoProvider",
|
|
825
|
+
data: {
|
|
826
|
+
issuer: body.issuer,
|
|
827
|
+
domain: body.domain,
|
|
828
|
+
domainVerified: false,
|
|
829
|
+
oidcConfig: buildOIDCConfig(),
|
|
830
|
+
samlConfig: body.samlConfig
|
|
831
|
+
? JSON.stringify({
|
|
832
|
+
issuer: body.issuer,
|
|
833
|
+
entryPoint: body.samlConfig.entryPoint,
|
|
834
|
+
cert: body.samlConfig.cert,
|
|
835
|
+
callbackUrl: body.samlConfig.callbackUrl,
|
|
836
|
+
audience: body.samlConfig.audience,
|
|
837
|
+
idpMetadata: body.samlConfig.idpMetadata,
|
|
838
|
+
spMetadata: body.samlConfig.spMetadata,
|
|
839
|
+
wantAssertionsSigned: body.samlConfig.wantAssertionsSigned,
|
|
840
|
+
authnRequestsSigned: body.samlConfig.authnRequestsSigned,
|
|
841
|
+
signatureAlgorithm: body.samlConfig.signatureAlgorithm,
|
|
842
|
+
digestAlgorithm: body.samlConfig.digestAlgorithm,
|
|
843
|
+
identifierFormat: body.samlConfig.identifierFormat,
|
|
844
|
+
privateKey: body.samlConfig.privateKey,
|
|
845
|
+
decryptionPvk: body.samlConfig.decryptionPvk,
|
|
846
|
+
additionalParams: body.samlConfig.additionalParams,
|
|
847
|
+
mapping: body.samlConfig.mapping,
|
|
848
|
+
})
|
|
849
|
+
: null,
|
|
850
|
+
organizationId: body.organizationId,
|
|
851
|
+
userId: ctx.context.session.user.id,
|
|
852
|
+
providerId: body.providerId,
|
|
853
|
+
},
|
|
854
|
+
});
|
|
855
|
+
|
|
856
|
+
let domainVerificationToken: string | undefined;
|
|
857
|
+
let domainVerified: boolean | undefined;
|
|
858
|
+
|
|
859
|
+
if (options?.domainVerification?.enabled) {
|
|
860
|
+
domainVerified = false;
|
|
861
|
+
domainVerificationToken = generateRandomString(24);
|
|
862
|
+
|
|
863
|
+
await ctx.context.adapter.create<Verification>({
|
|
864
|
+
model: "verification",
|
|
865
|
+
data: {
|
|
866
|
+
identifier: options.domainVerification?.tokenPrefix
|
|
867
|
+
? `${options.domainVerification?.tokenPrefix}-${provider.providerId}`
|
|
868
|
+
: `better-auth-token-${provider.providerId}`,
|
|
869
|
+
createdAt: new Date(),
|
|
870
|
+
updatedAt: new Date(),
|
|
871
|
+
value: domainVerificationToken as string,
|
|
872
|
+
expiresAt: new Date(Date.now() + 3600 * 24 * 7 * 1000), // 1 week
|
|
873
|
+
},
|
|
874
|
+
});
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
type SSOProviderResponse = {
|
|
878
|
+
redirectURI: string;
|
|
879
|
+
oidcConfig: OIDCConfig | null;
|
|
880
|
+
samlConfig: SAMLConfig | null;
|
|
881
|
+
} & Omit<SSOProvider<O>, "oidcConfig" | "samlConfig">;
|
|
882
|
+
|
|
883
|
+
type SSOProviderReturn = O["domainVerification"] extends { enabled: true }
|
|
884
|
+
? SSOProviderResponse & {
|
|
885
|
+
domainVerified: boolean;
|
|
886
|
+
domainVerificationToken: string;
|
|
887
|
+
}
|
|
888
|
+
: SSOProviderResponse;
|
|
889
|
+
|
|
890
|
+
const result = {
|
|
891
|
+
...provider,
|
|
892
|
+
oidcConfig: safeJsonParse<OIDCConfig>(
|
|
893
|
+
provider.oidcConfig as unknown as string,
|
|
894
|
+
),
|
|
895
|
+
samlConfig: safeJsonParse<SAMLConfig>(
|
|
896
|
+
provider.samlConfig as unknown as string,
|
|
897
|
+
),
|
|
898
|
+
redirectURI: `${ctx.context.baseURL}/sso/callback/${provider.providerId}`,
|
|
899
|
+
...(options?.domainVerification?.enabled ? { domainVerified } : {}),
|
|
900
|
+
...(options?.domainVerification?.enabled
|
|
901
|
+
? { domainVerificationToken }
|
|
902
|
+
: {}),
|
|
903
|
+
};
|
|
904
|
+
|
|
905
|
+
return ctx.json(result as SSOProviderReturn);
|
|
906
|
+
},
|
|
907
|
+
);
|
|
908
|
+
};
|
|
909
|
+
|
|
910
|
+
const signInSSOBodySchema = z.object({
|
|
911
|
+
email: z
|
|
912
|
+
.string({})
|
|
913
|
+
.meta({
|
|
914
|
+
description:
|
|
915
|
+
"The email address to sign in with. This is used to identify the issuer to sign in with. It's optional if the issuer is provided",
|
|
916
|
+
})
|
|
917
|
+
.optional(),
|
|
918
|
+
organizationSlug: z
|
|
919
|
+
.string({})
|
|
920
|
+
.meta({
|
|
921
|
+
description: "The slug of the organization to sign in with",
|
|
922
|
+
})
|
|
923
|
+
.optional(),
|
|
924
|
+
providerId: z
|
|
925
|
+
.string({})
|
|
926
|
+
.meta({
|
|
927
|
+
description:
|
|
928
|
+
"The ID of the provider to sign in with. This can be provided instead of email or issuer",
|
|
929
|
+
})
|
|
930
|
+
.optional(),
|
|
931
|
+
domain: z
|
|
932
|
+
.string({})
|
|
933
|
+
.meta({
|
|
934
|
+
description: "The domain of the provider.",
|
|
935
|
+
})
|
|
936
|
+
.optional(),
|
|
937
|
+
callbackURL: z.string({}).meta({
|
|
938
|
+
description: "The URL to redirect to after login",
|
|
939
|
+
}),
|
|
940
|
+
errorCallbackURL: z
|
|
941
|
+
.string({})
|
|
942
|
+
.meta({
|
|
943
|
+
description: "The URL to redirect to after login",
|
|
944
|
+
})
|
|
945
|
+
.optional(),
|
|
946
|
+
newUserCallbackURL: z
|
|
947
|
+
.string({})
|
|
948
|
+
.meta({
|
|
949
|
+
description: "The URL to redirect to after login if the user is new",
|
|
950
|
+
})
|
|
951
|
+
.optional(),
|
|
952
|
+
scopes: z
|
|
953
|
+
.array(z.string(), {})
|
|
954
|
+
.meta({
|
|
955
|
+
description: "Scopes to request from the provider.",
|
|
956
|
+
})
|
|
957
|
+
.optional(),
|
|
958
|
+
loginHint: z
|
|
959
|
+
.string({})
|
|
960
|
+
.meta({
|
|
961
|
+
description:
|
|
962
|
+
"Login hint to send to the identity provider (e.g., email or identifier). If supported, will be sent as 'login_hint'.",
|
|
963
|
+
})
|
|
964
|
+
.optional(),
|
|
965
|
+
requestSignUp: z
|
|
966
|
+
.boolean({})
|
|
967
|
+
.meta({
|
|
968
|
+
description:
|
|
969
|
+
"Explicitly request sign-up. Useful when disableImplicitSignUp is true for this provider",
|
|
970
|
+
})
|
|
971
|
+
.optional(),
|
|
972
|
+
providerType: z.enum(["oidc", "saml"]).optional(),
|
|
973
|
+
});
|
|
974
|
+
|
|
975
|
+
export const signInSSO = (options?: SSOOptions) => {
|
|
976
|
+
return createAuthEndpoint(
|
|
977
|
+
"/sign-in/sso",
|
|
978
|
+
{
|
|
979
|
+
method: "POST",
|
|
980
|
+
body: signInSSOBodySchema,
|
|
981
|
+
metadata: {
|
|
982
|
+
openapi: {
|
|
983
|
+
operationId: "signInWithSSO",
|
|
984
|
+
summary: "Sign in with SSO provider",
|
|
985
|
+
description:
|
|
986
|
+
"This endpoint is used to sign in with an SSO provider. It redirects to the provider's authorization URL",
|
|
987
|
+
requestBody: {
|
|
988
|
+
content: {
|
|
989
|
+
"application/json": {
|
|
990
|
+
schema: {
|
|
991
|
+
type: "object",
|
|
992
|
+
properties: {
|
|
993
|
+
email: {
|
|
994
|
+
type: "string",
|
|
995
|
+
description:
|
|
996
|
+
"The email address to sign in with. This is used to identify the issuer to sign in with. It's optional if the issuer is provided",
|
|
997
|
+
},
|
|
998
|
+
issuer: {
|
|
999
|
+
type: "string",
|
|
1000
|
+
description:
|
|
1001
|
+
"The issuer identifier, this is the URL of the provider and can be used to verify the provider and identify the provider during login. It's optional if the email is provided",
|
|
1002
|
+
},
|
|
1003
|
+
providerId: {
|
|
1004
|
+
type: "string",
|
|
1005
|
+
description:
|
|
1006
|
+
"The ID of the provider to sign in with. This can be provided instead of email or issuer",
|
|
1007
|
+
},
|
|
1008
|
+
callbackURL: {
|
|
1009
|
+
type: "string",
|
|
1010
|
+
description: "The URL to redirect to after login",
|
|
1011
|
+
},
|
|
1012
|
+
errorCallbackURL: {
|
|
1013
|
+
type: "string",
|
|
1014
|
+
description: "The URL to redirect to after login",
|
|
1015
|
+
},
|
|
1016
|
+
newUserCallbackURL: {
|
|
1017
|
+
type: "string",
|
|
1018
|
+
description:
|
|
1019
|
+
"The URL to redirect to after login if the user is new",
|
|
1020
|
+
},
|
|
1021
|
+
loginHint: {
|
|
1022
|
+
type: "string",
|
|
1023
|
+
description:
|
|
1024
|
+
"Login hint to send to the identity provider (e.g., email or identifier). If supported, sent as 'login_hint'.",
|
|
1025
|
+
},
|
|
1026
|
+
},
|
|
1027
|
+
required: ["callbackURL"],
|
|
1028
|
+
},
|
|
1029
|
+
},
|
|
1030
|
+
},
|
|
1031
|
+
},
|
|
1032
|
+
responses: {
|
|
1033
|
+
"200": {
|
|
1034
|
+
description:
|
|
1035
|
+
"Authorization URL generated successfully for SSO sign-in",
|
|
1036
|
+
content: {
|
|
1037
|
+
"application/json": {
|
|
1038
|
+
schema: {
|
|
1039
|
+
type: "object",
|
|
1040
|
+
properties: {
|
|
1041
|
+
url: {
|
|
1042
|
+
type: "string",
|
|
1043
|
+
format: "uri",
|
|
1044
|
+
description:
|
|
1045
|
+
"The authorization URL to redirect the user to for SSO sign-in",
|
|
1046
|
+
},
|
|
1047
|
+
redirect: {
|
|
1048
|
+
type: "boolean",
|
|
1049
|
+
description:
|
|
1050
|
+
"Indicates that the client should redirect to the provided URL",
|
|
1051
|
+
enum: [true],
|
|
1052
|
+
},
|
|
1053
|
+
},
|
|
1054
|
+
required: ["url", "redirect"],
|
|
1055
|
+
},
|
|
1056
|
+
},
|
|
1057
|
+
},
|
|
1058
|
+
},
|
|
1059
|
+
},
|
|
1060
|
+
},
|
|
1061
|
+
},
|
|
1062
|
+
},
|
|
1063
|
+
async (ctx) => {
|
|
1064
|
+
const body = ctx.body;
|
|
1065
|
+
let { email, organizationSlug, providerId, domain } = body;
|
|
1066
|
+
if (
|
|
1067
|
+
!options?.defaultSSO?.length &&
|
|
1068
|
+
!email &&
|
|
1069
|
+
!organizationSlug &&
|
|
1070
|
+
!domain &&
|
|
1071
|
+
!providerId
|
|
1072
|
+
) {
|
|
1073
|
+
throw new APIError("BAD_REQUEST", {
|
|
1074
|
+
message: "email, organizationSlug, domain or providerId is required",
|
|
1075
|
+
});
|
|
1076
|
+
}
|
|
1077
|
+
domain = body.domain || email?.split("@")[1];
|
|
1078
|
+
let orgId = "";
|
|
1079
|
+
if (organizationSlug) {
|
|
1080
|
+
orgId = await ctx.context.adapter
|
|
1081
|
+
.findOne<{ id: string }>({
|
|
1082
|
+
model: "organization",
|
|
1083
|
+
where: [
|
|
1084
|
+
{
|
|
1085
|
+
field: "slug",
|
|
1086
|
+
value: organizationSlug,
|
|
1087
|
+
},
|
|
1088
|
+
],
|
|
1089
|
+
})
|
|
1090
|
+
.then((res) => {
|
|
1091
|
+
if (!res) {
|
|
1092
|
+
return "";
|
|
1093
|
+
}
|
|
1094
|
+
return res.id;
|
|
1095
|
+
});
|
|
1096
|
+
}
|
|
1097
|
+
let provider: SSOProvider<SSOOptions> | null = null;
|
|
1098
|
+
if (options?.defaultSSO?.length) {
|
|
1099
|
+
// Find matching default SSO provider by providerId
|
|
1100
|
+
const matchingDefault = providerId
|
|
1101
|
+
? options.defaultSSO.find(
|
|
1102
|
+
(defaultProvider) => defaultProvider.providerId === providerId,
|
|
1103
|
+
)
|
|
1104
|
+
: options.defaultSSO.find(
|
|
1105
|
+
(defaultProvider) => defaultProvider.domain === domain,
|
|
1106
|
+
);
|
|
1107
|
+
|
|
1108
|
+
if (matchingDefault) {
|
|
1109
|
+
provider = {
|
|
1110
|
+
issuer:
|
|
1111
|
+
matchingDefault.samlConfig?.issuer ||
|
|
1112
|
+
matchingDefault.oidcConfig?.issuer ||
|
|
1113
|
+
"",
|
|
1114
|
+
providerId: matchingDefault.providerId,
|
|
1115
|
+
userId: "default",
|
|
1116
|
+
oidcConfig: matchingDefault.oidcConfig,
|
|
1117
|
+
samlConfig: matchingDefault.samlConfig,
|
|
1118
|
+
domain: matchingDefault.domain,
|
|
1119
|
+
...(options.domainVerification?.enabled
|
|
1120
|
+
? { domainVerified: true }
|
|
1121
|
+
: {}),
|
|
1122
|
+
} as SSOProvider<SSOOptions>;
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
if (!providerId && !orgId && !domain) {
|
|
1126
|
+
throw new APIError("BAD_REQUEST", {
|
|
1127
|
+
message: "providerId, orgId or domain is required",
|
|
1128
|
+
});
|
|
1129
|
+
}
|
|
1130
|
+
// Try to find provider in database
|
|
1131
|
+
if (!provider) {
|
|
1132
|
+
const parseProvider = (res: SSOProvider<SSOOptions> | null) => {
|
|
1133
|
+
if (!res) return null;
|
|
1134
|
+
return {
|
|
1135
|
+
...res,
|
|
1136
|
+
oidcConfig: res.oidcConfig
|
|
1137
|
+
? safeJsonParse<OIDCConfig>(
|
|
1138
|
+
res.oidcConfig as unknown as string,
|
|
1139
|
+
) || undefined
|
|
1140
|
+
: undefined,
|
|
1141
|
+
samlConfig: res.samlConfig
|
|
1142
|
+
? safeJsonParse<SAMLConfig>(
|
|
1143
|
+
res.samlConfig as unknown as string,
|
|
1144
|
+
) || undefined
|
|
1145
|
+
: undefined,
|
|
1146
|
+
};
|
|
1147
|
+
};
|
|
1148
|
+
|
|
1149
|
+
if (providerId || orgId) {
|
|
1150
|
+
// Exact match for providerId or orgId
|
|
1151
|
+
provider = parseProvider(
|
|
1152
|
+
await ctx.context.adapter.findOne<SSOProvider<SSOOptions>>({
|
|
1153
|
+
model: "ssoProvider",
|
|
1154
|
+
where: [
|
|
1155
|
+
{
|
|
1156
|
+
field: providerId ? "providerId" : "organizationId",
|
|
1157
|
+
value: providerId || orgId!,
|
|
1158
|
+
},
|
|
1159
|
+
],
|
|
1160
|
+
}),
|
|
1161
|
+
);
|
|
1162
|
+
} else if (domain) {
|
|
1163
|
+
// For domain lookup, support comma-separated domains
|
|
1164
|
+
// First try exact match (fast path)
|
|
1165
|
+
provider = parseProvider(
|
|
1166
|
+
await ctx.context.adapter.findOne<SSOProvider<SSOOptions>>({
|
|
1167
|
+
model: "ssoProvider",
|
|
1168
|
+
where: [{ field: "domain", value: domain }],
|
|
1169
|
+
}),
|
|
1170
|
+
);
|
|
1171
|
+
// If not found, search all providers for comma-separated domain match
|
|
1172
|
+
if (!provider) {
|
|
1173
|
+
const allProviders = await ctx.context.adapter.findMany<
|
|
1174
|
+
SSOProvider<SSOOptions>
|
|
1175
|
+
>({
|
|
1176
|
+
model: "ssoProvider",
|
|
1177
|
+
});
|
|
1178
|
+
const matchingProvider = allProviders.find((p) =>
|
|
1179
|
+
domainMatches(domain, p.domain),
|
|
1180
|
+
);
|
|
1181
|
+
provider = parseProvider(matchingProvider ?? null);
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
if (!provider) {
|
|
1187
|
+
throw new APIError("NOT_FOUND", {
|
|
1188
|
+
message: "No provider found for the issuer",
|
|
1189
|
+
});
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
if (body.providerType) {
|
|
1193
|
+
if (body.providerType === "oidc" && !provider.oidcConfig) {
|
|
1194
|
+
throw new APIError("BAD_REQUEST", {
|
|
1195
|
+
message: "OIDC provider is not configured",
|
|
1196
|
+
});
|
|
1197
|
+
}
|
|
1198
|
+
if (body.providerType === "saml" && !provider.samlConfig) {
|
|
1199
|
+
throw new APIError("BAD_REQUEST", {
|
|
1200
|
+
message: "SAML provider is not configured",
|
|
1201
|
+
});
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
if (
|
|
1206
|
+
options?.domainVerification?.enabled &&
|
|
1207
|
+
!("domainVerified" in provider && provider.domainVerified)
|
|
1208
|
+
) {
|
|
1209
|
+
throw new APIError("UNAUTHORIZED", {
|
|
1210
|
+
message: "Provider domain has not been verified",
|
|
1211
|
+
});
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
if (provider.oidcConfig && body.providerType !== "saml") {
|
|
1215
|
+
let finalAuthUrl = provider.oidcConfig.authorizationEndpoint;
|
|
1216
|
+
if (!finalAuthUrl && provider.oidcConfig.discoveryEndpoint) {
|
|
1217
|
+
const discovery = await betterFetch<{
|
|
1218
|
+
authorization_endpoint: string;
|
|
1219
|
+
}>(provider.oidcConfig.discoveryEndpoint, {
|
|
1220
|
+
method: "GET",
|
|
1221
|
+
});
|
|
1222
|
+
if (discovery.data) {
|
|
1223
|
+
finalAuthUrl = discovery.data.authorization_endpoint;
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
if (!finalAuthUrl) {
|
|
1227
|
+
throw new APIError("BAD_REQUEST", {
|
|
1228
|
+
message: "Invalid OIDC configuration. Authorization URL not found.",
|
|
1229
|
+
});
|
|
1230
|
+
}
|
|
1231
|
+
const state = await generateState(ctx, undefined, false);
|
|
1232
|
+
const redirectURI = `${ctx.context.baseURL}/sso/callback/${provider.providerId}`;
|
|
1233
|
+
const authorizationURL = await createAuthorizationURL({
|
|
1234
|
+
id: provider.issuer,
|
|
1235
|
+
options: {
|
|
1236
|
+
clientId: provider.oidcConfig.clientId,
|
|
1237
|
+
clientSecret: provider.oidcConfig.clientSecret,
|
|
1238
|
+
},
|
|
1239
|
+
redirectURI,
|
|
1240
|
+
state: state.state,
|
|
1241
|
+
codeVerifier: provider.oidcConfig.pkce
|
|
1242
|
+
? state.codeVerifier
|
|
1243
|
+
: undefined,
|
|
1244
|
+
scopes: ctx.body.scopes ||
|
|
1245
|
+
provider.oidcConfig.scopes || [
|
|
1246
|
+
"openid",
|
|
1247
|
+
"email",
|
|
1248
|
+
"profile",
|
|
1249
|
+
"offline_access",
|
|
1250
|
+
],
|
|
1251
|
+
loginHint: ctx.body.loginHint || email,
|
|
1252
|
+
authorizationEndpoint: finalAuthUrl,
|
|
1253
|
+
});
|
|
1254
|
+
return ctx.json({
|
|
1255
|
+
url: authorizationURL.toString(),
|
|
1256
|
+
redirect: true,
|
|
1257
|
+
});
|
|
1258
|
+
}
|
|
1259
|
+
if (provider.samlConfig) {
|
|
1260
|
+
const parsedSamlConfig =
|
|
1261
|
+
typeof provider.samlConfig === "object"
|
|
1262
|
+
? provider.samlConfig
|
|
1263
|
+
: safeJsonParse<SAMLConfig>(
|
|
1264
|
+
provider.samlConfig as unknown as string,
|
|
1265
|
+
);
|
|
1266
|
+
if (!parsedSamlConfig) {
|
|
1267
|
+
throw new APIError("BAD_REQUEST", {
|
|
1268
|
+
message: "Invalid SAML configuration",
|
|
1269
|
+
});
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
if (
|
|
1273
|
+
parsedSamlConfig.authnRequestsSigned &&
|
|
1274
|
+
!parsedSamlConfig.spMetadata?.privateKey &&
|
|
1275
|
+
!parsedSamlConfig.privateKey
|
|
1276
|
+
) {
|
|
1277
|
+
ctx.context.logger.warn(
|
|
1278
|
+
"authnRequestsSigned is enabled but no privateKey provided - AuthnRequests will not be signed",
|
|
1279
|
+
{ providerId: provider.providerId },
|
|
1280
|
+
);
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
let metadata = parsedSamlConfig.spMetadata.metadata;
|
|
1284
|
+
|
|
1285
|
+
if (!metadata) {
|
|
1286
|
+
metadata =
|
|
1287
|
+
saml
|
|
1288
|
+
.SPMetadata({
|
|
1289
|
+
entityID:
|
|
1290
|
+
parsedSamlConfig.spMetadata?.entityID ||
|
|
1291
|
+
parsedSamlConfig.issuer,
|
|
1292
|
+
assertionConsumerService: [
|
|
1293
|
+
{
|
|
1294
|
+
Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
|
|
1295
|
+
Location:
|
|
1296
|
+
parsedSamlConfig.callbackUrl ||
|
|
1297
|
+
`${ctx.context.baseURL}/sso/saml2/sp/acs/${provider.providerId}`,
|
|
1298
|
+
},
|
|
1299
|
+
],
|
|
1300
|
+
wantMessageSigned:
|
|
1301
|
+
parsedSamlConfig.wantAssertionsSigned || false,
|
|
1302
|
+
authnRequestsSigned:
|
|
1303
|
+
parsedSamlConfig.authnRequestsSigned || false,
|
|
1304
|
+
nameIDFormat: parsedSamlConfig.identifierFormat
|
|
1305
|
+
? [parsedSamlConfig.identifierFormat]
|
|
1306
|
+
: undefined,
|
|
1307
|
+
})
|
|
1308
|
+
.getMetadata() || "";
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
const sp = saml.ServiceProvider({
|
|
1312
|
+
metadata: metadata,
|
|
1313
|
+
allowCreate: true,
|
|
1314
|
+
privateKey:
|
|
1315
|
+
parsedSamlConfig.spMetadata?.privateKey ||
|
|
1316
|
+
parsedSamlConfig.privateKey,
|
|
1317
|
+
privateKeyPass: parsedSamlConfig.spMetadata?.privateKeyPass,
|
|
1318
|
+
});
|
|
1319
|
+
|
|
1320
|
+
const idp = saml.IdentityProvider({
|
|
1321
|
+
metadata: parsedSamlConfig.idpMetadata?.metadata,
|
|
1322
|
+
entityID: parsedSamlConfig.idpMetadata?.entityID,
|
|
1323
|
+
encryptCert: parsedSamlConfig.idpMetadata?.cert,
|
|
1324
|
+
singleSignOnService:
|
|
1325
|
+
parsedSamlConfig.idpMetadata?.singleSignOnService,
|
|
1326
|
+
});
|
|
1327
|
+
const loginRequest = sp.createLoginRequest(
|
|
1328
|
+
idp,
|
|
1329
|
+
"redirect",
|
|
1330
|
+
) as BindingContext & {
|
|
1331
|
+
entityEndpoint: string;
|
|
1332
|
+
type: string;
|
|
1333
|
+
id: string;
|
|
1334
|
+
};
|
|
1335
|
+
if (!loginRequest) {
|
|
1336
|
+
throw new APIError("BAD_REQUEST", {
|
|
1337
|
+
message: "Invalid SAML request",
|
|
1338
|
+
});
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
const { state: relayState } = await generateRelayState(
|
|
1342
|
+
ctx,
|
|
1343
|
+
undefined,
|
|
1344
|
+
false,
|
|
1345
|
+
);
|
|
1346
|
+
|
|
1347
|
+
const shouldSaveRequest =
|
|
1348
|
+
loginRequest.id && options?.saml?.enableInResponseToValidation;
|
|
1349
|
+
if (shouldSaveRequest) {
|
|
1350
|
+
const ttl = options?.saml?.requestTTL ?? DEFAULT_AUTHN_REQUEST_TTL_MS;
|
|
1351
|
+
const record: AuthnRequestRecord = {
|
|
1352
|
+
id: loginRequest.id,
|
|
1353
|
+
providerId: provider.providerId,
|
|
1354
|
+
createdAt: Date.now(),
|
|
1355
|
+
expiresAt: Date.now() + ttl,
|
|
1356
|
+
};
|
|
1357
|
+
await ctx.context.internalAdapter.createVerificationValue({
|
|
1358
|
+
identifier: `${AUTHN_REQUEST_KEY_PREFIX}${record.id}`,
|
|
1359
|
+
value: JSON.stringify(record),
|
|
1360
|
+
expiresAt: new Date(record.expiresAt),
|
|
1361
|
+
});
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
return ctx.json({
|
|
1365
|
+
url: `${loginRequest.context}&RelayState=${encodeURIComponent(relayState)}`,
|
|
1366
|
+
redirect: true,
|
|
1367
|
+
});
|
|
1368
|
+
}
|
|
1369
|
+
throw new APIError("BAD_REQUEST", {
|
|
1370
|
+
message: "Invalid SSO provider",
|
|
1371
|
+
});
|
|
1372
|
+
},
|
|
1373
|
+
);
|
|
1374
|
+
};
|
|
1375
|
+
|
|
1376
|
+
const callbackSSOQuerySchema = z.object({
|
|
1377
|
+
code: z.string().optional(),
|
|
1378
|
+
state: z.string(),
|
|
1379
|
+
error: z.string().optional(),
|
|
1380
|
+
error_description: z.string().optional(),
|
|
1381
|
+
});
|
|
1382
|
+
|
|
1383
|
+
export const callbackSSO = (options?: SSOOptions) => {
|
|
1384
|
+
return createAuthEndpoint(
|
|
1385
|
+
"/sso/callback/:providerId",
|
|
1386
|
+
{
|
|
1387
|
+
method: "GET",
|
|
1388
|
+
query: callbackSSOQuerySchema,
|
|
1389
|
+
allowedMediaTypes: [
|
|
1390
|
+
"application/x-www-form-urlencoded",
|
|
1391
|
+
"application/json",
|
|
1392
|
+
],
|
|
1393
|
+
metadata: {
|
|
1394
|
+
...HIDE_METADATA,
|
|
1395
|
+
openapi: {
|
|
1396
|
+
operationId: "handleSSOCallback",
|
|
1397
|
+
summary: "Callback URL for SSO provider",
|
|
1398
|
+
description:
|
|
1399
|
+
"This endpoint is used as the callback URL for SSO providers. It handles the authorization code and exchanges it for an access token",
|
|
1400
|
+
responses: {
|
|
1401
|
+
"302": {
|
|
1402
|
+
description: "Redirects to the callback URL",
|
|
1403
|
+
},
|
|
1404
|
+
},
|
|
1405
|
+
},
|
|
1406
|
+
},
|
|
1407
|
+
},
|
|
1408
|
+
async (ctx) => {
|
|
1409
|
+
const { code, error, error_description } = ctx.query;
|
|
1410
|
+
const stateData = await parseState(ctx);
|
|
1411
|
+
if (!stateData) {
|
|
1412
|
+
const errorURL =
|
|
1413
|
+
ctx.context.options.onAPIError?.errorURL ||
|
|
1414
|
+
`${ctx.context.baseURL}/error`;
|
|
1415
|
+
throw ctx.redirect(`${errorURL}?error=invalid_state`);
|
|
1416
|
+
}
|
|
1417
|
+
const { callbackURL, errorURL, newUserURL, requestSignUp } = stateData;
|
|
1418
|
+
if (!code || error) {
|
|
1419
|
+
throw ctx.redirect(
|
|
1420
|
+
`${
|
|
1421
|
+
errorURL || callbackURL
|
|
1422
|
+
}?error=${error}&error_description=${error_description}`,
|
|
1423
|
+
);
|
|
1424
|
+
}
|
|
1425
|
+
let provider: SSOProvider<SSOOptions> | null = null;
|
|
1426
|
+
if (options?.defaultSSO?.length) {
|
|
1427
|
+
const matchingDefault = options.defaultSSO.find(
|
|
1428
|
+
(defaultProvider) =>
|
|
1429
|
+
defaultProvider.providerId === ctx.params.providerId,
|
|
1430
|
+
);
|
|
1431
|
+
if (matchingDefault) {
|
|
1432
|
+
provider = {
|
|
1433
|
+
...matchingDefault,
|
|
1434
|
+
issuer: matchingDefault.oidcConfig?.issuer || "",
|
|
1435
|
+
userId: "default",
|
|
1436
|
+
...(options.domainVerification?.enabled
|
|
1437
|
+
? { domainVerified: true }
|
|
1438
|
+
: {}),
|
|
1439
|
+
} as SSOProvider<SSOOptions>;
|
|
1440
|
+
}
|
|
1441
|
+
}
|
|
1442
|
+
if (!provider) {
|
|
1443
|
+
provider = await ctx.context.adapter
|
|
1444
|
+
.findOne<{
|
|
1445
|
+
oidcConfig: string;
|
|
1446
|
+
}>({
|
|
1447
|
+
model: "ssoProvider",
|
|
1448
|
+
where: [
|
|
1449
|
+
{
|
|
1450
|
+
field: "providerId",
|
|
1451
|
+
value: ctx.params.providerId,
|
|
1452
|
+
},
|
|
1453
|
+
],
|
|
1454
|
+
})
|
|
1455
|
+
.then((res) => {
|
|
1456
|
+
if (!res) {
|
|
1457
|
+
return null;
|
|
1458
|
+
}
|
|
1459
|
+
return {
|
|
1460
|
+
...res,
|
|
1461
|
+
oidcConfig:
|
|
1462
|
+
safeJsonParse<OIDCConfig>(res.oidcConfig) || undefined,
|
|
1463
|
+
} as SSOProvider<SSOOptions>;
|
|
1464
|
+
});
|
|
1465
|
+
}
|
|
1466
|
+
if (!provider) {
|
|
1467
|
+
throw ctx.redirect(
|
|
1468
|
+
`${
|
|
1469
|
+
errorURL || callbackURL
|
|
1470
|
+
}/error?error=invalid_provider&error_description=provider not found`,
|
|
1471
|
+
);
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
if (
|
|
1475
|
+
options?.domainVerification?.enabled &&
|
|
1476
|
+
!("domainVerified" in provider && provider.domainVerified)
|
|
1477
|
+
) {
|
|
1478
|
+
throw new APIError("UNAUTHORIZED", {
|
|
1479
|
+
message: "Provider domain has not been verified",
|
|
1480
|
+
});
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1483
|
+
let config = provider.oidcConfig;
|
|
1484
|
+
|
|
1485
|
+
if (!config) {
|
|
1486
|
+
throw ctx.redirect(
|
|
1487
|
+
`${
|
|
1488
|
+
errorURL || callbackURL
|
|
1489
|
+
}/error?error=invalid_provider&error_description=provider not found`,
|
|
1490
|
+
);
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1493
|
+
const discovery = await betterFetch<{
|
|
1494
|
+
token_endpoint: string;
|
|
1495
|
+
userinfo_endpoint: string;
|
|
1496
|
+
token_endpoint_auth_method:
|
|
1497
|
+
| "client_secret_basic"
|
|
1498
|
+
| "client_secret_post";
|
|
1499
|
+
}>(config.discoveryEndpoint);
|
|
1500
|
+
|
|
1501
|
+
if (discovery.data) {
|
|
1502
|
+
config = {
|
|
1503
|
+
tokenEndpoint: discovery.data.token_endpoint,
|
|
1504
|
+
tokenEndpointAuthentication:
|
|
1505
|
+
discovery.data.token_endpoint_auth_method,
|
|
1506
|
+
userInfoEndpoint: discovery.data.userinfo_endpoint,
|
|
1507
|
+
scopes: ["openid", "email", "profile", "offline_access"],
|
|
1508
|
+
...config,
|
|
1509
|
+
};
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1512
|
+
if (!config.tokenEndpoint) {
|
|
1513
|
+
throw ctx.redirect(
|
|
1514
|
+
`${
|
|
1515
|
+
errorURL || callbackURL
|
|
1516
|
+
}/error?error=invalid_provider&error_description=token_endpoint_not_found`,
|
|
1517
|
+
);
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
const tokenResponse = await validateAuthorizationCode({
|
|
1521
|
+
code,
|
|
1522
|
+
codeVerifier: config.pkce ? stateData.codeVerifier : undefined,
|
|
1523
|
+
redirectURI: `${ctx.context.baseURL}/sso/callback/${provider.providerId}`,
|
|
1524
|
+
options: {
|
|
1525
|
+
clientId: config.clientId,
|
|
1526
|
+
clientSecret: config.clientSecret,
|
|
1527
|
+
},
|
|
1528
|
+
tokenEndpoint: config.tokenEndpoint,
|
|
1529
|
+
authentication:
|
|
1530
|
+
config.tokenEndpointAuthentication === "client_secret_post"
|
|
1531
|
+
? "post"
|
|
1532
|
+
: "basic",
|
|
1533
|
+
}).catch((e) => {
|
|
1534
|
+
if (e instanceof BetterFetchError) {
|
|
1535
|
+
throw ctx.redirect(
|
|
1536
|
+
`${
|
|
1537
|
+
errorURL || callbackURL
|
|
1538
|
+
}?error=invalid_provider&error_description=${e.message}`,
|
|
1539
|
+
);
|
|
1540
|
+
}
|
|
1541
|
+
return null;
|
|
1542
|
+
});
|
|
1543
|
+
if (!tokenResponse) {
|
|
1544
|
+
throw ctx.redirect(
|
|
1545
|
+
`${
|
|
1546
|
+
errorURL || callbackURL
|
|
1547
|
+
}/error?error=invalid_provider&error_description=token_response_not_found`,
|
|
1548
|
+
);
|
|
1549
|
+
}
|
|
1550
|
+
let userInfo: {
|
|
1551
|
+
id?: string;
|
|
1552
|
+
email?: string;
|
|
1553
|
+
name?: string;
|
|
1554
|
+
image?: string;
|
|
1555
|
+
emailVerified?: boolean;
|
|
1556
|
+
[key: string]: any;
|
|
1557
|
+
} | null = null;
|
|
1558
|
+
if (tokenResponse.idToken) {
|
|
1559
|
+
const idToken = decodeJwt(tokenResponse.idToken);
|
|
1560
|
+
if (!config.jwksEndpoint) {
|
|
1561
|
+
throw ctx.redirect(
|
|
1562
|
+
`${
|
|
1563
|
+
errorURL || callbackURL
|
|
1564
|
+
}/error?error=invalid_provider&error_description=jwks_endpoint_not_found`,
|
|
1565
|
+
);
|
|
1566
|
+
}
|
|
1567
|
+
const verified = await validateToken(
|
|
1568
|
+
tokenResponse.idToken,
|
|
1569
|
+
config.jwksEndpoint,
|
|
1570
|
+
).catch((e) => {
|
|
1571
|
+
ctx.context.logger.error(e);
|
|
1572
|
+
return null;
|
|
1573
|
+
});
|
|
1574
|
+
if (!verified) {
|
|
1575
|
+
throw ctx.redirect(
|
|
1576
|
+
`${
|
|
1577
|
+
errorURL || callbackURL
|
|
1578
|
+
}/error?error=invalid_provider&error_description=token_not_verified`,
|
|
1579
|
+
);
|
|
1580
|
+
}
|
|
1581
|
+
if (verified.payload.iss !== provider.issuer) {
|
|
1582
|
+
throw ctx.redirect(
|
|
1583
|
+
`${
|
|
1584
|
+
errorURL || callbackURL
|
|
1585
|
+
}/error?error=invalid_provider&error_description=issuer_mismatch`,
|
|
1586
|
+
);
|
|
1587
|
+
}
|
|
1588
|
+
|
|
1589
|
+
const mapping = config.mapping || {};
|
|
1590
|
+
userInfo = {
|
|
1591
|
+
...Object.fromEntries(
|
|
1592
|
+
Object.entries(mapping.extraFields || {}).map(([key, value]) => [
|
|
1593
|
+
key,
|
|
1594
|
+
verified.payload[value],
|
|
1595
|
+
]),
|
|
1596
|
+
),
|
|
1597
|
+
id: idToken[mapping.id || "sub"],
|
|
1598
|
+
email: idToken[mapping.email || "email"],
|
|
1599
|
+
emailVerified: options?.trustEmailVerified
|
|
1600
|
+
? idToken[mapping.emailVerified || "email_verified"]
|
|
1601
|
+
: false,
|
|
1602
|
+
name: idToken[mapping.name || "name"],
|
|
1603
|
+
image: idToken[mapping.image || "picture"],
|
|
1604
|
+
} as {
|
|
1605
|
+
id?: string;
|
|
1606
|
+
email?: string;
|
|
1607
|
+
name?: string;
|
|
1608
|
+
image?: string;
|
|
1609
|
+
emailVerified?: boolean;
|
|
1610
|
+
};
|
|
1611
|
+
}
|
|
1612
|
+
|
|
1613
|
+
if (!userInfo) {
|
|
1614
|
+
if (!config.userInfoEndpoint) {
|
|
1615
|
+
throw ctx.redirect(
|
|
1616
|
+
`${
|
|
1617
|
+
errorURL || callbackURL
|
|
1618
|
+
}/error?error=invalid_provider&error_description=user_info_endpoint_not_found`,
|
|
1619
|
+
);
|
|
1620
|
+
}
|
|
1621
|
+
const userInfoResponse = await betterFetch<{
|
|
1622
|
+
email?: string;
|
|
1623
|
+
name?: string;
|
|
1624
|
+
id?: string;
|
|
1625
|
+
image?: string;
|
|
1626
|
+
emailVerified?: boolean;
|
|
1627
|
+
}>(config.userInfoEndpoint, {
|
|
1628
|
+
headers: {
|
|
1629
|
+
Authorization: `Bearer ${tokenResponse.accessToken}`,
|
|
1630
|
+
},
|
|
1631
|
+
});
|
|
1632
|
+
if (userInfoResponse.error) {
|
|
1633
|
+
throw ctx.redirect(
|
|
1634
|
+
`${
|
|
1635
|
+
errorURL || callbackURL
|
|
1636
|
+
}/error?error=invalid_provider&error_description=${
|
|
1637
|
+
userInfoResponse.error.message
|
|
1638
|
+
}`,
|
|
1639
|
+
);
|
|
1640
|
+
}
|
|
1641
|
+
userInfo = userInfoResponse.data;
|
|
1642
|
+
}
|
|
1643
|
+
|
|
1644
|
+
if (!userInfo.email || !userInfo.id) {
|
|
1645
|
+
throw ctx.redirect(
|
|
1646
|
+
`${
|
|
1647
|
+
errorURL || callbackURL
|
|
1648
|
+
}/error?error=invalid_provider&error_description=missing_user_info`,
|
|
1649
|
+
);
|
|
1650
|
+
}
|
|
1651
|
+
const isTrustedProvider =
|
|
1652
|
+
"domainVerified" in provider &&
|
|
1653
|
+
(provider as { domainVerified?: boolean }).domainVerified === true &&
|
|
1654
|
+
validateEmailDomain(userInfo.email, provider.domain);
|
|
1655
|
+
|
|
1656
|
+
const linked = await handleOAuthUserInfo(ctx, {
|
|
1657
|
+
userInfo: {
|
|
1658
|
+
email: userInfo.email,
|
|
1659
|
+
name: userInfo.name || userInfo.email,
|
|
1660
|
+
id: userInfo.id,
|
|
1661
|
+
image: userInfo.image,
|
|
1662
|
+
emailVerified: options?.trustEmailVerified
|
|
1663
|
+
? userInfo.emailVerified || false
|
|
1664
|
+
: false,
|
|
1665
|
+
},
|
|
1666
|
+
account: {
|
|
1667
|
+
idToken: tokenResponse.idToken,
|
|
1668
|
+
accessToken: tokenResponse.accessToken,
|
|
1669
|
+
refreshToken: tokenResponse.refreshToken,
|
|
1670
|
+
accountId: userInfo.id,
|
|
1671
|
+
providerId: provider.providerId,
|
|
1672
|
+
accessTokenExpiresAt: tokenResponse.accessTokenExpiresAt,
|
|
1673
|
+
refreshTokenExpiresAt: tokenResponse.refreshTokenExpiresAt,
|
|
1674
|
+
scope: tokenResponse.scopes?.join(","),
|
|
1675
|
+
},
|
|
1676
|
+
callbackURL,
|
|
1677
|
+
disableSignUp: options?.disableImplicitSignUp && !requestSignUp,
|
|
1678
|
+
overrideUserInfo: config.overrideUserInfo,
|
|
1679
|
+
isTrustedProvider,
|
|
1680
|
+
});
|
|
1681
|
+
if (linked.error) {
|
|
1682
|
+
throw ctx.redirect(
|
|
1683
|
+
`${errorURL || callbackURL}/error?error=${linked.error}`,
|
|
1684
|
+
);
|
|
1685
|
+
}
|
|
1686
|
+
const { session, user } = linked.data!;
|
|
1687
|
+
|
|
1688
|
+
if (options?.provisionUser) {
|
|
1689
|
+
await options.provisionUser({
|
|
1690
|
+
user,
|
|
1691
|
+
userInfo,
|
|
1692
|
+
token: tokenResponse,
|
|
1693
|
+
provider,
|
|
1694
|
+
});
|
|
1695
|
+
}
|
|
1696
|
+
|
|
1697
|
+
await assignOrganizationFromProvider(ctx as any, {
|
|
1698
|
+
user,
|
|
1699
|
+
profile: {
|
|
1700
|
+
providerType: "oidc",
|
|
1701
|
+
providerId: provider.providerId,
|
|
1702
|
+
accountId: userInfo.id,
|
|
1703
|
+
email: userInfo.email,
|
|
1704
|
+
emailVerified: Boolean(userInfo.emailVerified),
|
|
1705
|
+
rawAttributes: userInfo,
|
|
1706
|
+
},
|
|
1707
|
+
provider,
|
|
1708
|
+
token: tokenResponse,
|
|
1709
|
+
provisioningOptions: options?.organizationProvisioning,
|
|
1710
|
+
});
|
|
1711
|
+
|
|
1712
|
+
await setSessionCookie(ctx, {
|
|
1713
|
+
session,
|
|
1714
|
+
user,
|
|
1715
|
+
});
|
|
1716
|
+
let toRedirectTo: string;
|
|
1717
|
+
try {
|
|
1718
|
+
const url = linked.isRegister ? newUserURL || callbackURL : callbackURL;
|
|
1719
|
+
toRedirectTo = url.toString();
|
|
1720
|
+
} catch {
|
|
1721
|
+
toRedirectTo = linked.isRegister
|
|
1722
|
+
? newUserURL || callbackURL
|
|
1723
|
+
: callbackURL;
|
|
1724
|
+
}
|
|
1725
|
+
throw ctx.redirect(toRedirectTo);
|
|
1726
|
+
},
|
|
1727
|
+
);
|
|
1728
|
+
};
|
|
1729
|
+
|
|
1730
|
+
const callbackSSOSAMLBodySchema = z.object({
|
|
1731
|
+
SAMLResponse: z.string(),
|
|
1732
|
+
RelayState: z.string().optional(),
|
|
1733
|
+
});
|
|
1734
|
+
|
|
1735
|
+
/**
|
|
1736
|
+
* Validates and returns a safe redirect URL.
|
|
1737
|
+
* - Prevents open redirect attacks by validating against trusted origins
|
|
1738
|
+
* - Prevents redirect loops by checking if URL points to callback route
|
|
1739
|
+
* - Falls back to appOrigin if URL is invalid or unsafe
|
|
1740
|
+
*/
|
|
1741
|
+
const getSafeRedirectUrl = (
|
|
1742
|
+
url: string | undefined,
|
|
1743
|
+
callbackPath: string,
|
|
1744
|
+
appOrigin: string,
|
|
1745
|
+
isTrustedOrigin: (
|
|
1746
|
+
url: string,
|
|
1747
|
+
settings?: { allowRelativePaths: boolean },
|
|
1748
|
+
) => boolean,
|
|
1749
|
+
): string => {
|
|
1750
|
+
if (!url) {
|
|
1751
|
+
return appOrigin;
|
|
1752
|
+
}
|
|
1753
|
+
|
|
1754
|
+
if (url.startsWith("/") && !url.startsWith("//")) {
|
|
1755
|
+
try {
|
|
1756
|
+
const absoluteUrl = new URL(url, appOrigin);
|
|
1757
|
+
if (absoluteUrl.origin !== appOrigin) {
|
|
1758
|
+
return appOrigin;
|
|
1759
|
+
}
|
|
1760
|
+
const callbackPathname = new URL(callbackPath).pathname;
|
|
1761
|
+
if (absoluteUrl.pathname === callbackPathname) {
|
|
1762
|
+
return appOrigin;
|
|
1763
|
+
}
|
|
1764
|
+
} catch {
|
|
1765
|
+
return appOrigin;
|
|
1766
|
+
}
|
|
1767
|
+
return url;
|
|
1768
|
+
}
|
|
1769
|
+
|
|
1770
|
+
if (!isTrustedOrigin(url, { allowRelativePaths: false })) {
|
|
1771
|
+
return appOrigin;
|
|
1772
|
+
}
|
|
1773
|
+
|
|
1774
|
+
try {
|
|
1775
|
+
const callbackPathname = new URL(callbackPath).pathname;
|
|
1776
|
+
const urlPathname = new URL(url).pathname;
|
|
1777
|
+
if (urlPathname === callbackPathname) {
|
|
1778
|
+
return appOrigin;
|
|
1779
|
+
}
|
|
1780
|
+
} catch {
|
|
1781
|
+
if (url === callbackPath || url.startsWith(`${callbackPath}?`)) {
|
|
1782
|
+
return appOrigin;
|
|
1783
|
+
}
|
|
1784
|
+
}
|
|
1785
|
+
|
|
1786
|
+
return url;
|
|
1787
|
+
};
|
|
1788
|
+
|
|
1789
|
+
export const callbackSSOSAML = (options?: SSOOptions) => {
|
|
1790
|
+
return createAuthEndpoint(
|
|
1791
|
+
"/sso/saml2/callback/:providerId",
|
|
1792
|
+
{
|
|
1793
|
+
method: ["GET", "POST"],
|
|
1794
|
+
body: callbackSSOSAMLBodySchema.optional(),
|
|
1795
|
+
query: z
|
|
1796
|
+
.object({
|
|
1797
|
+
RelayState: z.string().optional(),
|
|
1798
|
+
})
|
|
1799
|
+
.optional(),
|
|
1800
|
+
metadata: {
|
|
1801
|
+
...HIDE_METADATA,
|
|
1802
|
+
allowedMediaTypes: [
|
|
1803
|
+
"application/x-www-form-urlencoded",
|
|
1804
|
+
"application/json",
|
|
1805
|
+
],
|
|
1806
|
+
openapi: {
|
|
1807
|
+
operationId: "handleSAMLCallback",
|
|
1808
|
+
summary: "Callback URL for SAML provider",
|
|
1809
|
+
description:
|
|
1810
|
+
"This endpoint is used as the callback URL for SAML providers. Supports both GET and POST methods for IdP-initiated and SP-initiated flows.",
|
|
1811
|
+
responses: {
|
|
1812
|
+
"302": {
|
|
1813
|
+
description: "Redirects to the callback URL",
|
|
1814
|
+
},
|
|
1815
|
+
"400": {
|
|
1816
|
+
description: "Invalid SAML response",
|
|
1817
|
+
},
|
|
1818
|
+
"401": {
|
|
1819
|
+
description: "Unauthorized - SAML authentication failed",
|
|
1820
|
+
},
|
|
1821
|
+
},
|
|
1822
|
+
},
|
|
1823
|
+
},
|
|
1824
|
+
},
|
|
1825
|
+
async (ctx) => {
|
|
1826
|
+
const { providerId } = ctx.params;
|
|
1827
|
+
const appOrigin = new URL(ctx.context.baseURL).origin;
|
|
1828
|
+
const errorURL =
|
|
1829
|
+
ctx.context.options.onAPIError?.errorURL || `${appOrigin}/error`;
|
|
1830
|
+
const currentCallbackPath = `${ctx.context.baseURL}/sso/saml2/callback/${providerId}`;
|
|
1831
|
+
|
|
1832
|
+
// Determine if this is a GET request by checking both method AND body presence
|
|
1833
|
+
// When called via auth.api.*, ctx.method may not be reliable, so we also check for body
|
|
1834
|
+
const isGetRequest = ctx.method === "GET" && !ctx.body?.SAMLResponse;
|
|
1835
|
+
|
|
1836
|
+
if (isGetRequest) {
|
|
1837
|
+
const session = await getSessionFromCtx(ctx);
|
|
1838
|
+
|
|
1839
|
+
if (!session?.session) {
|
|
1840
|
+
throw ctx.redirect(`${errorURL}?error=invalid_request`);
|
|
1841
|
+
}
|
|
1842
|
+
|
|
1843
|
+
const relayState = ctx.query?.RelayState as string | undefined;
|
|
1844
|
+
const safeRedirectUrl = getSafeRedirectUrl(
|
|
1845
|
+
relayState,
|
|
1846
|
+
currentCallbackPath,
|
|
1847
|
+
appOrigin,
|
|
1848
|
+
(url, settings) => ctx.context.isTrustedOrigin(url, settings),
|
|
1849
|
+
);
|
|
1850
|
+
|
|
1851
|
+
throw ctx.redirect(safeRedirectUrl);
|
|
1852
|
+
}
|
|
1853
|
+
|
|
1854
|
+
if (!ctx.body?.SAMLResponse) {
|
|
1855
|
+
throw new APIError("BAD_REQUEST", {
|
|
1856
|
+
message: "SAMLResponse is required for POST requests",
|
|
1857
|
+
});
|
|
1858
|
+
}
|
|
1859
|
+
|
|
1860
|
+
const { SAMLResponse } = ctx.body;
|
|
1861
|
+
|
|
1862
|
+
const maxResponseSize =
|
|
1863
|
+
options?.saml?.maxResponseSize ?? DEFAULT_MAX_SAML_RESPONSE_SIZE;
|
|
1864
|
+
if (new TextEncoder().encode(SAMLResponse).length > maxResponseSize) {
|
|
1865
|
+
throw new APIError("BAD_REQUEST", {
|
|
1866
|
+
message: `SAML response exceeds maximum allowed size (${maxResponseSize} bytes)`,
|
|
1867
|
+
});
|
|
1868
|
+
}
|
|
1869
|
+
|
|
1870
|
+
let relayState: RelayState | null = null;
|
|
1871
|
+
if (ctx.body.RelayState) {
|
|
1872
|
+
try {
|
|
1873
|
+
relayState = await parseRelayState(ctx);
|
|
1874
|
+
} catch {
|
|
1875
|
+
relayState = null;
|
|
1876
|
+
}
|
|
1877
|
+
}
|
|
1878
|
+
let provider: SSOProvider<SSOOptions> | null = null;
|
|
1879
|
+
if (options?.defaultSSO?.length) {
|
|
1880
|
+
const matchingDefault = options.defaultSSO.find(
|
|
1881
|
+
(defaultProvider) => defaultProvider.providerId === providerId,
|
|
1882
|
+
);
|
|
1883
|
+
if (matchingDefault) {
|
|
1884
|
+
provider = {
|
|
1885
|
+
...matchingDefault,
|
|
1886
|
+
userId: "default",
|
|
1887
|
+
issuer: matchingDefault.samlConfig?.issuer || "",
|
|
1888
|
+
...(options.domainVerification?.enabled
|
|
1889
|
+
? { domainVerified: true }
|
|
1890
|
+
: {}),
|
|
1891
|
+
} as SSOProvider<SSOOptions>;
|
|
1892
|
+
}
|
|
1893
|
+
}
|
|
1894
|
+
if (!provider) {
|
|
1895
|
+
provider = await ctx.context.adapter
|
|
1896
|
+
.findOne<SSOProvider<SSOOptions>>({
|
|
1897
|
+
model: "ssoProvider",
|
|
1898
|
+
where: [{ field: "providerId", value: providerId }],
|
|
1899
|
+
})
|
|
1900
|
+
.then((res) => {
|
|
1901
|
+
if (!res) return null;
|
|
1902
|
+
return {
|
|
1903
|
+
...res,
|
|
1904
|
+
samlConfig: res.samlConfig
|
|
1905
|
+
? safeJsonParse<SAMLConfig>(
|
|
1906
|
+
res.samlConfig as unknown as string,
|
|
1907
|
+
) || undefined
|
|
1908
|
+
: undefined,
|
|
1909
|
+
};
|
|
1910
|
+
});
|
|
1911
|
+
}
|
|
1912
|
+
|
|
1913
|
+
if (!provider) {
|
|
1914
|
+
throw new APIError("NOT_FOUND", {
|
|
1915
|
+
message: "No provider found for the given providerId",
|
|
1916
|
+
});
|
|
1917
|
+
}
|
|
1918
|
+
|
|
1919
|
+
if (
|
|
1920
|
+
options?.domainVerification?.enabled &&
|
|
1921
|
+
!("domainVerified" in provider && provider.domainVerified)
|
|
1922
|
+
) {
|
|
1923
|
+
throw new APIError("UNAUTHORIZED", {
|
|
1924
|
+
message: "Provider domain has not been verified",
|
|
1925
|
+
});
|
|
1926
|
+
}
|
|
1927
|
+
|
|
1928
|
+
const parsedSamlConfig = safeJsonParse<SAMLConfig>(
|
|
1929
|
+
provider.samlConfig as unknown as string,
|
|
1930
|
+
);
|
|
1931
|
+
if (!parsedSamlConfig) {
|
|
1932
|
+
throw new APIError("BAD_REQUEST", {
|
|
1933
|
+
message: "Invalid SAML configuration",
|
|
1934
|
+
});
|
|
1935
|
+
}
|
|
1936
|
+
const idpData = parsedSamlConfig.idpMetadata;
|
|
1937
|
+
let idp: IdentityProvider | null = null;
|
|
1938
|
+
|
|
1939
|
+
// Construct IDP with fallback to manual configuration
|
|
1940
|
+
if (!idpData?.metadata) {
|
|
1941
|
+
idp = saml.IdentityProvider({
|
|
1942
|
+
entityID: idpData?.entityID || parsedSamlConfig.issuer,
|
|
1943
|
+
singleSignOnService: [
|
|
1944
|
+
{
|
|
1945
|
+
Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect",
|
|
1946
|
+
Location: parsedSamlConfig.entryPoint,
|
|
1947
|
+
},
|
|
1948
|
+
],
|
|
1949
|
+
signingCert: idpData?.cert || parsedSamlConfig.cert,
|
|
1950
|
+
wantAuthnRequestsSigned:
|
|
1951
|
+
parsedSamlConfig.wantAssertionsSigned || false,
|
|
1952
|
+
isAssertionEncrypted: idpData?.isAssertionEncrypted || false,
|
|
1953
|
+
encPrivateKey: idpData?.encPrivateKey,
|
|
1954
|
+
encPrivateKeyPass: idpData?.encPrivateKeyPass,
|
|
1955
|
+
});
|
|
1956
|
+
} else {
|
|
1957
|
+
idp = saml.IdentityProvider({
|
|
1958
|
+
metadata: idpData.metadata,
|
|
1959
|
+
privateKey: idpData.privateKey,
|
|
1960
|
+
privateKeyPass: idpData.privateKeyPass,
|
|
1961
|
+
isAssertionEncrypted: idpData.isAssertionEncrypted,
|
|
1962
|
+
encPrivateKey: idpData.encPrivateKey,
|
|
1963
|
+
encPrivateKeyPass: idpData.encPrivateKeyPass,
|
|
1964
|
+
});
|
|
1965
|
+
}
|
|
1966
|
+
|
|
1967
|
+
// Construct SP with fallback to manual configuration
|
|
1968
|
+
const spData = parsedSamlConfig.spMetadata;
|
|
1969
|
+
const sp = saml.ServiceProvider({
|
|
1970
|
+
metadata: spData?.metadata,
|
|
1971
|
+
entityID: spData?.entityID || parsedSamlConfig.issuer,
|
|
1972
|
+
assertionConsumerService: spData?.metadata
|
|
1973
|
+
? undefined
|
|
1974
|
+
: [
|
|
1975
|
+
{
|
|
1976
|
+
Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
|
|
1977
|
+
Location: parsedSamlConfig.callbackUrl,
|
|
1978
|
+
},
|
|
1979
|
+
],
|
|
1980
|
+
privateKey: spData?.privateKey || parsedSamlConfig.privateKey,
|
|
1981
|
+
privateKeyPass: spData?.privateKeyPass,
|
|
1982
|
+
isAssertionEncrypted: spData?.isAssertionEncrypted || false,
|
|
1983
|
+
encPrivateKey: spData?.encPrivateKey,
|
|
1984
|
+
encPrivateKeyPass: spData?.encPrivateKeyPass,
|
|
1985
|
+
wantMessageSigned: parsedSamlConfig.wantAssertionsSigned || false,
|
|
1986
|
+
nameIDFormat: parsedSamlConfig.identifierFormat
|
|
1987
|
+
? [parsedSamlConfig.identifierFormat]
|
|
1988
|
+
: undefined,
|
|
1989
|
+
});
|
|
1990
|
+
|
|
1991
|
+
validateSingleAssertion(SAMLResponse);
|
|
1992
|
+
|
|
1993
|
+
let parsedResponse: FlowResult;
|
|
1994
|
+
try {
|
|
1995
|
+
parsedResponse = await sp.parseLoginResponse(idp, "post", {
|
|
1996
|
+
body: {
|
|
1997
|
+
SAMLResponse,
|
|
1998
|
+
RelayState: ctx.body.RelayState || undefined,
|
|
1999
|
+
},
|
|
2000
|
+
});
|
|
2001
|
+
|
|
2002
|
+
if (!parsedResponse?.extract) {
|
|
2003
|
+
throw new Error("Invalid SAML response structure");
|
|
2004
|
+
}
|
|
2005
|
+
} catch (error) {
|
|
2006
|
+
ctx.context.logger.error("SAML response validation failed", {
|
|
2007
|
+
error,
|
|
2008
|
+
decodedResponse: Buffer.from(SAMLResponse, "base64").toString(
|
|
2009
|
+
"utf-8",
|
|
2010
|
+
),
|
|
2011
|
+
});
|
|
2012
|
+
throw new APIError("BAD_REQUEST", {
|
|
2013
|
+
message: "Invalid SAML response",
|
|
2014
|
+
details: error instanceof Error ? error.message : String(error),
|
|
2015
|
+
});
|
|
2016
|
+
}
|
|
2017
|
+
|
|
2018
|
+
const { extract } = parsedResponse!;
|
|
2019
|
+
|
|
2020
|
+
validateSAMLAlgorithms(parsedResponse, options?.saml?.algorithms);
|
|
2021
|
+
|
|
2022
|
+
validateSAMLTimestamp((extract as any).conditions, {
|
|
2023
|
+
clockSkew: options?.saml?.clockSkew,
|
|
2024
|
+
requireTimestamps: options?.saml?.requireTimestamps,
|
|
2025
|
+
logger: ctx.context.logger,
|
|
2026
|
+
});
|
|
2027
|
+
|
|
2028
|
+
const inResponseTo = (extract as any).inResponseTo as string | undefined;
|
|
2029
|
+
const shouldValidateInResponseTo =
|
|
2030
|
+
options?.saml?.enableInResponseToValidation;
|
|
2031
|
+
|
|
2032
|
+
if (shouldValidateInResponseTo) {
|
|
2033
|
+
const allowIdpInitiated = options?.saml?.allowIdpInitiated !== false;
|
|
2034
|
+
|
|
2035
|
+
if (inResponseTo) {
|
|
2036
|
+
let storedRequest: AuthnRequestRecord | null = null;
|
|
2037
|
+
|
|
2038
|
+
const verification =
|
|
2039
|
+
await ctx.context.internalAdapter.findVerificationValue(
|
|
2040
|
+
`${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`,
|
|
2041
|
+
);
|
|
2042
|
+
if (verification) {
|
|
2043
|
+
try {
|
|
2044
|
+
storedRequest = JSON.parse(
|
|
2045
|
+
verification.value,
|
|
2046
|
+
) as AuthnRequestRecord;
|
|
2047
|
+
if (storedRequest && storedRequest.expiresAt < Date.now()) {
|
|
2048
|
+
storedRequest = null;
|
|
2049
|
+
}
|
|
2050
|
+
} catch {
|
|
2051
|
+
storedRequest = null;
|
|
2052
|
+
}
|
|
2053
|
+
}
|
|
2054
|
+
|
|
2055
|
+
if (!storedRequest) {
|
|
2056
|
+
ctx.context.logger.error(
|
|
2057
|
+
"SAML InResponseTo validation failed: unknown or expired request ID",
|
|
2058
|
+
{ inResponseTo, providerId: provider.providerId },
|
|
2059
|
+
);
|
|
2060
|
+
const redirectUrl =
|
|
2061
|
+
relayState?.callbackURL ||
|
|
2062
|
+
parsedSamlConfig.callbackUrl ||
|
|
2063
|
+
ctx.context.baseURL;
|
|
2064
|
+
throw ctx.redirect(
|
|
2065
|
+
`${redirectUrl}?error=invalid_saml_response&error_description=Unknown+or+expired+request+ID`,
|
|
2066
|
+
);
|
|
2067
|
+
}
|
|
2068
|
+
|
|
2069
|
+
if (storedRequest.providerId !== provider.providerId) {
|
|
2070
|
+
ctx.context.logger.error(
|
|
2071
|
+
"SAML InResponseTo validation failed: provider mismatch",
|
|
2072
|
+
{
|
|
2073
|
+
inResponseTo,
|
|
2074
|
+
expectedProvider: storedRequest.providerId,
|
|
2075
|
+
actualProvider: provider.providerId,
|
|
2076
|
+
},
|
|
2077
|
+
);
|
|
2078
|
+
|
|
2079
|
+
await ctx.context.internalAdapter.deleteVerificationByIdentifier(
|
|
2080
|
+
`${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`,
|
|
2081
|
+
);
|
|
2082
|
+
const redirectUrl =
|
|
2083
|
+
relayState?.callbackURL ||
|
|
2084
|
+
parsedSamlConfig.callbackUrl ||
|
|
2085
|
+
ctx.context.baseURL;
|
|
2086
|
+
throw ctx.redirect(
|
|
2087
|
+
`${redirectUrl}?error=invalid_saml_response&error_description=Provider+mismatch`,
|
|
2088
|
+
);
|
|
2089
|
+
}
|
|
2090
|
+
|
|
2091
|
+
await ctx.context.internalAdapter.deleteVerificationByIdentifier(
|
|
2092
|
+
`${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`,
|
|
2093
|
+
);
|
|
2094
|
+
} else if (!allowIdpInitiated) {
|
|
2095
|
+
ctx.context.logger.error(
|
|
2096
|
+
"SAML IdP-initiated SSO rejected: InResponseTo missing and allowIdpInitiated is false",
|
|
2097
|
+
{ providerId: provider.providerId },
|
|
2098
|
+
);
|
|
2099
|
+
const redirectUrl =
|
|
2100
|
+
relayState?.callbackURL ||
|
|
2101
|
+
parsedSamlConfig.callbackUrl ||
|
|
2102
|
+
ctx.context.baseURL;
|
|
2103
|
+
throw ctx.redirect(
|
|
2104
|
+
`${redirectUrl}?error=unsolicited_response&error_description=IdP-initiated+SSO+not+allowed`,
|
|
2105
|
+
);
|
|
2106
|
+
}
|
|
2107
|
+
}
|
|
2108
|
+
|
|
2109
|
+
// Assertion Replay Protection
|
|
2110
|
+
const samlContent = (parsedResponse as any).samlContent as
|
|
2111
|
+
| string
|
|
2112
|
+
| undefined;
|
|
2113
|
+
const assertionId = samlContent ? extractAssertionId(samlContent) : null;
|
|
2114
|
+
|
|
2115
|
+
if (assertionId) {
|
|
2116
|
+
const issuer = idp.entityMeta.getEntityID();
|
|
2117
|
+
const conditions = (extract as any).conditions as
|
|
2118
|
+
| SAMLConditions
|
|
2119
|
+
| undefined;
|
|
2120
|
+
const clockSkew = options?.saml?.clockSkew ?? DEFAULT_CLOCK_SKEW_MS;
|
|
2121
|
+
const expiresAt = conditions?.notOnOrAfter
|
|
2122
|
+
? new Date(conditions.notOnOrAfter).getTime() + clockSkew
|
|
2123
|
+
: Date.now() + DEFAULT_ASSERTION_TTL_MS;
|
|
2124
|
+
|
|
2125
|
+
const existingAssertion =
|
|
2126
|
+
await ctx.context.internalAdapter.findVerificationValue(
|
|
2127
|
+
`${USED_ASSERTION_KEY_PREFIX}${assertionId}`,
|
|
2128
|
+
);
|
|
2129
|
+
|
|
2130
|
+
let isReplay = false;
|
|
2131
|
+
if (existingAssertion) {
|
|
2132
|
+
try {
|
|
2133
|
+
const stored = JSON.parse(existingAssertion.value);
|
|
2134
|
+
if (stored.expiresAt >= Date.now()) {
|
|
2135
|
+
isReplay = true;
|
|
2136
|
+
}
|
|
2137
|
+
} catch (error) {
|
|
2138
|
+
ctx.context.logger.warn("Failed to parse stored assertion record", {
|
|
2139
|
+
assertionId,
|
|
2140
|
+
error,
|
|
2141
|
+
});
|
|
2142
|
+
}
|
|
2143
|
+
}
|
|
2144
|
+
|
|
2145
|
+
if (isReplay) {
|
|
2146
|
+
ctx.context.logger.error(
|
|
2147
|
+
"SAML assertion replay detected: assertion ID already used",
|
|
2148
|
+
{
|
|
2149
|
+
assertionId,
|
|
2150
|
+
issuer,
|
|
2151
|
+
providerId: provider.providerId,
|
|
2152
|
+
},
|
|
2153
|
+
);
|
|
2154
|
+
const redirectUrl =
|
|
2155
|
+
relayState?.callbackURL ||
|
|
2156
|
+
parsedSamlConfig.callbackUrl ||
|
|
2157
|
+
ctx.context.baseURL;
|
|
2158
|
+
throw ctx.redirect(
|
|
2159
|
+
`${redirectUrl}?error=replay_detected&error_description=SAML+assertion+has+already+been+used`,
|
|
2160
|
+
);
|
|
2161
|
+
}
|
|
2162
|
+
|
|
2163
|
+
await ctx.context.internalAdapter.createVerificationValue({
|
|
2164
|
+
identifier: `${USED_ASSERTION_KEY_PREFIX}${assertionId}`,
|
|
2165
|
+
value: JSON.stringify({
|
|
2166
|
+
assertionId,
|
|
2167
|
+
issuer,
|
|
2168
|
+
providerId: provider.providerId,
|
|
2169
|
+
usedAt: Date.now(),
|
|
2170
|
+
expiresAt,
|
|
2171
|
+
}),
|
|
2172
|
+
expiresAt: new Date(expiresAt),
|
|
2173
|
+
});
|
|
2174
|
+
} else {
|
|
2175
|
+
ctx.context.logger.warn(
|
|
2176
|
+
"Could not extract assertion ID for replay protection",
|
|
2177
|
+
{ providerId: provider.providerId },
|
|
2178
|
+
);
|
|
2179
|
+
}
|
|
2180
|
+
|
|
2181
|
+
const attributes = extract.attributes || {};
|
|
2182
|
+
const mapping = parsedSamlConfig.mapping ?? {};
|
|
2183
|
+
|
|
2184
|
+
const userInfo = {
|
|
2185
|
+
...Object.fromEntries(
|
|
2186
|
+
Object.entries(mapping.extraFields || {}).map(([key, value]) => [
|
|
2187
|
+
key,
|
|
2188
|
+
attributes[value as string],
|
|
2189
|
+
]),
|
|
2190
|
+
),
|
|
2191
|
+
id: attributes[mapping.id || "nameID"] || extract.nameID,
|
|
2192
|
+
email: (
|
|
2193
|
+
attributes[mapping.email || "email"] || extract.nameID
|
|
2194
|
+
).toLowerCase(),
|
|
2195
|
+
name:
|
|
2196
|
+
[
|
|
2197
|
+
attributes[mapping.firstName || "givenName"],
|
|
2198
|
+
attributes[mapping.lastName || "surname"],
|
|
2199
|
+
]
|
|
2200
|
+
.filter(Boolean)
|
|
2201
|
+
.join(" ") ||
|
|
2202
|
+
attributes[mapping.name || "displayName"] ||
|
|
2203
|
+
extract.nameID,
|
|
2204
|
+
emailVerified:
|
|
2205
|
+
options?.trustEmailVerified && mapping.emailVerified
|
|
2206
|
+
? ((attributes[mapping.emailVerified] || false) as boolean)
|
|
2207
|
+
: false,
|
|
2208
|
+
};
|
|
2209
|
+
if (!userInfo.id || !userInfo.email) {
|
|
2210
|
+
ctx.context.logger.error(
|
|
2211
|
+
"Missing essential user info from SAML response",
|
|
2212
|
+
{
|
|
2213
|
+
attributes: Object.keys(attributes),
|
|
2214
|
+
mapping,
|
|
2215
|
+
extractedId: userInfo.id,
|
|
2216
|
+
extractedEmail: userInfo.email,
|
|
2217
|
+
},
|
|
2218
|
+
);
|
|
2219
|
+
throw new APIError("BAD_REQUEST", {
|
|
2220
|
+
message: "Unable to extract user ID or email from SAML response",
|
|
2221
|
+
});
|
|
2222
|
+
}
|
|
2223
|
+
|
|
2224
|
+
const isTrustedProvider: boolean =
|
|
2225
|
+
!!ctx.context.options.account?.accountLinking?.trustedProviders?.includes(
|
|
2226
|
+
provider.providerId,
|
|
2227
|
+
) ||
|
|
2228
|
+
("domainVerified" in provider &&
|
|
2229
|
+
!!(provider as { domainVerified?: boolean }).domainVerified &&
|
|
2230
|
+
validateEmailDomain(userInfo.email as string, provider.domain));
|
|
2231
|
+
|
|
2232
|
+
const callbackUrl =
|
|
2233
|
+
relayState?.callbackURL ||
|
|
2234
|
+
parsedSamlConfig.callbackUrl ||
|
|
2235
|
+
ctx.context.baseURL;
|
|
2236
|
+
|
|
2237
|
+
const result = await handleOAuthUserInfo(ctx, {
|
|
2238
|
+
userInfo: {
|
|
2239
|
+
email: userInfo.email as string,
|
|
2240
|
+
name: (userInfo.name || userInfo.email) as string,
|
|
2241
|
+
id: userInfo.id as string,
|
|
2242
|
+
emailVerified: Boolean(userInfo.emailVerified),
|
|
2243
|
+
},
|
|
2244
|
+
account: {
|
|
2245
|
+
providerId: provider.providerId,
|
|
2246
|
+
accountId: userInfo.id as string,
|
|
2247
|
+
accessToken: "",
|
|
2248
|
+
refreshToken: "",
|
|
2249
|
+
},
|
|
2250
|
+
callbackURL: callbackUrl,
|
|
2251
|
+
disableSignUp: options?.disableImplicitSignUp,
|
|
2252
|
+
isTrustedProvider,
|
|
2253
|
+
});
|
|
2254
|
+
|
|
2255
|
+
if (result.error) {
|
|
2256
|
+
throw ctx.redirect(
|
|
2257
|
+
`${callbackUrl}?error=${result.error.split(" ").join("_")}`,
|
|
2258
|
+
);
|
|
2259
|
+
}
|
|
2260
|
+
|
|
2261
|
+
const { session, user } = result.data!;
|
|
2262
|
+
|
|
2263
|
+
if (options?.provisionUser) {
|
|
2264
|
+
await options.provisionUser({
|
|
2265
|
+
user: user as User & Record<string, any>,
|
|
2266
|
+
userInfo,
|
|
2267
|
+
provider,
|
|
2268
|
+
});
|
|
2269
|
+
}
|
|
2270
|
+
|
|
2271
|
+
await assignOrganizationFromProvider(ctx as any, {
|
|
2272
|
+
user,
|
|
2273
|
+
profile: {
|
|
2274
|
+
providerType: "saml",
|
|
2275
|
+
providerId: provider.providerId,
|
|
2276
|
+
accountId: userInfo.id as string,
|
|
2277
|
+
email: userInfo.email as string,
|
|
2278
|
+
emailVerified: Boolean(userInfo.emailVerified),
|
|
2279
|
+
rawAttributes: attributes,
|
|
2280
|
+
},
|
|
2281
|
+
provider,
|
|
2282
|
+
provisioningOptions: options?.organizationProvisioning,
|
|
2283
|
+
});
|
|
2284
|
+
|
|
2285
|
+
await setSessionCookie(ctx, { session, user });
|
|
2286
|
+
|
|
2287
|
+
const safeRedirectUrl = getSafeRedirectUrl(
|
|
2288
|
+
relayState?.callbackURL || parsedSamlConfig.callbackUrl,
|
|
2289
|
+
currentCallbackPath,
|
|
2290
|
+
appOrigin,
|
|
2291
|
+
(url, settings) => ctx.context.isTrustedOrigin(url, settings),
|
|
2292
|
+
);
|
|
2293
|
+
throw ctx.redirect(safeRedirectUrl);
|
|
2294
|
+
},
|
|
2295
|
+
);
|
|
2296
|
+
};
|
|
2297
|
+
|
|
2298
|
+
const acsEndpointBodySchema = z.object({
|
|
2299
|
+
SAMLResponse: z.string(),
|
|
2300
|
+
RelayState: z.string().optional(),
|
|
2301
|
+
});
|
|
2302
|
+
|
|
2303
|
+
export const acsEndpoint = (options?: SSOOptions) => {
|
|
2304
|
+
return createAuthEndpoint(
|
|
2305
|
+
"/sso/saml2/sp/acs/:providerId",
|
|
2306
|
+
{
|
|
2307
|
+
method: "POST",
|
|
2308
|
+
body: acsEndpointBodySchema,
|
|
2309
|
+
metadata: {
|
|
2310
|
+
...HIDE_METADATA,
|
|
2311
|
+
allowedMediaTypes: [
|
|
2312
|
+
"application/x-www-form-urlencoded",
|
|
2313
|
+
"application/json",
|
|
2314
|
+
],
|
|
2315
|
+
openapi: {
|
|
2316
|
+
operationId: "handleSAMLAssertionConsumerService",
|
|
2317
|
+
summary: "SAML Assertion Consumer Service",
|
|
2318
|
+
description:
|
|
2319
|
+
"Handles SAML responses from IdP after successful authentication",
|
|
2320
|
+
responses: {
|
|
2321
|
+
"302": {
|
|
2322
|
+
description:
|
|
2323
|
+
"Redirects to the callback URL after successful authentication",
|
|
2324
|
+
},
|
|
2325
|
+
},
|
|
2326
|
+
},
|
|
2327
|
+
},
|
|
2328
|
+
},
|
|
2329
|
+
async (ctx) => {
|
|
2330
|
+
const { SAMLResponse, RelayState = "" } = ctx.body;
|
|
2331
|
+
const { providerId } = ctx.params;
|
|
2332
|
+
|
|
2333
|
+
const maxResponseSize =
|
|
2334
|
+
options?.saml?.maxResponseSize ?? DEFAULT_MAX_SAML_RESPONSE_SIZE;
|
|
2335
|
+
if (new TextEncoder().encode(SAMLResponse).length > maxResponseSize) {
|
|
2336
|
+
throw new APIError("BAD_REQUEST", {
|
|
2337
|
+
message: `SAML response exceeds maximum allowed size (${maxResponseSize} bytes)`,
|
|
2338
|
+
});
|
|
2339
|
+
}
|
|
2340
|
+
|
|
2341
|
+
// If defaultSSO is configured, use it as the provider
|
|
2342
|
+
let provider: SSOProvider<SSOOptions> | null = null;
|
|
2343
|
+
|
|
2344
|
+
if (options?.defaultSSO?.length) {
|
|
2345
|
+
// For ACS endpoint, we can use the first default provider or try to match by providerId
|
|
2346
|
+
const matchingDefault = providerId
|
|
2347
|
+
? options.defaultSSO.find(
|
|
2348
|
+
(defaultProvider) => defaultProvider.providerId === providerId,
|
|
2349
|
+
)
|
|
2350
|
+
: options.defaultSSO[0]; // Use first default provider if no specific providerId
|
|
2351
|
+
|
|
2352
|
+
if (matchingDefault) {
|
|
2353
|
+
provider = {
|
|
2354
|
+
issuer: matchingDefault.samlConfig?.issuer || "",
|
|
2355
|
+
providerId: matchingDefault.providerId,
|
|
2356
|
+
userId: "default",
|
|
2357
|
+
samlConfig: matchingDefault.samlConfig,
|
|
2358
|
+
domain: matchingDefault.domain,
|
|
2359
|
+
...(options.domainVerification?.enabled
|
|
2360
|
+
? { domainVerified: true }
|
|
2361
|
+
: {}),
|
|
2362
|
+
};
|
|
2363
|
+
}
|
|
2364
|
+
} else {
|
|
2365
|
+
provider = await ctx.context.adapter
|
|
2366
|
+
.findOne<SSOProvider<SSOOptions>>({
|
|
2367
|
+
model: "ssoProvider",
|
|
2368
|
+
where: [
|
|
2369
|
+
{
|
|
2370
|
+
field: "providerId",
|
|
2371
|
+
value: providerId,
|
|
2372
|
+
},
|
|
2373
|
+
],
|
|
2374
|
+
})
|
|
2375
|
+
.then((res) => {
|
|
2376
|
+
if (!res) return null;
|
|
2377
|
+
return {
|
|
2378
|
+
...res,
|
|
2379
|
+
samlConfig: res.samlConfig
|
|
2380
|
+
? safeJsonParse<SAMLConfig>(
|
|
2381
|
+
res.samlConfig as unknown as string,
|
|
2382
|
+
) || undefined
|
|
2383
|
+
: undefined,
|
|
2384
|
+
};
|
|
2385
|
+
});
|
|
2386
|
+
}
|
|
2387
|
+
|
|
2388
|
+
if (!provider?.samlConfig) {
|
|
2389
|
+
throw new APIError("NOT_FOUND", {
|
|
2390
|
+
message: "No SAML provider found",
|
|
2391
|
+
});
|
|
2392
|
+
}
|
|
2393
|
+
|
|
2394
|
+
if (
|
|
2395
|
+
options?.domainVerification?.enabled &&
|
|
2396
|
+
!("domainVerified" in provider && provider.domainVerified)
|
|
2397
|
+
) {
|
|
2398
|
+
throw new APIError("UNAUTHORIZED", {
|
|
2399
|
+
message: "Provider domain has not been verified",
|
|
2400
|
+
});
|
|
2401
|
+
}
|
|
2402
|
+
|
|
2403
|
+
const parsedSamlConfig = provider.samlConfig;
|
|
2404
|
+
// Configure SP and IdP
|
|
2405
|
+
const sp = saml.ServiceProvider({
|
|
2406
|
+
entityID:
|
|
2407
|
+
parsedSamlConfig.spMetadata?.entityID || parsedSamlConfig.issuer,
|
|
2408
|
+
assertionConsumerService: [
|
|
2409
|
+
{
|
|
2410
|
+
Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
|
|
2411
|
+
Location:
|
|
2412
|
+
parsedSamlConfig.callbackUrl ||
|
|
2413
|
+
`${ctx.context.baseURL}/sso/saml2/sp/acs/${providerId}`,
|
|
2414
|
+
},
|
|
2415
|
+
],
|
|
2416
|
+
wantMessageSigned: parsedSamlConfig.wantAssertionsSigned || false,
|
|
2417
|
+
metadata: parsedSamlConfig.spMetadata?.metadata,
|
|
2418
|
+
privateKey:
|
|
2419
|
+
parsedSamlConfig.spMetadata?.privateKey ||
|
|
2420
|
+
parsedSamlConfig.privateKey,
|
|
2421
|
+
privateKeyPass: parsedSamlConfig.spMetadata?.privateKeyPass,
|
|
2422
|
+
nameIDFormat: parsedSamlConfig.identifierFormat
|
|
2423
|
+
? [parsedSamlConfig.identifierFormat]
|
|
2424
|
+
: undefined,
|
|
2425
|
+
});
|
|
2426
|
+
|
|
2427
|
+
// Update where we construct the IdP
|
|
2428
|
+
const idpData = parsedSamlConfig.idpMetadata;
|
|
2429
|
+
const idp = !idpData?.metadata
|
|
2430
|
+
? saml.IdentityProvider({
|
|
2431
|
+
entityID: idpData?.entityID || parsedSamlConfig.issuer,
|
|
2432
|
+
singleSignOnService: idpData?.singleSignOnService || [
|
|
2433
|
+
{
|
|
2434
|
+
Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect",
|
|
2435
|
+
Location: parsedSamlConfig.entryPoint,
|
|
2436
|
+
},
|
|
2437
|
+
],
|
|
2438
|
+
signingCert: idpData?.cert || parsedSamlConfig.cert,
|
|
2439
|
+
})
|
|
2440
|
+
: saml.IdentityProvider({
|
|
2441
|
+
metadata: idpData.metadata,
|
|
2442
|
+
});
|
|
2443
|
+
|
|
2444
|
+
try {
|
|
2445
|
+
validateSingleAssertion(SAMLResponse);
|
|
2446
|
+
} catch (error) {
|
|
2447
|
+
if (error instanceof APIError) {
|
|
2448
|
+
const redirectUrl =
|
|
2449
|
+
RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
|
|
2450
|
+
const errorCode =
|
|
2451
|
+
error.body?.code === "SAML_MULTIPLE_ASSERTIONS"
|
|
2452
|
+
? "multiple_assertions"
|
|
2453
|
+
: "no_assertion";
|
|
2454
|
+
throw ctx.redirect(
|
|
2455
|
+
`${redirectUrl}?error=${errorCode}&error_description=${encodeURIComponent(error.message)}`,
|
|
2456
|
+
);
|
|
2457
|
+
}
|
|
2458
|
+
throw error;
|
|
2459
|
+
}
|
|
2460
|
+
|
|
2461
|
+
// Parse and validate SAML response
|
|
2462
|
+
let parsedResponse: FlowResult;
|
|
2463
|
+
try {
|
|
2464
|
+
parsedResponse = await sp.parseLoginResponse(idp, "post", {
|
|
2465
|
+
body: {
|
|
2466
|
+
SAMLResponse,
|
|
2467
|
+
RelayState: RelayState || undefined,
|
|
2468
|
+
},
|
|
2469
|
+
});
|
|
2470
|
+
|
|
2471
|
+
if (!parsedResponse?.extract) {
|
|
2472
|
+
throw new Error("Invalid SAML response structure");
|
|
2473
|
+
}
|
|
2474
|
+
} catch (error) {
|
|
2475
|
+
ctx.context.logger.error("SAML response validation failed", {
|
|
2476
|
+
error,
|
|
2477
|
+
decodedResponse: Buffer.from(SAMLResponse, "base64").toString(
|
|
2478
|
+
"utf-8",
|
|
2479
|
+
),
|
|
2480
|
+
});
|
|
2481
|
+
throw new APIError("BAD_REQUEST", {
|
|
2482
|
+
message: "Invalid SAML response",
|
|
2483
|
+
details: error instanceof Error ? error.message : String(error),
|
|
2484
|
+
});
|
|
2485
|
+
}
|
|
2486
|
+
|
|
2487
|
+
const { extract } = parsedResponse!;
|
|
2488
|
+
|
|
2489
|
+
validateSAMLAlgorithms(parsedResponse, options?.saml?.algorithms);
|
|
2490
|
+
|
|
2491
|
+
validateSAMLTimestamp((extract as any).conditions, {
|
|
2492
|
+
clockSkew: options?.saml?.clockSkew,
|
|
2493
|
+
requireTimestamps: options?.saml?.requireTimestamps,
|
|
2494
|
+
logger: ctx.context.logger,
|
|
2495
|
+
});
|
|
2496
|
+
|
|
2497
|
+
const inResponseToAcs = (extract as any).inResponseTo as
|
|
2498
|
+
| string
|
|
2499
|
+
| undefined;
|
|
2500
|
+
const shouldValidateInResponseToAcs =
|
|
2501
|
+
options?.saml?.enableInResponseToValidation;
|
|
2502
|
+
|
|
2503
|
+
if (shouldValidateInResponseToAcs) {
|
|
2504
|
+
const allowIdpInitiated = options?.saml?.allowIdpInitiated !== false;
|
|
2505
|
+
|
|
2506
|
+
if (inResponseToAcs) {
|
|
2507
|
+
let storedRequest: AuthnRequestRecord | null = null;
|
|
2508
|
+
|
|
2509
|
+
const verification =
|
|
2510
|
+
await ctx.context.internalAdapter.findVerificationValue(
|
|
2511
|
+
`${AUTHN_REQUEST_KEY_PREFIX}${inResponseToAcs}`,
|
|
2512
|
+
);
|
|
2513
|
+
if (verification) {
|
|
2514
|
+
try {
|
|
2515
|
+
storedRequest = JSON.parse(
|
|
2516
|
+
verification.value,
|
|
2517
|
+
) as AuthnRequestRecord;
|
|
2518
|
+
if (storedRequest && storedRequest.expiresAt < Date.now()) {
|
|
2519
|
+
storedRequest = null;
|
|
2520
|
+
}
|
|
2521
|
+
} catch {
|
|
2522
|
+
storedRequest = null;
|
|
2523
|
+
}
|
|
2524
|
+
}
|
|
2525
|
+
|
|
2526
|
+
if (!storedRequest) {
|
|
2527
|
+
ctx.context.logger.error(
|
|
2528
|
+
"SAML InResponseTo validation failed: unknown or expired request ID",
|
|
2529
|
+
{ inResponseTo: inResponseToAcs, providerId },
|
|
2530
|
+
);
|
|
2531
|
+
const redirectUrl =
|
|
2532
|
+
RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
|
|
2533
|
+
throw ctx.redirect(
|
|
2534
|
+
`${redirectUrl}?error=invalid_saml_response&error_description=Unknown+or+expired+request+ID`,
|
|
2535
|
+
);
|
|
2536
|
+
}
|
|
2537
|
+
|
|
2538
|
+
if (storedRequest.providerId !== providerId) {
|
|
2539
|
+
ctx.context.logger.error(
|
|
2540
|
+
"SAML InResponseTo validation failed: provider mismatch",
|
|
2541
|
+
{
|
|
2542
|
+
inResponseTo: inResponseToAcs,
|
|
2543
|
+
expectedProvider: storedRequest.providerId,
|
|
2544
|
+
actualProvider: providerId,
|
|
2545
|
+
},
|
|
2546
|
+
);
|
|
2547
|
+
await ctx.context.internalAdapter.deleteVerificationByIdentifier(
|
|
2548
|
+
`${AUTHN_REQUEST_KEY_PREFIX}${inResponseToAcs}`,
|
|
2549
|
+
);
|
|
2550
|
+
const redirectUrl =
|
|
2551
|
+
RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
|
|
2552
|
+
throw ctx.redirect(
|
|
2553
|
+
`${redirectUrl}?error=invalid_saml_response&error_description=Provider+mismatch`,
|
|
2554
|
+
);
|
|
2555
|
+
}
|
|
2556
|
+
|
|
2557
|
+
await ctx.context.internalAdapter.deleteVerificationByIdentifier(
|
|
2558
|
+
`${AUTHN_REQUEST_KEY_PREFIX}${inResponseToAcs}`,
|
|
2559
|
+
);
|
|
2560
|
+
} else if (!allowIdpInitiated) {
|
|
2561
|
+
ctx.context.logger.error(
|
|
2562
|
+
"SAML IdP-initiated SSO rejected: InResponseTo missing and allowIdpInitiated is false",
|
|
2563
|
+
{ providerId },
|
|
2564
|
+
);
|
|
2565
|
+
const redirectUrl =
|
|
2566
|
+
RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
|
|
2567
|
+
throw ctx.redirect(
|
|
2568
|
+
`${redirectUrl}?error=unsolicited_response&error_description=IdP-initiated+SSO+not+allowed`,
|
|
2569
|
+
);
|
|
2570
|
+
}
|
|
2571
|
+
}
|
|
2572
|
+
|
|
2573
|
+
// Assertion Replay Protection
|
|
2574
|
+
const samlContentAcs = Buffer.from(SAMLResponse, "base64").toString(
|
|
2575
|
+
"utf-8",
|
|
2576
|
+
);
|
|
2577
|
+
const assertionIdAcs = extractAssertionId(samlContentAcs);
|
|
2578
|
+
|
|
2579
|
+
if (assertionIdAcs) {
|
|
2580
|
+
const issuer = idp.entityMeta.getEntityID();
|
|
2581
|
+
const conditions = (extract as any).conditions as
|
|
2582
|
+
| SAMLConditions
|
|
2583
|
+
| undefined;
|
|
2584
|
+
const clockSkew = options?.saml?.clockSkew ?? DEFAULT_CLOCK_SKEW_MS;
|
|
2585
|
+
const expiresAt = conditions?.notOnOrAfter
|
|
2586
|
+
? new Date(conditions.notOnOrAfter).getTime() + clockSkew
|
|
2587
|
+
: Date.now() + DEFAULT_ASSERTION_TTL_MS;
|
|
2588
|
+
|
|
2589
|
+
const existingAssertion =
|
|
2590
|
+
await ctx.context.internalAdapter.findVerificationValue(
|
|
2591
|
+
`${USED_ASSERTION_KEY_PREFIX}${assertionIdAcs}`,
|
|
2592
|
+
);
|
|
2593
|
+
|
|
2594
|
+
let isReplay = false;
|
|
2595
|
+
if (existingAssertion) {
|
|
2596
|
+
try {
|
|
2597
|
+
const stored = JSON.parse(existingAssertion.value);
|
|
2598
|
+
if (stored.expiresAt >= Date.now()) {
|
|
2599
|
+
isReplay = true;
|
|
2600
|
+
}
|
|
2601
|
+
} catch (error) {
|
|
2602
|
+
ctx.context.logger.warn("Failed to parse stored assertion record", {
|
|
2603
|
+
assertionId: assertionIdAcs,
|
|
2604
|
+
error,
|
|
2605
|
+
});
|
|
2606
|
+
}
|
|
2607
|
+
}
|
|
2608
|
+
|
|
2609
|
+
if (isReplay) {
|
|
2610
|
+
ctx.context.logger.error(
|
|
2611
|
+
"SAML assertion replay detected: assertion ID already used",
|
|
2612
|
+
{
|
|
2613
|
+
assertionId: assertionIdAcs,
|
|
2614
|
+
issuer,
|
|
2615
|
+
providerId,
|
|
2616
|
+
},
|
|
2617
|
+
);
|
|
2618
|
+
const redirectUrl =
|
|
2619
|
+
RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
|
|
2620
|
+
throw ctx.redirect(
|
|
2621
|
+
`${redirectUrl}?error=replay_detected&error_description=SAML+assertion+has+already+been+used`,
|
|
2622
|
+
);
|
|
2623
|
+
}
|
|
2624
|
+
|
|
2625
|
+
await ctx.context.internalAdapter.createVerificationValue({
|
|
2626
|
+
identifier: `${USED_ASSERTION_KEY_PREFIX}${assertionIdAcs}`,
|
|
2627
|
+
value: JSON.stringify({
|
|
2628
|
+
assertionId: assertionIdAcs,
|
|
2629
|
+
issuer,
|
|
2630
|
+
providerId,
|
|
2631
|
+
usedAt: Date.now(),
|
|
2632
|
+
expiresAt,
|
|
2633
|
+
}),
|
|
2634
|
+
expiresAt: new Date(expiresAt),
|
|
2635
|
+
});
|
|
2636
|
+
} else {
|
|
2637
|
+
ctx.context.logger.warn(
|
|
2638
|
+
"Could not extract assertion ID for replay protection",
|
|
2639
|
+
{ providerId },
|
|
2640
|
+
);
|
|
2641
|
+
}
|
|
2642
|
+
|
|
2643
|
+
const attributes = extract.attributes || {};
|
|
2644
|
+
const mapping = parsedSamlConfig.mapping ?? {};
|
|
2645
|
+
|
|
2646
|
+
const userInfo = {
|
|
2647
|
+
...Object.fromEntries(
|
|
2648
|
+
Object.entries(mapping.extraFields || {}).map(([key, value]) => [
|
|
2649
|
+
key,
|
|
2650
|
+
attributes[value as string],
|
|
2651
|
+
]),
|
|
2652
|
+
),
|
|
2653
|
+
id: attributes[mapping.id || "nameID"] || extract.nameID,
|
|
2654
|
+
email: (
|
|
2655
|
+
attributes[mapping.email || "email"] || extract.nameID
|
|
2656
|
+
).toLowerCase(),
|
|
2657
|
+
name:
|
|
2658
|
+
[
|
|
2659
|
+
attributes[mapping.firstName || "givenName"],
|
|
2660
|
+
attributes[mapping.lastName || "surname"],
|
|
2661
|
+
]
|
|
2662
|
+
.filter(Boolean)
|
|
2663
|
+
.join(" ") ||
|
|
2664
|
+
attributes[mapping.name || "displayName"] ||
|
|
2665
|
+
extract.nameID,
|
|
2666
|
+
emailVerified:
|
|
2667
|
+
options?.trustEmailVerified && mapping.emailVerified
|
|
2668
|
+
? ((attributes[mapping.emailVerified] || false) as boolean)
|
|
2669
|
+
: false,
|
|
2670
|
+
};
|
|
2671
|
+
|
|
2672
|
+
if (!userInfo.id || !userInfo.email) {
|
|
2673
|
+
ctx.context.logger.error(
|
|
2674
|
+
"Missing essential user info from SAML response",
|
|
2675
|
+
{
|
|
2676
|
+
attributes: Object.keys(attributes),
|
|
2677
|
+
mapping,
|
|
2678
|
+
extractedId: userInfo.id,
|
|
2679
|
+
extractedEmail: userInfo.email,
|
|
2680
|
+
},
|
|
2681
|
+
);
|
|
2682
|
+
throw new APIError("BAD_REQUEST", {
|
|
2683
|
+
message: "Unable to extract user ID or email from SAML response",
|
|
2684
|
+
});
|
|
2685
|
+
}
|
|
2686
|
+
|
|
2687
|
+
const isTrustedProvider: boolean =
|
|
2688
|
+
!!ctx.context.options.account?.accountLinking?.trustedProviders?.includes(
|
|
2689
|
+
provider.providerId,
|
|
2690
|
+
) ||
|
|
2691
|
+
("domainVerified" in provider &&
|
|
2692
|
+
!!(provider as { domainVerified?: boolean }).domainVerified &&
|
|
2693
|
+
validateEmailDomain(userInfo.email as string, provider.domain));
|
|
2694
|
+
|
|
2695
|
+
const callbackUrl =
|
|
2696
|
+
RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
|
|
2697
|
+
|
|
2698
|
+
const result = await handleOAuthUserInfo(ctx, {
|
|
2699
|
+
userInfo: {
|
|
2700
|
+
email: userInfo.email as string,
|
|
2701
|
+
name: (userInfo.name || userInfo.email) as string,
|
|
2702
|
+
id: userInfo.id as string,
|
|
2703
|
+
emailVerified: Boolean(userInfo.emailVerified),
|
|
2704
|
+
},
|
|
2705
|
+
account: {
|
|
2706
|
+
providerId: provider.providerId,
|
|
2707
|
+
accountId: userInfo.id as string,
|
|
2708
|
+
accessToken: "",
|
|
2709
|
+
refreshToken: "",
|
|
2710
|
+
},
|
|
2711
|
+
callbackURL: callbackUrl,
|
|
2712
|
+
disableSignUp: options?.disableImplicitSignUp,
|
|
2713
|
+
isTrustedProvider,
|
|
2714
|
+
});
|
|
2715
|
+
|
|
2716
|
+
if (result.error) {
|
|
2717
|
+
throw ctx.redirect(
|
|
2718
|
+
`${callbackUrl}?error=${result.error.split(" ").join("_")}`,
|
|
2719
|
+
);
|
|
2720
|
+
}
|
|
2721
|
+
|
|
2722
|
+
const { session, user } = result.data!;
|
|
2723
|
+
|
|
2724
|
+
if (options?.provisionUser) {
|
|
2725
|
+
await options.provisionUser({
|
|
2726
|
+
user: user as User & Record<string, any>,
|
|
2727
|
+
userInfo,
|
|
2728
|
+
provider,
|
|
2729
|
+
});
|
|
2730
|
+
}
|
|
2731
|
+
|
|
2732
|
+
await assignOrganizationFromProvider(ctx as any, {
|
|
2733
|
+
user,
|
|
2734
|
+
profile: {
|
|
2735
|
+
providerType: "saml",
|
|
2736
|
+
providerId: provider.providerId,
|
|
2737
|
+
accountId: userInfo.id as string,
|
|
2738
|
+
email: userInfo.email as string,
|
|
2739
|
+
emailVerified: Boolean(userInfo.emailVerified),
|
|
2740
|
+
rawAttributes: attributes,
|
|
2741
|
+
},
|
|
2742
|
+
provider,
|
|
2743
|
+
provisioningOptions: options?.organizationProvisioning,
|
|
2744
|
+
});
|
|
2745
|
+
|
|
2746
|
+
await setSessionCookie(ctx, { session, user });
|
|
2747
|
+
throw ctx.redirect(callbackUrl);
|
|
2748
|
+
},
|
|
2749
|
+
);
|
|
2750
|
+
};
|