@better-auth/sso 1.4.17 → 1.4.19
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 +10 -8
- package/dist/client.d.mts +7 -2
- package/dist/client.mjs +7 -2
- package/dist/client.mjs.map +1 -0
- package/dist/{index-XUgmj4eH.d.mts → index-D-VInsst.d.mts} +387 -8
- package/dist/index.d.mts +1 -1
- package/dist/index.mjs +1214 -657
- package/dist/index.mjs.map +1 -0
- package/package.json +4 -3
- package/src/client.ts +5 -1
- package/src/domain-verification.test.ts +46 -4
- package/src/index.ts +45 -6
- package/src/linking/org-assignment.ts +30 -15
- package/src/oidc.test.ts +1 -3
- package/src/providers.test.ts +1326 -0
- package/src/routes/domain-verification.ts +34 -12
- package/src/routes/providers.ts +567 -0
- package/src/routes/schemas.ts +95 -0
- package/src/routes/sso.ts +302 -118
- package/src/saml-state.ts +78 -0
- package/src/saml.test.ts +1660 -229
- package/src/types.ts +13 -2
- package/src/utils.test.ts +103 -0
- package/src/utils.ts +45 -5
- package/tsdown.config.ts +1 -0
package/src/routes/sso.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { base64 } from "@better-auth/utils/base64";
|
|
1
2
|
import { BetterFetchError, betterFetch } from "@better-fetch/fetch";
|
|
2
3
|
import type { User, Verification } from "better-auth";
|
|
3
4
|
import {
|
|
@@ -11,6 +12,7 @@ import {
|
|
|
11
12
|
import {
|
|
12
13
|
APIError,
|
|
13
14
|
createAuthEndpoint,
|
|
15
|
+
getSessionFromCtx,
|
|
14
16
|
sessionMiddleware,
|
|
15
17
|
} from "better-auth/api";
|
|
16
18
|
import { setSessionCookie } from "better-auth/cookies";
|
|
@@ -23,6 +25,7 @@ import type { BindingContext } from "samlify/types/src/entity";
|
|
|
23
25
|
import type { IdentityProvider } from "samlify/types/src/entity-idp";
|
|
24
26
|
import type { FlowResult } from "samlify/types/src/flow";
|
|
25
27
|
import z from "zod/v4";
|
|
28
|
+
import { getVerificationIdentifier } from "./domain-verification";
|
|
26
29
|
|
|
27
30
|
interface AuthnRequestRecord {
|
|
28
31
|
id: string;
|
|
@@ -52,8 +55,9 @@ import {
|
|
|
52
55
|
validateSAMLAlgorithms,
|
|
53
56
|
validateSingleAssertion,
|
|
54
57
|
} from "../saml";
|
|
58
|
+
import { generateRelayState, parseRelayState } from "../saml-state";
|
|
55
59
|
import type { OIDCConfig, SAMLConfig, SSOOptions, SSOProvider } from "../types";
|
|
56
|
-
import { safeJsonParse, validateEmailDomain } from "../utils";
|
|
60
|
+
import { domainMatches, safeJsonParse, validateEmailDomain } from "../utils";
|
|
57
61
|
|
|
58
62
|
export interface TimestampValidationOptions {
|
|
59
63
|
clockSkew?: number;
|
|
@@ -165,6 +169,8 @@ const spMetadataQuerySchema = z.object({
|
|
|
165
169
|
format: z.enum(["xml", "json"]).default("xml"),
|
|
166
170
|
});
|
|
167
171
|
|
|
172
|
+
type RelayState = Awaited<ReturnType<typeof parseRelayState>>;
|
|
173
|
+
|
|
168
174
|
export const spMetadata = () => {
|
|
169
175
|
return createAuthEndpoint(
|
|
170
176
|
"/sso/saml2/sp/metadata",
|
|
@@ -224,6 +230,7 @@ export const spMetadata = () => {
|
|
|
224
230
|
`${ctx.context.baseURL}/sso/saml2/sp/acs/${provider.id}`,
|
|
225
231
|
},
|
|
226
232
|
],
|
|
233
|
+
authnRequestsSigned: parsedSamlConfig.authnRequestsSigned || false,
|
|
227
234
|
wantMessageSigned: parsedSamlConfig.wantAssertionsSigned || false,
|
|
228
235
|
nameIDFormat: parsedSamlConfig.identifierFormat
|
|
229
236
|
? [parsedSamlConfig.identifierFormat]
|
|
@@ -247,7 +254,8 @@ const ssoProviderBodySchema = z.object({
|
|
|
247
254
|
description: "The issuer of the provider",
|
|
248
255
|
}),
|
|
249
256
|
domain: z.string({}).meta({
|
|
250
|
-
description:
|
|
257
|
+
description:
|
|
258
|
+
"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')",
|
|
251
259
|
}),
|
|
252
260
|
oidcConfig: z
|
|
253
261
|
.object({
|
|
@@ -855,9 +863,7 @@ export const registerSSOProvider = <O extends SSOOptions>(options: O) => {
|
|
|
855
863
|
await ctx.context.adapter.create<Verification>({
|
|
856
864
|
model: "verification",
|
|
857
865
|
data: {
|
|
858
|
-
identifier: options.
|
|
859
|
-
? `${options.domainVerification?.tokenPrefix}-${provider.providerId}`
|
|
860
|
-
: `better-auth-token-${provider.providerId}`,
|
|
866
|
+
identifier: getVerificationIdentifier(options, provider.providerId),
|
|
861
867
|
createdAt: new Date(),
|
|
862
868
|
updatedAt: new Date(),
|
|
863
869
|
value: domainVerificationToken as string,
|
|
@@ -1121,38 +1127,58 @@ export const signInSSO = (options?: SSOOptions) => {
|
|
|
1121
1127
|
}
|
|
1122
1128
|
// Try to find provider in database
|
|
1123
1129
|
if (!provider) {
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1130
|
+
const parseProvider = (res: SSOProvider<SSOOptions> | null) => {
|
|
1131
|
+
if (!res) return null;
|
|
1132
|
+
return {
|
|
1133
|
+
...res,
|
|
1134
|
+
oidcConfig: res.oidcConfig
|
|
1135
|
+
? safeJsonParse<OIDCConfig>(
|
|
1136
|
+
res.oidcConfig as unknown as string,
|
|
1137
|
+
) || undefined
|
|
1138
|
+
: undefined,
|
|
1139
|
+
samlConfig: res.samlConfig
|
|
1140
|
+
? safeJsonParse<SAMLConfig>(
|
|
1141
|
+
res.samlConfig as unknown as string,
|
|
1142
|
+
) || undefined
|
|
1143
|
+
: undefined,
|
|
1144
|
+
};
|
|
1145
|
+
};
|
|
1146
|
+
|
|
1147
|
+
if (providerId || orgId) {
|
|
1148
|
+
// Exact match for providerId or orgId
|
|
1149
|
+
provider = parseProvider(
|
|
1150
|
+
await ctx.context.adapter.findOne<SSOProvider<SSOOptions>>({
|
|
1151
|
+
model: "ssoProvider",
|
|
1152
|
+
where: [
|
|
1153
|
+
{
|
|
1154
|
+
field: providerId ? "providerId" : "organizationId",
|
|
1155
|
+
value: providerId || orgId!,
|
|
1156
|
+
},
|
|
1157
|
+
],
|
|
1158
|
+
}),
|
|
1159
|
+
);
|
|
1160
|
+
} else if (domain) {
|
|
1161
|
+
// For domain lookup, support comma-separated domains
|
|
1162
|
+
// First try exact match (fast path)
|
|
1163
|
+
provider = parseProvider(
|
|
1164
|
+
await ctx.context.adapter.findOne<SSOProvider<SSOOptions>>({
|
|
1165
|
+
model: "ssoProvider",
|
|
1166
|
+
where: [{ field: "domain", value: domain }],
|
|
1167
|
+
}),
|
|
1168
|
+
);
|
|
1169
|
+
// If not found, search all providers for comma-separated domain match
|
|
1170
|
+
if (!provider) {
|
|
1171
|
+
const allProviders = await ctx.context.adapter.findMany<
|
|
1172
|
+
SSOProvider<SSOOptions>
|
|
1173
|
+
>({
|
|
1174
|
+
model: "ssoProvider",
|
|
1175
|
+
});
|
|
1176
|
+
const matchingProvider = allProviders.find((p) =>
|
|
1177
|
+
domainMatches(domain, p.domain),
|
|
1178
|
+
);
|
|
1179
|
+
provider = parseProvider(matchingProvider ?? null);
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1156
1182
|
}
|
|
1157
1183
|
|
|
1158
1184
|
if (!provider) {
|
|
@@ -1258,6 +1284,8 @@ export const signInSSO = (options?: SSOOptions) => {
|
|
|
1258
1284
|
`${ctx.context.baseURL}/sso/saml2/sp/acs/${provider.providerId}`,
|
|
1259
1285
|
},
|
|
1260
1286
|
],
|
|
1287
|
+
authnRequestsSigned:
|
|
1288
|
+
parsedSamlConfig.authnRequestsSigned || false,
|
|
1261
1289
|
wantMessageSigned:
|
|
1262
1290
|
parsedSamlConfig.wantAssertionsSigned || false,
|
|
1263
1291
|
nameIDFormat: parsedSamlConfig.identifierFormat
|
|
@@ -1269,16 +1297,41 @@ export const signInSSO = (options?: SSOOptions) => {
|
|
|
1269
1297
|
|
|
1270
1298
|
const sp = saml.ServiceProvider({
|
|
1271
1299
|
metadata: metadata,
|
|
1300
|
+
privateKey:
|
|
1301
|
+
parsedSamlConfig.spMetadata?.privateKey ||
|
|
1302
|
+
parsedSamlConfig.privateKey,
|
|
1303
|
+
privateKeyPass: parsedSamlConfig.spMetadata?.privateKeyPass,
|
|
1272
1304
|
allowCreate: true,
|
|
1273
1305
|
});
|
|
1274
1306
|
|
|
1275
|
-
const
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1307
|
+
const idpData = parsedSamlConfig.idpMetadata;
|
|
1308
|
+
let idp: IdentityProvider;
|
|
1309
|
+
if (!idpData?.metadata) {
|
|
1310
|
+
idp = saml.IdentityProvider({
|
|
1311
|
+
entityID: idpData?.entityID || parsedSamlConfig.issuer,
|
|
1312
|
+
singleSignOnService: idpData?.singleSignOnService || [
|
|
1313
|
+
{
|
|
1314
|
+
Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect",
|
|
1315
|
+
Location: parsedSamlConfig.entryPoint,
|
|
1316
|
+
},
|
|
1317
|
+
],
|
|
1318
|
+
signingCert: idpData?.cert || parsedSamlConfig.cert,
|
|
1319
|
+
wantAuthnRequestsSigned:
|
|
1320
|
+
parsedSamlConfig.authnRequestsSigned || false,
|
|
1321
|
+
isAssertionEncrypted: idpData?.isAssertionEncrypted || false,
|
|
1322
|
+
encPrivateKey: idpData?.encPrivateKey,
|
|
1323
|
+
encPrivateKeyPass: idpData?.encPrivateKeyPass,
|
|
1324
|
+
});
|
|
1325
|
+
} else {
|
|
1326
|
+
idp = saml.IdentityProvider({
|
|
1327
|
+
metadata: idpData.metadata,
|
|
1328
|
+
privateKey: idpData.privateKey,
|
|
1329
|
+
privateKeyPass: idpData.privateKeyPass,
|
|
1330
|
+
isAssertionEncrypted: idpData.isAssertionEncrypted,
|
|
1331
|
+
encPrivateKey: idpData.encPrivateKey,
|
|
1332
|
+
encPrivateKeyPass: idpData.encPrivateKeyPass,
|
|
1333
|
+
});
|
|
1334
|
+
}
|
|
1282
1335
|
const loginRequest = sp.createLoginRequest(
|
|
1283
1336
|
idp,
|
|
1284
1337
|
"redirect",
|
|
@@ -1293,6 +1346,12 @@ export const signInSSO = (options?: SSOOptions) => {
|
|
|
1293
1346
|
});
|
|
1294
1347
|
}
|
|
1295
1348
|
|
|
1349
|
+
const { state: relayState } = await generateRelayState(
|
|
1350
|
+
ctx,
|
|
1351
|
+
undefined,
|
|
1352
|
+
false,
|
|
1353
|
+
);
|
|
1354
|
+
|
|
1296
1355
|
const shouldSaveRequest =
|
|
1297
1356
|
loginRequest.id && options?.saml?.enableInResponseToValidation;
|
|
1298
1357
|
if (shouldSaveRequest) {
|
|
@@ -1311,9 +1370,7 @@ export const signInSSO = (options?: SSOOptions) => {
|
|
|
1311
1370
|
}
|
|
1312
1371
|
|
|
1313
1372
|
return ctx.json({
|
|
1314
|
-
url: `${loginRequest.context}&RelayState=${encodeURIComponent(
|
|
1315
|
-
body.callbackURL,
|
|
1316
|
-
)}`,
|
|
1373
|
+
url: `${loginRequest.context}&RelayState=${encodeURIComponent(relayState)}`,
|
|
1317
1374
|
redirect: true,
|
|
1318
1375
|
});
|
|
1319
1376
|
}
|
|
@@ -1418,7 +1475,7 @@ export const callbackSSO = (options?: SSOOptions) => {
|
|
|
1418
1475
|
throw ctx.redirect(
|
|
1419
1476
|
`${
|
|
1420
1477
|
errorURL || callbackURL
|
|
1421
|
-
}
|
|
1478
|
+
}?error=invalid_provider&error_description=provider not found`,
|
|
1422
1479
|
);
|
|
1423
1480
|
}
|
|
1424
1481
|
|
|
@@ -1437,7 +1494,7 @@ export const callbackSSO = (options?: SSOOptions) => {
|
|
|
1437
1494
|
throw ctx.redirect(
|
|
1438
1495
|
`${
|
|
1439
1496
|
errorURL || callbackURL
|
|
1440
|
-
}
|
|
1497
|
+
}?error=invalid_provider&error_description=provider not found`,
|
|
1441
1498
|
);
|
|
1442
1499
|
}
|
|
1443
1500
|
|
|
@@ -1464,7 +1521,7 @@ export const callbackSSO = (options?: SSOOptions) => {
|
|
|
1464
1521
|
throw ctx.redirect(
|
|
1465
1522
|
`${
|
|
1466
1523
|
errorURL || callbackURL
|
|
1467
|
-
}
|
|
1524
|
+
}?error=invalid_provider&error_description=token_endpoint_not_found`,
|
|
1468
1525
|
);
|
|
1469
1526
|
}
|
|
1470
1527
|
|
|
@@ -1495,7 +1552,7 @@ export const callbackSSO = (options?: SSOOptions) => {
|
|
|
1495
1552
|
throw ctx.redirect(
|
|
1496
1553
|
`${
|
|
1497
1554
|
errorURL || callbackURL
|
|
1498
|
-
}
|
|
1555
|
+
}?error=invalid_provider&error_description=token_response_not_found`,
|
|
1499
1556
|
);
|
|
1500
1557
|
}
|
|
1501
1558
|
let userInfo: {
|
|
@@ -1512,12 +1569,16 @@ export const callbackSSO = (options?: SSOOptions) => {
|
|
|
1512
1569
|
throw ctx.redirect(
|
|
1513
1570
|
`${
|
|
1514
1571
|
errorURL || callbackURL
|
|
1515
|
-
}
|
|
1572
|
+
}?error=invalid_provider&error_description=jwks_endpoint_not_found`,
|
|
1516
1573
|
);
|
|
1517
1574
|
}
|
|
1518
1575
|
const verified = await validateToken(
|
|
1519
1576
|
tokenResponse.idToken,
|
|
1520
1577
|
config.jwksEndpoint,
|
|
1578
|
+
{
|
|
1579
|
+
audience: config.clientId,
|
|
1580
|
+
issuer: provider.issuer,
|
|
1581
|
+
},
|
|
1521
1582
|
).catch((e) => {
|
|
1522
1583
|
ctx.context.logger.error(e);
|
|
1523
1584
|
return null;
|
|
@@ -1526,14 +1587,7 @@ export const callbackSSO = (options?: SSOOptions) => {
|
|
|
1526
1587
|
throw ctx.redirect(
|
|
1527
1588
|
`${
|
|
1528
1589
|
errorURL || callbackURL
|
|
1529
|
-
}
|
|
1530
|
-
);
|
|
1531
|
-
}
|
|
1532
|
-
if (verified.payload.iss !== provider.issuer) {
|
|
1533
|
-
throw ctx.redirect(
|
|
1534
|
-
`${
|
|
1535
|
-
errorURL || callbackURL
|
|
1536
|
-
}/error?error=invalid_provider&error_description=issuer_mismatch`,
|
|
1590
|
+
}?error=invalid_provider&error_description=token_not_verified`,
|
|
1537
1591
|
);
|
|
1538
1592
|
}
|
|
1539
1593
|
|
|
@@ -1566,7 +1620,7 @@ export const callbackSSO = (options?: SSOOptions) => {
|
|
|
1566
1620
|
throw ctx.redirect(
|
|
1567
1621
|
`${
|
|
1568
1622
|
errorURL || callbackURL
|
|
1569
|
-
}
|
|
1623
|
+
}?error=invalid_provider&error_description=user_info_endpoint_not_found`,
|
|
1570
1624
|
);
|
|
1571
1625
|
}
|
|
1572
1626
|
const userInfoResponse = await betterFetch<{
|
|
@@ -1584,7 +1638,7 @@ export const callbackSSO = (options?: SSOOptions) => {
|
|
|
1584
1638
|
throw ctx.redirect(
|
|
1585
1639
|
`${
|
|
1586
1640
|
errorURL || callbackURL
|
|
1587
|
-
}
|
|
1641
|
+
}?error=invalid_provider&error_description=${
|
|
1588
1642
|
userInfoResponse.error.message
|
|
1589
1643
|
}`,
|
|
1590
1644
|
);
|
|
@@ -1596,7 +1650,7 @@ export const callbackSSO = (options?: SSOOptions) => {
|
|
|
1596
1650
|
throw ctx.redirect(
|
|
1597
1651
|
`${
|
|
1598
1652
|
errorURL || callbackURL
|
|
1599
|
-
}
|
|
1653
|
+
}?error=invalid_provider&error_description=missing_user_info`,
|
|
1600
1654
|
);
|
|
1601
1655
|
}
|
|
1602
1656
|
const isTrustedProvider =
|
|
@@ -1630,9 +1684,7 @@ export const callbackSSO = (options?: SSOOptions) => {
|
|
|
1630
1684
|
isTrustedProvider,
|
|
1631
1685
|
});
|
|
1632
1686
|
if (linked.error) {
|
|
1633
|
-
throw ctx.redirect(
|
|
1634
|
-
`${errorURL || callbackURL}/error?error=${linked.error}`,
|
|
1635
|
-
);
|
|
1687
|
+
throw ctx.redirect(`${errorURL || callbackURL}?error=${linked.error}`);
|
|
1636
1688
|
}
|
|
1637
1689
|
const { session, user } = linked.data!;
|
|
1638
1690
|
|
|
@@ -1683,12 +1735,71 @@ const callbackSSOSAMLBodySchema = z.object({
|
|
|
1683
1735
|
RelayState: z.string().optional(),
|
|
1684
1736
|
});
|
|
1685
1737
|
|
|
1738
|
+
/**
|
|
1739
|
+
* Validates and returns a safe redirect URL.
|
|
1740
|
+
* - Prevents open redirect attacks by validating against trusted origins
|
|
1741
|
+
* - Prevents redirect loops by checking if URL points to callback route
|
|
1742
|
+
* - Falls back to appOrigin if URL is invalid or unsafe
|
|
1743
|
+
*/
|
|
1744
|
+
const getSafeRedirectUrl = (
|
|
1745
|
+
url: string | undefined,
|
|
1746
|
+
callbackPath: string,
|
|
1747
|
+
appOrigin: string,
|
|
1748
|
+
isTrustedOrigin: (
|
|
1749
|
+
url: string,
|
|
1750
|
+
settings?: { allowRelativePaths: boolean },
|
|
1751
|
+
) => boolean,
|
|
1752
|
+
): string => {
|
|
1753
|
+
if (!url) {
|
|
1754
|
+
return appOrigin;
|
|
1755
|
+
}
|
|
1756
|
+
|
|
1757
|
+
if (url.startsWith("/") && !url.startsWith("//")) {
|
|
1758
|
+
try {
|
|
1759
|
+
const absoluteUrl = new URL(url, appOrigin);
|
|
1760
|
+
if (absoluteUrl.origin !== appOrigin) {
|
|
1761
|
+
return appOrigin;
|
|
1762
|
+
}
|
|
1763
|
+
const callbackPathname = new URL(callbackPath).pathname;
|
|
1764
|
+
if (absoluteUrl.pathname === callbackPathname) {
|
|
1765
|
+
return appOrigin;
|
|
1766
|
+
}
|
|
1767
|
+
} catch {
|
|
1768
|
+
return appOrigin;
|
|
1769
|
+
}
|
|
1770
|
+
return url;
|
|
1771
|
+
}
|
|
1772
|
+
|
|
1773
|
+
if (!isTrustedOrigin(url, { allowRelativePaths: false })) {
|
|
1774
|
+
return appOrigin;
|
|
1775
|
+
}
|
|
1776
|
+
|
|
1777
|
+
try {
|
|
1778
|
+
const callbackPathname = new URL(callbackPath).pathname;
|
|
1779
|
+
const urlPathname = new URL(url).pathname;
|
|
1780
|
+
if (urlPathname === callbackPathname) {
|
|
1781
|
+
return appOrigin;
|
|
1782
|
+
}
|
|
1783
|
+
} catch {
|
|
1784
|
+
if (url === callbackPath || url.startsWith(`${callbackPath}?`)) {
|
|
1785
|
+
return appOrigin;
|
|
1786
|
+
}
|
|
1787
|
+
}
|
|
1788
|
+
|
|
1789
|
+
return url;
|
|
1790
|
+
};
|
|
1791
|
+
|
|
1686
1792
|
export const callbackSSOSAML = (options?: SSOOptions) => {
|
|
1687
1793
|
return createAuthEndpoint(
|
|
1688
1794
|
"/sso/saml2/callback/:providerId",
|
|
1689
1795
|
{
|
|
1690
|
-
method: "POST",
|
|
1691
|
-
body: callbackSSOSAMLBodySchema,
|
|
1796
|
+
method: ["GET", "POST"],
|
|
1797
|
+
body: callbackSSOSAMLBodySchema.optional(),
|
|
1798
|
+
query: z
|
|
1799
|
+
.object({
|
|
1800
|
+
RelayState: z.string().optional(),
|
|
1801
|
+
})
|
|
1802
|
+
.optional(),
|
|
1692
1803
|
metadata: {
|
|
1693
1804
|
...HIDE_METADATA,
|
|
1694
1805
|
allowedMediaTypes: [
|
|
@@ -1699,7 +1810,7 @@ export const callbackSSOSAML = (options?: SSOOptions) => {
|
|
|
1699
1810
|
operationId: "handleSAMLCallback",
|
|
1700
1811
|
summary: "Callback URL for SAML provider",
|
|
1701
1812
|
description:
|
|
1702
|
-
"This endpoint is used as the callback URL for SAML providers.",
|
|
1813
|
+
"This endpoint is used as the callback URL for SAML providers. Supports both GET and POST methods for IdP-initiated and SP-initiated flows.",
|
|
1703
1814
|
responses: {
|
|
1704
1815
|
"302": {
|
|
1705
1816
|
description: "Redirects to the callback URL",
|
|
@@ -1715,8 +1826,41 @@ export const callbackSSOSAML = (options?: SSOOptions) => {
|
|
|
1715
1826
|
},
|
|
1716
1827
|
},
|
|
1717
1828
|
async (ctx) => {
|
|
1718
|
-
const { SAMLResponse, RelayState } = ctx.body;
|
|
1719
1829
|
const { providerId } = ctx.params;
|
|
1830
|
+
const appOrigin = new URL(ctx.context.baseURL).origin;
|
|
1831
|
+
const errorURL =
|
|
1832
|
+
ctx.context.options.onAPIError?.errorURL || `${appOrigin}/error`;
|
|
1833
|
+
const currentCallbackPath = `${ctx.context.baseURL}/sso/saml2/callback/${providerId}`;
|
|
1834
|
+
|
|
1835
|
+
// Determine if this is a GET request by checking both method AND body presence
|
|
1836
|
+
// When called via auth.api.*, ctx.method may not be reliable, so we also check for body
|
|
1837
|
+
const isGetRequest = ctx.method === "GET" && !ctx.body?.SAMLResponse;
|
|
1838
|
+
|
|
1839
|
+
if (isGetRequest) {
|
|
1840
|
+
const session = await getSessionFromCtx(ctx);
|
|
1841
|
+
|
|
1842
|
+
if (!session?.session) {
|
|
1843
|
+
throw ctx.redirect(`${errorURL}?error=invalid_request`);
|
|
1844
|
+
}
|
|
1845
|
+
|
|
1846
|
+
const relayState = ctx.query?.RelayState as string | undefined;
|
|
1847
|
+
const safeRedirectUrl = getSafeRedirectUrl(
|
|
1848
|
+
relayState,
|
|
1849
|
+
currentCallbackPath,
|
|
1850
|
+
appOrigin,
|
|
1851
|
+
(url, settings) => ctx.context.isTrustedOrigin(url, settings),
|
|
1852
|
+
);
|
|
1853
|
+
|
|
1854
|
+
throw ctx.redirect(safeRedirectUrl);
|
|
1855
|
+
}
|
|
1856
|
+
|
|
1857
|
+
if (!ctx.body?.SAMLResponse) {
|
|
1858
|
+
throw new APIError("BAD_REQUEST", {
|
|
1859
|
+
message: "SAMLResponse is required for POST requests",
|
|
1860
|
+
});
|
|
1861
|
+
}
|
|
1862
|
+
|
|
1863
|
+
const { SAMLResponse } = ctx.body;
|
|
1720
1864
|
|
|
1721
1865
|
const maxResponseSize =
|
|
1722
1866
|
options?.saml?.maxResponseSize ?? DEFAULT_MAX_SAML_RESPONSE_SIZE;
|
|
@@ -1726,6 +1870,14 @@ export const callbackSSOSAML = (options?: SSOOptions) => {
|
|
|
1726
1870
|
});
|
|
1727
1871
|
}
|
|
1728
1872
|
|
|
1873
|
+
let relayState: RelayState | null = null;
|
|
1874
|
+
if (ctx.body.RelayState) {
|
|
1875
|
+
try {
|
|
1876
|
+
relayState = await parseRelayState(ctx);
|
|
1877
|
+
} catch {
|
|
1878
|
+
relayState = null;
|
|
1879
|
+
}
|
|
1880
|
+
}
|
|
1729
1881
|
let provider: SSOProvider<SSOOptions> | null = null;
|
|
1730
1882
|
if (options?.defaultSSO?.length) {
|
|
1731
1883
|
const matchingDefault = options.defaultSSO.find(
|
|
@@ -1784,6 +1936,21 @@ export const callbackSSOSAML = (options?: SSOOptions) => {
|
|
|
1784
1936
|
message: "Invalid SAML configuration",
|
|
1785
1937
|
});
|
|
1786
1938
|
}
|
|
1939
|
+
|
|
1940
|
+
const isTrusted = (
|
|
1941
|
+
url: string,
|
|
1942
|
+
settings?: { allowRelativePaths: boolean },
|
|
1943
|
+
) => ctx.context.isTrustedOrigin(url, settings);
|
|
1944
|
+
|
|
1945
|
+
const safeErrorUrl = getSafeRedirectUrl(
|
|
1946
|
+
relayState?.errorURL ||
|
|
1947
|
+
relayState?.callbackURL ||
|
|
1948
|
+
parsedSamlConfig.callbackUrl,
|
|
1949
|
+
currentCallbackPath,
|
|
1950
|
+
appOrigin,
|
|
1951
|
+
isTrusted,
|
|
1952
|
+
);
|
|
1953
|
+
|
|
1787
1954
|
const idpData = parsedSamlConfig.idpMetadata;
|
|
1788
1955
|
let idp: IdentityProvider | null = null;
|
|
1789
1956
|
|
|
@@ -1791,7 +1958,7 @@ export const callbackSSOSAML = (options?: SSOOptions) => {
|
|
|
1791
1958
|
if (!idpData?.metadata) {
|
|
1792
1959
|
idp = saml.IdentityProvider({
|
|
1793
1960
|
entityID: idpData?.entityID || parsedSamlConfig.issuer,
|
|
1794
|
-
singleSignOnService: [
|
|
1961
|
+
singleSignOnService: idpData?.singleSignOnService || [
|
|
1795
1962
|
{
|
|
1796
1963
|
Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect",
|
|
1797
1964
|
Location: parsedSamlConfig.entryPoint,
|
|
@@ -1799,7 +1966,7 @@ export const callbackSSOSAML = (options?: SSOOptions) => {
|
|
|
1799
1966
|
],
|
|
1800
1967
|
signingCert: idpData?.cert || parsedSamlConfig.cert,
|
|
1801
1968
|
wantAuthnRequestsSigned:
|
|
1802
|
-
parsedSamlConfig.
|
|
1969
|
+
parsedSamlConfig.authnRequestsSigned || false,
|
|
1803
1970
|
isAssertionEncrypted: idpData?.isAssertionEncrypted || false,
|
|
1804
1971
|
encPrivateKey: idpData?.encPrivateKey,
|
|
1805
1972
|
encPrivateKeyPass: idpData?.encPrivateKeyPass,
|
|
@@ -1846,7 +2013,7 @@ export const callbackSSOSAML = (options?: SSOOptions) => {
|
|
|
1846
2013
|
parsedResponse = await sp.parseLoginResponse(idp, "post", {
|
|
1847
2014
|
body: {
|
|
1848
2015
|
SAMLResponse,
|
|
1849
|
-
RelayState: RelayState || undefined,
|
|
2016
|
+
RelayState: ctx.body.RelayState || undefined,
|
|
1850
2017
|
},
|
|
1851
2018
|
});
|
|
1852
2019
|
|
|
@@ -1856,8 +2023,8 @@ export const callbackSSOSAML = (options?: SSOOptions) => {
|
|
|
1856
2023
|
} catch (error) {
|
|
1857
2024
|
ctx.context.logger.error("SAML response validation failed", {
|
|
1858
2025
|
error,
|
|
1859
|
-
decodedResponse:
|
|
1860
|
-
|
|
2026
|
+
decodedResponse: new TextDecoder().decode(
|
|
2027
|
+
base64.decode(SAMLResponse),
|
|
1861
2028
|
),
|
|
1862
2029
|
});
|
|
1863
2030
|
throw new APIError("BAD_REQUEST", {
|
|
@@ -1908,10 +2075,8 @@ export const callbackSSOSAML = (options?: SSOOptions) => {
|
|
|
1908
2075
|
"SAML InResponseTo validation failed: unknown or expired request ID",
|
|
1909
2076
|
{ inResponseTo, providerId: provider.providerId },
|
|
1910
2077
|
);
|
|
1911
|
-
const redirectUrl =
|
|
1912
|
-
RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
|
|
1913
2078
|
throw ctx.redirect(
|
|
1914
|
-
`${
|
|
2079
|
+
`${safeErrorUrl}?error=invalid_saml_response&error_description=Unknown+or+expired+request+ID`,
|
|
1915
2080
|
);
|
|
1916
2081
|
}
|
|
1917
2082
|
|
|
@@ -1928,10 +2093,8 @@ export const callbackSSOSAML = (options?: SSOOptions) => {
|
|
|
1928
2093
|
await ctx.context.internalAdapter.deleteVerificationByIdentifier(
|
|
1929
2094
|
`${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`,
|
|
1930
2095
|
);
|
|
1931
|
-
const redirectUrl =
|
|
1932
|
-
RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
|
|
1933
2096
|
throw ctx.redirect(
|
|
1934
|
-
`${
|
|
2097
|
+
`${safeErrorUrl}?error=invalid_saml_response&error_description=Provider+mismatch`,
|
|
1935
2098
|
);
|
|
1936
2099
|
}
|
|
1937
2100
|
|
|
@@ -1943,10 +2106,8 @@ export const callbackSSOSAML = (options?: SSOOptions) => {
|
|
|
1943
2106
|
"SAML IdP-initiated SSO rejected: InResponseTo missing and allowIdpInitiated is false",
|
|
1944
2107
|
{ providerId: provider.providerId },
|
|
1945
2108
|
);
|
|
1946
|
-
const redirectUrl =
|
|
1947
|
-
RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
|
|
1948
2109
|
throw ctx.redirect(
|
|
1949
|
-
`${
|
|
2110
|
+
`${safeErrorUrl}?error=unsolicited_response&error_description=IdP-initiated+SSO+not+allowed`,
|
|
1950
2111
|
);
|
|
1951
2112
|
}
|
|
1952
2113
|
}
|
|
@@ -1996,10 +2157,8 @@ export const callbackSSOSAML = (options?: SSOOptions) => {
|
|
|
1996
2157
|
providerId: provider.providerId,
|
|
1997
2158
|
},
|
|
1998
2159
|
);
|
|
1999
|
-
const redirectUrl =
|
|
2000
|
-
RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
|
|
2001
2160
|
throw ctx.redirect(
|
|
2002
|
-
`${
|
|
2161
|
+
`${safeErrorUrl}?error=replay_detected&error_description=SAML+assertion+has+already+been+used`,
|
|
2003
2162
|
);
|
|
2004
2163
|
}
|
|
2005
2164
|
|
|
@@ -2070,8 +2229,12 @@ export const callbackSSOSAML = (options?: SSOOptions) => {
|
|
|
2070
2229
|
!!(provider as { domainVerified?: boolean }).domainVerified &&
|
|
2071
2230
|
validateEmailDomain(userInfo.email as string, provider.domain));
|
|
2072
2231
|
|
|
2073
|
-
const
|
|
2074
|
-
|
|
2232
|
+
const safeCallbackUrl = getSafeRedirectUrl(
|
|
2233
|
+
relayState?.callbackURL || parsedSamlConfig.callbackUrl,
|
|
2234
|
+
currentCallbackPath,
|
|
2235
|
+
appOrigin,
|
|
2236
|
+
isTrusted,
|
|
2237
|
+
);
|
|
2075
2238
|
|
|
2076
2239
|
const result = await handleOAuthUserInfo(ctx, {
|
|
2077
2240
|
userInfo: {
|
|
@@ -2086,14 +2249,14 @@ export const callbackSSOSAML = (options?: SSOOptions) => {
|
|
|
2086
2249
|
accessToken: "",
|
|
2087
2250
|
refreshToken: "",
|
|
2088
2251
|
},
|
|
2089
|
-
callbackURL:
|
|
2252
|
+
callbackURL: safeCallbackUrl,
|
|
2090
2253
|
disableSignUp: options?.disableImplicitSignUp,
|
|
2091
2254
|
isTrustedProvider,
|
|
2092
2255
|
});
|
|
2093
2256
|
|
|
2094
2257
|
if (result.error) {
|
|
2095
2258
|
throw ctx.redirect(
|
|
2096
|
-
`${
|
|
2259
|
+
`${safeCallbackUrl}?error=${result.error.split(" ").join("_")}`,
|
|
2097
2260
|
);
|
|
2098
2261
|
}
|
|
2099
2262
|
|
|
@@ -2122,7 +2285,7 @@ export const callbackSSOSAML = (options?: SSOOptions) => {
|
|
|
2122
2285
|
});
|
|
2123
2286
|
|
|
2124
2287
|
await setSessionCookie(ctx, { session, user });
|
|
2125
|
-
throw ctx.redirect(
|
|
2288
|
+
throw ctx.redirect(safeCallbackUrl);
|
|
2126
2289
|
},
|
|
2127
2290
|
);
|
|
2128
2291
|
};
|
|
@@ -2159,8 +2322,10 @@ export const acsEndpoint = (options?: SSOOptions) => {
|
|
|
2159
2322
|
},
|
|
2160
2323
|
},
|
|
2161
2324
|
async (ctx) => {
|
|
2162
|
-
const { SAMLResponse
|
|
2325
|
+
const { SAMLResponse } = ctx.body;
|
|
2163
2326
|
const { providerId } = ctx.params;
|
|
2327
|
+
const currentCallbackPath = `${ctx.context.baseURL}/sso/saml2/sp/acs/${providerId}`;
|
|
2328
|
+
const appOrigin = new URL(ctx.context.baseURL).origin;
|
|
2164
2329
|
|
|
2165
2330
|
const maxResponseSize =
|
|
2166
2331
|
options?.saml?.maxResponseSize ?? DEFAULT_MAX_SAML_RESPONSE_SIZE;
|
|
@@ -2169,6 +2334,14 @@ export const acsEndpoint = (options?: SSOOptions) => {
|
|
|
2169
2334
|
message: `SAML response exceeds maximum allowed size (${maxResponseSize} bytes)`,
|
|
2170
2335
|
});
|
|
2171
2336
|
}
|
|
2337
|
+
let relayState: RelayState | null = null;
|
|
2338
|
+
if (ctx.body.RelayState) {
|
|
2339
|
+
try {
|
|
2340
|
+
relayState = await parseRelayState(ctx);
|
|
2341
|
+
} catch {
|
|
2342
|
+
relayState = null;
|
|
2343
|
+
}
|
|
2344
|
+
}
|
|
2172
2345
|
|
|
2173
2346
|
// If defaultSSO is configured, use it as the provider
|
|
2174
2347
|
let provider: SSOProvider<SSOOptions> | null = null;
|
|
@@ -2233,6 +2406,23 @@ export const acsEndpoint = (options?: SSOOptions) => {
|
|
|
2233
2406
|
}
|
|
2234
2407
|
|
|
2235
2408
|
const parsedSamlConfig = provider.samlConfig;
|
|
2409
|
+
|
|
2410
|
+
const isTrusted = (
|
|
2411
|
+
url: string,
|
|
2412
|
+
settings?: { allowRelativePaths: boolean },
|
|
2413
|
+
) => ctx.context.isTrustedOrigin(url, settings);
|
|
2414
|
+
|
|
2415
|
+
// Compute a safe error redirect URL once, reused by all error paths.
|
|
2416
|
+
// Prefers errorURL from relay state, falls back to callbackURL, then provider config, then baseURL.
|
|
2417
|
+
const safeErrorUrl = getSafeRedirectUrl(
|
|
2418
|
+
relayState?.errorURL ||
|
|
2419
|
+
relayState?.callbackURL ||
|
|
2420
|
+
parsedSamlConfig.callbackUrl,
|
|
2421
|
+
currentCallbackPath,
|
|
2422
|
+
appOrigin,
|
|
2423
|
+
isTrusted,
|
|
2424
|
+
);
|
|
2425
|
+
|
|
2236
2426
|
// Configure SP and IdP
|
|
2237
2427
|
const sp = saml.ServiceProvider({
|
|
2238
2428
|
entityID:
|
|
@@ -2277,14 +2467,12 @@ export const acsEndpoint = (options?: SSOOptions) => {
|
|
|
2277
2467
|
validateSingleAssertion(SAMLResponse);
|
|
2278
2468
|
} catch (error) {
|
|
2279
2469
|
if (error instanceof APIError) {
|
|
2280
|
-
const redirectUrl =
|
|
2281
|
-
RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
|
|
2282
2470
|
const errorCode =
|
|
2283
2471
|
error.body?.code === "SAML_MULTIPLE_ASSERTIONS"
|
|
2284
2472
|
? "multiple_assertions"
|
|
2285
2473
|
: "no_assertion";
|
|
2286
2474
|
throw ctx.redirect(
|
|
2287
|
-
`${
|
|
2475
|
+
`${safeErrorUrl}?error=${errorCode}&error_description=${encodeURIComponent(error.message)}`,
|
|
2288
2476
|
);
|
|
2289
2477
|
}
|
|
2290
2478
|
throw error;
|
|
@@ -2296,7 +2484,7 @@ export const acsEndpoint = (options?: SSOOptions) => {
|
|
|
2296
2484
|
parsedResponse = await sp.parseLoginResponse(idp, "post", {
|
|
2297
2485
|
body: {
|
|
2298
2486
|
SAMLResponse,
|
|
2299
|
-
RelayState: RelayState || undefined,
|
|
2487
|
+
RelayState: ctx.body.RelayState || undefined,
|
|
2300
2488
|
},
|
|
2301
2489
|
});
|
|
2302
2490
|
|
|
@@ -2306,8 +2494,8 @@ export const acsEndpoint = (options?: SSOOptions) => {
|
|
|
2306
2494
|
} catch (error) {
|
|
2307
2495
|
ctx.context.logger.error("SAML response validation failed", {
|
|
2308
2496
|
error,
|
|
2309
|
-
decodedResponse:
|
|
2310
|
-
|
|
2497
|
+
decodedResponse: new TextDecoder().decode(
|
|
2498
|
+
base64.decode(SAMLResponse),
|
|
2311
2499
|
),
|
|
2312
2500
|
});
|
|
2313
2501
|
throw new APIError("BAD_REQUEST", {
|
|
@@ -2360,10 +2548,8 @@ export const acsEndpoint = (options?: SSOOptions) => {
|
|
|
2360
2548
|
"SAML InResponseTo validation failed: unknown or expired request ID",
|
|
2361
2549
|
{ inResponseTo: inResponseToAcs, providerId },
|
|
2362
2550
|
);
|
|
2363
|
-
const redirectUrl =
|
|
2364
|
-
RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
|
|
2365
2551
|
throw ctx.redirect(
|
|
2366
|
-
`${
|
|
2552
|
+
`${safeErrorUrl}?error=invalid_saml_response&error_description=Unknown+or+expired+request+ID`,
|
|
2367
2553
|
);
|
|
2368
2554
|
}
|
|
2369
2555
|
|
|
@@ -2379,10 +2565,8 @@ export const acsEndpoint = (options?: SSOOptions) => {
|
|
|
2379
2565
|
await ctx.context.internalAdapter.deleteVerificationByIdentifier(
|
|
2380
2566
|
`${AUTHN_REQUEST_KEY_PREFIX}${inResponseToAcs}`,
|
|
2381
2567
|
);
|
|
2382
|
-
const redirectUrl =
|
|
2383
|
-
RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
|
|
2384
2568
|
throw ctx.redirect(
|
|
2385
|
-
`${
|
|
2569
|
+
`${safeErrorUrl}?error=invalid_saml_response&error_description=Provider+mismatch`,
|
|
2386
2570
|
);
|
|
2387
2571
|
}
|
|
2388
2572
|
|
|
@@ -2394,17 +2578,15 @@ export const acsEndpoint = (options?: SSOOptions) => {
|
|
|
2394
2578
|
"SAML IdP-initiated SSO rejected: InResponseTo missing and allowIdpInitiated is false",
|
|
2395
2579
|
{ providerId },
|
|
2396
2580
|
);
|
|
2397
|
-
const redirectUrl =
|
|
2398
|
-
RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
|
|
2399
2581
|
throw ctx.redirect(
|
|
2400
|
-
`${
|
|
2582
|
+
`${safeErrorUrl}?error=unsolicited_response&error_description=IdP-initiated+SSO+not+allowed`,
|
|
2401
2583
|
);
|
|
2402
2584
|
}
|
|
2403
2585
|
}
|
|
2404
2586
|
|
|
2405
2587
|
// Assertion Replay Protection
|
|
2406
|
-
const samlContentAcs =
|
|
2407
|
-
|
|
2588
|
+
const samlContentAcs = new TextDecoder().decode(
|
|
2589
|
+
base64.decode(SAMLResponse),
|
|
2408
2590
|
);
|
|
2409
2591
|
const assertionIdAcs = extractAssertionId(samlContentAcs);
|
|
2410
2592
|
|
|
@@ -2447,10 +2629,8 @@ export const acsEndpoint = (options?: SSOOptions) => {
|
|
|
2447
2629
|
providerId,
|
|
2448
2630
|
},
|
|
2449
2631
|
);
|
|
2450
|
-
const redirectUrl =
|
|
2451
|
-
RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
|
|
2452
2632
|
throw ctx.redirect(
|
|
2453
|
-
`${
|
|
2633
|
+
`${safeErrorUrl}?error=replay_detected&error_description=SAML+assertion+has+already+been+used`,
|
|
2454
2634
|
);
|
|
2455
2635
|
}
|
|
2456
2636
|
|
|
@@ -2522,8 +2702,12 @@ export const acsEndpoint = (options?: SSOOptions) => {
|
|
|
2522
2702
|
!!(provider as { domainVerified?: boolean }).domainVerified &&
|
|
2523
2703
|
validateEmailDomain(userInfo.email as string, provider.domain));
|
|
2524
2704
|
|
|
2525
|
-
const
|
|
2526
|
-
|
|
2705
|
+
const safeCallbackUrl = getSafeRedirectUrl(
|
|
2706
|
+
relayState?.callbackURL || parsedSamlConfig.callbackUrl,
|
|
2707
|
+
currentCallbackPath,
|
|
2708
|
+
appOrigin,
|
|
2709
|
+
isTrusted,
|
|
2710
|
+
);
|
|
2527
2711
|
|
|
2528
2712
|
const result = await handleOAuthUserInfo(ctx, {
|
|
2529
2713
|
userInfo: {
|
|
@@ -2538,14 +2722,14 @@ export const acsEndpoint = (options?: SSOOptions) => {
|
|
|
2538
2722
|
accessToken: "",
|
|
2539
2723
|
refreshToken: "",
|
|
2540
2724
|
},
|
|
2541
|
-
callbackURL:
|
|
2725
|
+
callbackURL: safeCallbackUrl,
|
|
2542
2726
|
disableSignUp: options?.disableImplicitSignUp,
|
|
2543
2727
|
isTrustedProvider,
|
|
2544
2728
|
});
|
|
2545
2729
|
|
|
2546
2730
|
if (result.error) {
|
|
2547
2731
|
throw ctx.redirect(
|
|
2548
|
-
`${
|
|
2732
|
+
`${safeCallbackUrl}?error=${result.error.split(" ").join("_")}`,
|
|
2549
2733
|
);
|
|
2550
2734
|
}
|
|
2551
2735
|
|
|
@@ -2574,7 +2758,7 @@ export const acsEndpoint = (options?: SSOOptions) => {
|
|
|
2574
2758
|
});
|
|
2575
2759
|
|
|
2576
2760
|
await setSessionCookie(ctx, { session, user });
|
|
2577
|
-
throw ctx.redirect(
|
|
2761
|
+
throw ctx.redirect(safeCallbackUrl);
|
|
2578
2762
|
},
|
|
2579
2763
|
);
|
|
2580
2764
|
};
|