@better-auth/sso 1.8.0-beta.7 → 1.8.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/dist/client.d.mts +1 -3
- package/dist/client.mjs +2 -3
- package/dist/{index-BvKzNnKP.d.mts → index-BkypgbRt.d.mts} +94 -28
- package/dist/index.d.mts +2 -2
- package/dist/index.mjs +301 -146
- package/dist/version-D4csRLQZ.mjs +3 -0
- package/package.json +8 -8
- package/dist/version-B5en_Bdq.mjs +0 -5
package/dist/client.d.mts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
import { t as SSOPlugin } from "./index-
|
|
1
|
+
import { t as SSOPlugin } from "./index-BkypgbRt.mjs";
|
|
2
2
|
import { DBFieldAttribute } from "better-auth/db";
|
|
3
|
-
//#region src/client.d.ts
|
|
4
3
|
interface SSOClientOptions {
|
|
5
4
|
domainVerification?: {
|
|
6
5
|
enabled: boolean;
|
|
@@ -29,5 +28,4 @@ export declare const ssoClient: <CO extends SSOClientOptions>(options?: CO | und
|
|
|
29
28
|
"/sso/get-provider": "GET";
|
|
30
29
|
};
|
|
31
30
|
};
|
|
32
|
-
//#endregion
|
|
33
31
|
export {};
|
package/dist/client.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { t as PACKAGE_VERSION } from "./version-
|
|
2
|
-
|
|
1
|
+
import { t as PACKAGE_VERSION } from "./version-D4csRLQZ.mjs";
|
|
2
|
+
// src/client.ts
|
|
3
3
|
const ssoClient = (options) => {
|
|
4
4
|
return {
|
|
5
5
|
id: "sso-client",
|
|
@@ -11,5 +11,4 @@ const ssoClient = (options) => {
|
|
|
11
11
|
}
|
|
12
12
|
};
|
|
13
13
|
};
|
|
14
|
-
//#endregion
|
|
15
14
|
export { ssoClient };
|
|
@@ -6,7 +6,6 @@ import { DBFieldAttribute, FieldAttributeToObject, InferAdditionalFieldsFromPlug
|
|
|
6
6
|
import { Awaitable, DBTransactionAdapter, OAuth2Tokens, User } from "better-auth";
|
|
7
7
|
import "@better-auth/core/db/adapter";
|
|
8
8
|
import "@better-auth/core";
|
|
9
|
-
//#region src/saml/algorithms.d.ts
|
|
10
9
|
declare const SignatureAlgorithm: {
|
|
11
10
|
readonly RSA_SHA1: "http://www.w3.org/2000/09/xmldsig#rsa-sha1";
|
|
12
11
|
readonly RSA_SHA256: "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256";
|
|
@@ -44,8 +43,6 @@ interface AlgorithmValidationOptions {
|
|
|
44
43
|
allowedKeyEncryptionAlgorithms?: string[];
|
|
45
44
|
allowedDataEncryptionAlgorithms?: string[];
|
|
46
45
|
}
|
|
47
|
-
//#endregion
|
|
48
|
-
//#region src/types.d.ts
|
|
49
46
|
interface OIDCMapping {
|
|
50
47
|
email?: string | undefined;
|
|
51
48
|
emailVerified?: string | undefined;
|
|
@@ -238,7 +235,13 @@ type SSOUserResolution = {
|
|
|
238
235
|
code: string;
|
|
239
236
|
message?: string | undefined;
|
|
240
237
|
};
|
|
241
|
-
/**
|
|
238
|
+
/**
|
|
239
|
+
* Normalized provider attributes available to an SSO user resolver. When
|
|
240
|
+
* `mapProfileToUser` is set, `email`, `name`, and `image` hold its result,
|
|
241
|
+
* and `emailVerified` is `false` if it changed the email. The provider's own
|
|
242
|
+
* values remain in `providerClaims`, `verifiedIdTokenClaims`, or
|
|
243
|
+
* `providerAttributes`.
|
|
244
|
+
*/
|
|
242
245
|
type SSOProviderUserProfile = {
|
|
243
246
|
email: string;
|
|
244
247
|
emailVerified: boolean;
|
|
@@ -294,6 +297,68 @@ type SSOUserResolutionInput = SSOOIDCUserResolutionInput | SSOSAMLUserResolution
|
|
|
294
297
|
interface SSOUserResolutionContext {
|
|
295
298
|
database: DBTransactionAdapter;
|
|
296
299
|
}
|
|
300
|
+
interface BaseSSOProfileMappingInput {
|
|
301
|
+
providerId: string;
|
|
302
|
+
accountKey: BaseSSOUserResolutionInput["accountKey"];
|
|
303
|
+
/**
|
|
304
|
+
* Profile fields read from the provider with the provider's `mapping`.
|
|
305
|
+
* `email` is absent when the provider sent no usable email.
|
|
306
|
+
*/
|
|
307
|
+
providerUser: {
|
|
308
|
+
email?: string | undefined;
|
|
309
|
+
/** Always `false` unless `trustEmailVerified` is enabled. */
|
|
310
|
+
emailVerified: boolean;
|
|
311
|
+
name?: string | undefined;
|
|
312
|
+
image?: string | undefined;
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
/** OIDC profile data available to `mapProfileToUser`. */
|
|
316
|
+
interface SSOOIDCProfileMappingInput extends BaseSSOProfileMappingInput {
|
|
317
|
+
protocol: "oidc";
|
|
318
|
+
/** Raw claims from UserInfo, or the verified ID Token when UserInfo is absent. */
|
|
319
|
+
providerClaims: Record<string, unknown>;
|
|
320
|
+
/** Claims from the cryptographically verified ID Token, or `{}` when none was issued. */
|
|
321
|
+
verifiedIdTokenClaims: Record<string, unknown>;
|
|
322
|
+
}
|
|
323
|
+
/** SAML profile data available to `mapProfileToUser`. */
|
|
324
|
+
interface SSOSAMLProfileMappingInput extends BaseSSOProfileMappingInput {
|
|
325
|
+
protocol: "saml";
|
|
326
|
+
/**
|
|
327
|
+
* Attributes from the verified assertion. Multi-valued attributes remain
|
|
328
|
+
* arrays and all scalar values remain strings.
|
|
329
|
+
*/
|
|
330
|
+
providerAttributes: Record<string, string | readonly string[]>;
|
|
331
|
+
/** The verified assertion's NameID, which is also `accountKey.accountId`. */
|
|
332
|
+
nameID: string;
|
|
333
|
+
/** The NameID `Format` attribute, when the assertion sets one. */
|
|
334
|
+
nameIDFormat?: string | undefined;
|
|
335
|
+
}
|
|
336
|
+
/** Provider profile data available to `mapProfileToUser`. */
|
|
337
|
+
type SSOProfileMappingInput = SSOOIDCProfileMappingInput | SSOSAMLProfileMappingInput;
|
|
338
|
+
/**
|
|
339
|
+
* User fields an application can set from `mapProfileToUser`. Omitted or
|
|
340
|
+
* `undefined` fields keep the provider's value. Other keys are ignored; set
|
|
341
|
+
* additional user fields with the provider's `mapping.extraFields` or with
|
|
342
|
+
* `provisionUser`.
|
|
343
|
+
*/
|
|
344
|
+
interface SSOMappedUser {
|
|
345
|
+
/** The account subject always comes from the provider's `sub` claim or NameID. */
|
|
346
|
+
id?: never;
|
|
347
|
+
/**
|
|
348
|
+
* An email that differs from the provider's must be an email address and is
|
|
349
|
+
* stored unverified. Return `null` to refuse the email the provider sent;
|
|
350
|
+
* sign-in then fails with `missing_user_info`.
|
|
351
|
+
*/
|
|
352
|
+
email?: string | null | undefined;
|
|
353
|
+
/**
|
|
354
|
+
* Not settable. The stored email is verified only when the provider asserts
|
|
355
|
+
* it with `trustEmailVerified` on. Domain verification lets an unverified
|
|
356
|
+
* email link to an existing user; it does not mark the email verified.
|
|
357
|
+
*/
|
|
358
|
+
emailVerified?: never;
|
|
359
|
+
name?: string | undefined;
|
|
360
|
+
image?: string | undefined;
|
|
361
|
+
}
|
|
297
362
|
interface BaseSSOProviderMutationGuardInput {
|
|
298
363
|
provider: {
|
|
299
364
|
id: string;
|
|
@@ -323,13 +388,33 @@ interface SSOProviderMutationGuardContext {
|
|
|
323
388
|
database: DBTransactionAdapter;
|
|
324
389
|
}
|
|
325
390
|
interface SSOOptions {
|
|
391
|
+
/**
|
|
392
|
+
* Map a provider profile to user fields before the user is looked up or
|
|
393
|
+
* created.
|
|
394
|
+
*
|
|
395
|
+
* Runs on every OIDC and SAML sign-in, after the provider's `mapping` is
|
|
396
|
+
* applied. Use it for providers that send no email: sign-in fails when
|
|
397
|
+
* neither the provider nor this callback yields one. A SAML NameID is used
|
|
398
|
+
* as the email only when its `Format` is
|
|
399
|
+
* `urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress`. If the callback
|
|
400
|
+
* throws or returns an invalid result, sign-in redirects with
|
|
401
|
+
* `SSO_PROFILE_MAPPING_FAILED`.
|
|
402
|
+
*
|
|
403
|
+
* The returned email is used to find, link, and create the user, and to
|
|
404
|
+
* decide whether the provider's verified domain trusts it. An email that
|
|
405
|
+
* differs from the provider's is stored unverified. Return only addresses
|
|
406
|
+
* the provider vouches for, or a placeholder built with
|
|
407
|
+
* `createPlaceholderEmail` from `@better-auth/core/utils/email`.
|
|
408
|
+
*/
|
|
409
|
+
mapProfileToUser?: ((input: SSOProfileMappingInput) => Awaitable<SSOMappedUser>) | undefined;
|
|
326
410
|
/**
|
|
327
411
|
* Resolve a verified provider identity to a Better Auth user.
|
|
328
412
|
*
|
|
329
413
|
* For OIDC, `accountKey` is derived from the validated ID Token. For SAML,
|
|
330
414
|
* it contains the verified IdP entity ID and signed NameID. Profile fields,
|
|
331
415
|
* raw OIDC claims, and SAML assertion attributes are protocol-accepted
|
|
332
|
-
* provider data and may require application-level validation.
|
|
416
|
+
* provider data and may require application-level validation. When
|
|
417
|
+
* `mapProfileToUser` is set, `providerUser` holds the mapped profile.
|
|
333
418
|
*
|
|
334
419
|
* The callback runs on every SSO sign-in inside the same native database
|
|
335
420
|
* transaction as account finalization and session creation.
|
|
@@ -683,8 +768,6 @@ interface SSOOptions {
|
|
|
683
768
|
idpInitiatedCallbackUrl?: string | undefined;
|
|
684
769
|
};
|
|
685
770
|
}
|
|
686
|
-
//#endregion
|
|
687
|
-
//#region src/routes/domain-verification.d.ts
|
|
688
771
|
declare const requestDomainVerification: (options: SSOOptions) => import("better-call").StrictEndpoint<"/sso/request-domain-verification", {
|
|
689
772
|
method: "POST";
|
|
690
773
|
body: z.ZodObject<{
|
|
@@ -782,16 +865,12 @@ declare const verifyDomain: (options: SSOOptions) => import("better-call").Stric
|
|
|
782
865
|
};
|
|
783
866
|
}>>[];
|
|
784
867
|
}, void>;
|
|
785
|
-
//#endregion
|
|
786
|
-
//#region src/utils.d.ts
|
|
787
868
|
declare function parseCertificate(certPem: string): {
|
|
788
869
|
fingerprintSha256: string;
|
|
789
870
|
notBefore: string;
|
|
790
871
|
notAfter: string;
|
|
791
872
|
publicKeyAlgorithm: string;
|
|
792
873
|
};
|
|
793
|
-
//#endregion
|
|
794
|
-
//#region src/routes/providers.d.ts
|
|
795
874
|
type ParsedCert = ReturnType<typeof parseCertificate>;
|
|
796
875
|
type SanitizedCert = ParsedCert | {
|
|
797
876
|
error: string;
|
|
@@ -1150,8 +1229,6 @@ declare const deleteSSOProvider: (options?: SSOOptions) => import("better-call")
|
|
|
1150
1229
|
}, {
|
|
1151
1230
|
success: boolean;
|
|
1152
1231
|
}>;
|
|
1153
|
-
//#endregion
|
|
1154
|
-
//#region src/routes/sso.d.ts
|
|
1155
1232
|
declare const spMetadata: (options?: SSOOptions) => import("better-call").StrictEndpoint<"/sso/saml2/sp/metadata", {
|
|
1156
1233
|
method: "GET";
|
|
1157
1234
|
query: z.ZodObject<{
|
|
@@ -1200,7 +1277,9 @@ declare const registerSSOProvider: <O extends SSOOptions>(options: O) => import(
|
|
|
1200
1277
|
}>>[];
|
|
1201
1278
|
metadata: {
|
|
1202
1279
|
$Infer: {
|
|
1203
|
-
body:
|
|
1280
|
+
body: {
|
|
1281
|
+
[x: string]: any;
|
|
1282
|
+
} & SSOProviderAdditionalFieldsInput<O>;
|
|
1204
1283
|
};
|
|
1205
1284
|
openapi: {
|
|
1206
1285
|
operationId: string;
|
|
@@ -1627,8 +1706,6 @@ declare const initiateSLO: (options?: SSOOptions) => import("better-call").Stric
|
|
|
1627
1706
|
readonly scope: "server";
|
|
1628
1707
|
};
|
|
1629
1708
|
}, never>;
|
|
1630
|
-
//#endregion
|
|
1631
|
-
//#region src/constants.d.ts
|
|
1632
1709
|
/**
|
|
1633
1710
|
* Default clock skew tolerance (5 minutes).
|
|
1634
1711
|
* Allows for minor time differences between IdP and SP servers.
|
|
@@ -1649,8 +1726,6 @@ declare const DEFAULT_MAX_SAML_RESPONSE_SIZE: number;
|
|
|
1649
1726
|
* Protects against oversized metadata documents.
|
|
1650
1727
|
*/
|
|
1651
1728
|
declare const DEFAULT_MAX_SAML_METADATA_SIZE: number;
|
|
1652
|
-
//#endregion
|
|
1653
|
-
//#region src/routes/helpers.d.ts
|
|
1654
1729
|
interface SAMLServiceProviderPolicy {
|
|
1655
1730
|
/** Effective assertion-signing requirement advertised by SP metadata. */
|
|
1656
1731
|
wantAssertionsSigned: boolean;
|
|
@@ -1668,8 +1743,6 @@ declare function deriveSAMLServiceProviderPolicy(config: Pick<SAMLConfig, "spMet
|
|
|
1668
1743
|
* parsing and manual-configuration validation as SAML authentication.
|
|
1669
1744
|
*/
|
|
1670
1745
|
declare function deriveSAMLIdentityProviderEntityID(config: SAMLConfig): string;
|
|
1671
|
-
//#endregion
|
|
1672
|
-
//#region src/saml/timestamp.d.ts
|
|
1673
1746
|
interface TimestampValidationOptions {
|
|
1674
1747
|
clockSkew?: number;
|
|
1675
1748
|
requireTimestamps?: boolean;
|
|
@@ -1688,8 +1761,6 @@ interface SAMLConditions {
|
|
|
1688
1761
|
* @throws {APIError} If timestamps are invalid, expired, or not yet valid
|
|
1689
1762
|
*/
|
|
1690
1763
|
declare function validateSAMLTimestamp(conditions: SAMLConditions | undefined, options?: TimestampValidationOptions): void;
|
|
1691
|
-
//#endregion
|
|
1692
|
-
//#region src/oidc/types.d.ts
|
|
1693
1764
|
/**
|
|
1694
1765
|
* OIDC Discovery Types
|
|
1695
1766
|
*
|
|
@@ -1856,8 +1927,6 @@ interface DiscoverOIDCConfigParams {
|
|
|
1856
1927
|
*/
|
|
1857
1928
|
declare const REQUIRED_DISCOVERY_FIELDS: readonly ["issuer", "authorization_endpoint", "token_endpoint", "jwks_uri"];
|
|
1858
1929
|
type RequiredDiscoveryField = (typeof REQUIRED_DISCOVERY_FIELDS)[number];
|
|
1859
|
-
//#endregion
|
|
1860
|
-
//#region src/oidc/discovery.d.ts
|
|
1861
1930
|
/**
|
|
1862
1931
|
* Main entry point: Discover and hydrate OIDC configuration from an issuer.
|
|
1863
1932
|
*
|
|
@@ -1957,8 +2026,6 @@ declare function selectTokenEndpointAuthMethod(doc: OIDCDiscoveryDocument, exist
|
|
|
1957
2026
|
* @returns true if runtime discovery should be performed
|
|
1958
2027
|
*/
|
|
1959
2028
|
declare function needsRuntimeDiscovery(config: Partial<HydratedOIDCConfig> | undefined): boolean;
|
|
1960
|
-
//#endregion
|
|
1961
|
-
//#region src/index.d.ts
|
|
1962
2029
|
declare module "@better-auth/core" {
|
|
1963
2030
|
interface BetterAuthPluginRegistry<AuthOptions, Options> {
|
|
1964
2031
|
sso: {
|
|
@@ -2022,5 +2089,4 @@ declare function sso<O extends SSOOptions>(options?: O | undefined): {
|
|
|
2022
2089
|
};
|
|
2023
2090
|
options: NoInfer<O>;
|
|
2024
2091
|
};
|
|
2025
|
-
|
|
2026
|
-
export { SAMLIdentityProviderMetadata as A, SSOUserResolutionContext as B, deriveSAMLIdentityProviderEntityID as C, DEFAULT_MAX_SAML_RESPONSE_SIZE as D, DEFAULT_MAX_SAML_METADATA_SIZE as E, SSOProviderMutationGuardInput as F, DigestAlgorithm as G, AlgorithmValidationOptions as H, SSOProviderReference as I, KeyEncryptionAlgorithm as K, SSOProviderUserProfile as L, SSOOptions as M, SSOProvider as N, OIDCConfig as O, SSOProviderMutationGuardContext as P, SSOSAMLUserResolutionInput as R, SAMLServiceProviderPolicy as S, DEFAULT_CLOCK_SKEW_MS as T, DataEncryptionAlgorithm as U, SSOUserResolutionInput as V, DeprecatedAlgorithmBehavior as W, REQUIRED_DISCOVERY_FIELDS as _, fetchDiscoveryDocument as a, TimestampValidationOptions as b, normalizeUrl as c, validateDiscoveryUrl as d, DiscoverOIDCConfigParams as f, OIDCDiscoveryDocument as g, HydratedOIDCConfig as h, discoverOIDCConfig as i, SSOOIDCUserResolutionInput as j, SAMLConfig as k, selectTokenEndpointAuthMethod as l, DiscoveryErrorCode as m, sso as n, needsRuntimeDiscovery as o, DiscoveryError as p, SignatureAlgorithm as q, computeDiscoveryUrl as r, normalizeDiscoveryUrls as s, SSOPlugin as t, validateDiscoveryDocument as u, RequiredDiscoveryField as v, deriveSAMLServiceProviderPolicy as w, validateSAMLTimestamp as x, SAMLConditions as y, SSOUserResolution as z };
|
|
2092
|
+
export { SAMLIdentityProviderMetadata as A, SSOProviderUserProfile as B, deriveSAMLIdentityProviderEntityID as C, DEFAULT_MAX_SAML_RESPONSE_SIZE as D, DEFAULT_MAX_SAML_METADATA_SIZE as E, SSOProfileMappingInput as F, SSOUserResolutionInput as G, SSOSAMLUserResolutionInput as H, SSOProvider as I, DeprecatedAlgorithmBehavior as J, AlgorithmValidationOptions as K, SSOProviderMutationGuardContext as L, SSOOIDCProfileMappingInput as M, SSOOIDCUserResolutionInput as N, OIDCConfig as O, SSOOptions as P, SSOProviderMutationGuardInput as R, SAMLServiceProviderPolicy as S, DEFAULT_CLOCK_SKEW_MS as T, SSOUserResolution as U, SSOSAMLProfileMappingInput as V, SSOUserResolutionContext as W, KeyEncryptionAlgorithm as X, DigestAlgorithm as Y, SignatureAlgorithm as Z, REQUIRED_DISCOVERY_FIELDS as _, fetchDiscoveryDocument as a, TimestampValidationOptions as b, normalizeUrl as c, validateDiscoveryUrl as d, DiscoverOIDCConfigParams as f, OIDCDiscoveryDocument as g, HydratedOIDCConfig as h, discoverOIDCConfig as i, SSOMappedUser as j, SAMLConfig as k, selectTokenEndpointAuthMethod as l, DiscoveryErrorCode as m, sso as n, needsRuntimeDiscovery as o, DiscoveryError as p, DataEncryptionAlgorithm as q, computeDiscoveryUrl as r, normalizeDiscoveryUrls as s, SSOPlugin as t, validateDiscoveryDocument as u, RequiredDiscoveryField as v, deriveSAMLServiceProviderPolicy as w, validateSAMLTimestamp as x, SAMLConditions as y, SSOProviderReference as z };
|
package/dist/index.d.mts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { A as SAMLIdentityProviderMetadata, B as
|
|
2
|
-
export { type AlgorithmValidationOptions, DEFAULT_CLOCK_SKEW_MS, DEFAULT_MAX_SAML_METADATA_SIZE, DEFAULT_MAX_SAML_RESPONSE_SIZE, DataEncryptionAlgorithm, type DeprecatedAlgorithmBehavior, DigestAlgorithm, type DiscoverOIDCConfigParams, DiscoveryError, type DiscoveryErrorCode, type HydratedOIDCConfig, KeyEncryptionAlgorithm, type OIDCConfig, type OIDCDiscoveryDocument, REQUIRED_DISCOVERY_FIELDS, type RequiredDiscoveryField, type SAMLConditions, type SAMLConfig, type SAMLIdentityProviderMetadata, type SAMLServiceProviderPolicy, type SSOOIDCUserResolutionInput, type SSOOptions, SSOPlugin, type SSOProvider, type SSOProviderMutationGuardContext, type SSOProviderMutationGuardInput, type SSOProviderReference, type SSOProviderUserProfile, type SSOSAMLUserResolutionInput, type SSOUserResolution, type SSOUserResolutionContext, type SSOUserResolutionInput, SignatureAlgorithm, type TimestampValidationOptions, computeDiscoveryUrl, deriveSAMLIdentityProviderEntityID, deriveSAMLServiceProviderPolicy, discoverOIDCConfig, fetchDiscoveryDocument, needsRuntimeDiscovery, normalizeDiscoveryUrls, normalizeUrl, selectTokenEndpointAuthMethod, sso, validateDiscoveryDocument, validateDiscoveryUrl, validateSAMLTimestamp };
|
|
1
|
+
import { A as SAMLIdentityProviderMetadata, B as SSOProviderUserProfile, C as deriveSAMLIdentityProviderEntityID, D as DEFAULT_MAX_SAML_RESPONSE_SIZE, E as DEFAULT_MAX_SAML_METADATA_SIZE, F as SSOProfileMappingInput, G as SSOUserResolutionInput, H as SSOSAMLUserResolutionInput, I as SSOProvider, J as DeprecatedAlgorithmBehavior, K as AlgorithmValidationOptions, L as SSOProviderMutationGuardContext, M as SSOOIDCProfileMappingInput, N as SSOOIDCUserResolutionInput, O as OIDCConfig, P as SSOOptions, R as SSOProviderMutationGuardInput, S as SAMLServiceProviderPolicy, T as DEFAULT_CLOCK_SKEW_MS, U as SSOUserResolution, V as SSOSAMLProfileMappingInput, W as SSOUserResolutionContext, X as KeyEncryptionAlgorithm, Y as DigestAlgorithm, Z as SignatureAlgorithm, _ as REQUIRED_DISCOVERY_FIELDS, a as fetchDiscoveryDocument, b as TimestampValidationOptions, c as normalizeUrl, d as validateDiscoveryUrl, f as DiscoverOIDCConfigParams, g as OIDCDiscoveryDocument, h as HydratedOIDCConfig, i as discoverOIDCConfig, j as SSOMappedUser, k as SAMLConfig, l as selectTokenEndpointAuthMethod, m as DiscoveryErrorCode, n as sso, o as needsRuntimeDiscovery, p as DiscoveryError, q as DataEncryptionAlgorithm, r as computeDiscoveryUrl, s as normalizeDiscoveryUrls, t as SSOPlugin, u as validateDiscoveryDocument, v as RequiredDiscoveryField, w as deriveSAMLServiceProviderPolicy, x as validateSAMLTimestamp, y as SAMLConditions, z as SSOProviderReference } from "./index-BkypgbRt.mjs";
|
|
2
|
+
export { type AlgorithmValidationOptions, DEFAULT_CLOCK_SKEW_MS, DEFAULT_MAX_SAML_METADATA_SIZE, DEFAULT_MAX_SAML_RESPONSE_SIZE, DataEncryptionAlgorithm, type DeprecatedAlgorithmBehavior, DigestAlgorithm, type DiscoverOIDCConfigParams, DiscoveryError, type DiscoveryErrorCode, type HydratedOIDCConfig, KeyEncryptionAlgorithm, type OIDCConfig, type OIDCDiscoveryDocument, REQUIRED_DISCOVERY_FIELDS, type RequiredDiscoveryField, type SAMLConditions, type SAMLConfig, type SAMLIdentityProviderMetadata, type SAMLServiceProviderPolicy, type SSOMappedUser, type SSOOIDCProfileMappingInput, type SSOOIDCUserResolutionInput, type SSOOptions, SSOPlugin, type SSOProfileMappingInput, type SSOProvider, type SSOProviderMutationGuardContext, type SSOProviderMutationGuardInput, type SSOProviderReference, type SSOProviderUserProfile, type SSOSAMLProfileMappingInput, type SSOSAMLUserResolutionInput, type SSOUserResolution, type SSOUserResolutionContext, type SSOUserResolutionInput, SignatureAlgorithm, type TimestampValidationOptions, computeDiscoveryUrl, deriveSAMLIdentityProviderEntityID, deriveSAMLServiceProviderPolicy, discoverOIDCConfig, fetchDiscoveryDocument, needsRuntimeDiscovery, normalizeDiscoveryUrls, normalizeUrl, selectTokenEndpointAuthMethod, sso, validateDiscoveryDocument, validateDiscoveryUrl, validateSAMLTimestamp };
|
package/dist/index.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { t as PACKAGE_VERSION } from "./version-
|
|
2
|
-
import { APIError, addOAuthServerContext, createAuthEndpoint, createAuthMiddleware, getSessionFromCtx, sessionMiddleware } from "better-auth/api";
|
|
1
|
+
import { t as PACKAGE_VERSION } from "./version-D4csRLQZ.mjs";
|
|
2
|
+
import { APIError, addOAuthServerContext, createAuthEndpoint, createAuthMiddleware, generateGenericState, getSessionFromCtx, parseGenericState, sessionMiddleware } from "better-auth/api";
|
|
3
3
|
import { XMLParser, XMLValidator } from "fast-xml-parser";
|
|
4
4
|
import { X509Certificate } from "node:crypto";
|
|
5
5
|
import { getHostname } from "tldts";
|
|
@@ -17,12 +17,13 @@ import { DOMParser } from "@xmldom/xmldom";
|
|
|
17
17
|
import samlifyDefault, * as samlifyNamespace from "samlify";
|
|
18
18
|
import { defineErrorCodes } from "@better-auth/core/utils/error-codes";
|
|
19
19
|
import { parseInputData, toZodSchema } from "better-auth/db";
|
|
20
|
+
import { HIDE_METADATA } from "@better-auth/core/api";
|
|
21
|
+
import { PRIVATE_KEY_JWT_SIGNING_ALGORITHMS, authorizationCodeRequest, createAuthorizationURL, createPrivateKeyJwtClientAssertionGetter, getOAuth2Tokens, parseStringBoolean } from "@better-auth/core/oauth2";
|
|
20
22
|
import { isAPIError } from "@better-auth/core/utils/is-api-error";
|
|
21
23
|
import { appendQueryParams } from "@better-auth/core/utils/url";
|
|
22
|
-
import { HIDE_METADATA, PRIVATE_KEY_JWT_SIGNING_ALGORITHMS, authorizationCodeRequest, createAuthorizationURL, createPrivateKeyJwtClientAssertionGetter, generateGenericState, generateState, getOAuth2Tokens, parseGenericState, parseState } from "better-auth";
|
|
23
24
|
import { deleteSessionCookie, setAccountCookie, setSessionCookie } from "better-auth/cookies";
|
|
24
|
-
import { additionalAuthorizationParamsSchema, handleOAuthUserInfo } from "better-auth/oauth2";
|
|
25
|
-
|
|
25
|
+
import { additionalAuthorizationParamsSchema, generateState, handleOAuthUserInfo, parseState } from "better-auth/oauth2";
|
|
26
|
+
// src/constants.ts
|
|
26
27
|
/**
|
|
27
28
|
* SAML Constants
|
|
28
29
|
*
|
|
@@ -61,8 +62,25 @@ const DEFAULT_MAX_SAML_RESPONSE_SIZE = 262144;
|
|
|
61
62
|
* Protects against oversized metadata documents.
|
|
62
63
|
*/
|
|
63
64
|
const DEFAULT_MAX_SAML_METADATA_SIZE = 102400;
|
|
64
|
-
|
|
65
|
-
|
|
65
|
+
/**
|
|
66
|
+
* NameID format whose value is an email address. Other formats, such as
|
|
67
|
+
* persistent and transient, are opaque identifiers.
|
|
68
|
+
*
|
|
69
|
+
* @see https://docs.oasis-open.org/security/saml/v2.0/saml-core-2.0-os.pdf (§8.3.2)
|
|
70
|
+
*/
|
|
71
|
+
const SAML_EMAIL_NAME_ID_FORMAT = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress";
|
|
72
|
+
/**
|
|
73
|
+
* Attributes read for the email, in order, when `samlConfig.mapping.email` is
|
|
74
|
+
* not set and neither the `email` attribute nor an `emailAddress` NameID is
|
|
75
|
+
* present: the claim URI many IdPs send by default, and the `mail` attribute
|
|
76
|
+
* by name and by OID.
|
|
77
|
+
*/
|
|
78
|
+
const SAML_FALLBACK_EMAIL_ATTRIBUTES = [
|
|
79
|
+
"http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress",
|
|
80
|
+
"mail",
|
|
81
|
+
"urn:oid:0.9.2342.19200300.100.1.3"
|
|
82
|
+
];
|
|
83
|
+
// src/utils.ts
|
|
66
84
|
const unsafeSAMLRedirectPathPrefix = /^\/(?:\/|\\|%2f|%5c)/i;
|
|
67
85
|
const isSafeSAMLRedirectPath = (url) => url.startsWith("/") && !unsafeSAMLRedirectPathPrefix.test(url);
|
|
68
86
|
/**
|
|
@@ -94,16 +112,6 @@ const domainMatches = (searchDomain, domainList) => {
|
|
|
94
112
|
return domains.some((domain) => search === domain || search.endsWith(`.${domain}`));
|
|
95
113
|
};
|
|
96
114
|
/**
|
|
97
|
-
* Strictly parse a provider-supplied email-verification claim.
|
|
98
|
-
*
|
|
99
|
-
* OIDC userInfo, OIDC id-token, and SAML attribute values are frequently
|
|
100
|
-
* strings, so a loose `Boolean(value)` or truthy fallback treats the string
|
|
101
|
-
* `"false"` as verified. Only a boolean `true` or the exact string `"true"`
|
|
102
|
-
* count as verified; every other value, including `"false"`, `"0"`, `""`,
|
|
103
|
-
* numbers, arrays, and objects, is unverified.
|
|
104
|
-
*/
|
|
105
|
-
const parseProviderEmailVerified = (value) => value === true || value === "true";
|
|
106
|
-
/**
|
|
107
115
|
* Validates email domain against allowed domain(s).
|
|
108
116
|
* Supports comma-separated domains for multi-domain SSO.
|
|
109
117
|
*/
|
|
@@ -151,8 +159,7 @@ function maskClientId(clientId) {
|
|
|
151
159
|
if (clientId.length <= 4) return "****";
|
|
152
160
|
return `****${clientId.slice(-4)}`;
|
|
153
161
|
}
|
|
154
|
-
|
|
155
|
-
//#region src/linking/org-assignment.ts
|
|
162
|
+
// src/linking/org-assignment.ts
|
|
156
163
|
const VERIFIED_PROVIDER_PAGE_SIZE = 100;
|
|
157
164
|
function getEmailDomain(email) {
|
|
158
165
|
const parts = email.trim().toLowerCase().split("@");
|
|
@@ -295,8 +302,7 @@ async function assignOrganizationByDomain(ctx, options) {
|
|
|
295
302
|
...options
|
|
296
303
|
});
|
|
297
304
|
}
|
|
298
|
-
|
|
299
|
-
//#region src/oidc/types.ts
|
|
305
|
+
// src/oidc/types.ts
|
|
300
306
|
/**
|
|
301
307
|
* Custom error class for OIDC discovery failures.
|
|
302
308
|
* Can be caught and mapped to APIError at the edge.
|
|
@@ -321,8 +327,7 @@ const REQUIRED_DISCOVERY_FIELDS = [
|
|
|
321
327
|
"token_endpoint",
|
|
322
328
|
"jwks_uri"
|
|
323
329
|
];
|
|
324
|
-
|
|
325
|
-
//#region src/oidc/discovery.ts
|
|
330
|
+
// src/oidc/discovery.ts
|
|
326
331
|
/**
|
|
327
332
|
* OIDC Discovery Pipeline
|
|
328
333
|
*
|
|
@@ -788,8 +793,7 @@ async function ensureRuntimeDiscovery(config, issuer, isTrustedOrigin) {
|
|
|
788
793
|
await assertServerFetchedOIDCEndpointsAllowed(resolved, isTrustedOrigin);
|
|
789
794
|
return resolved;
|
|
790
795
|
}
|
|
791
|
-
|
|
792
|
-
//#region src/oidc/errors.ts
|
|
796
|
+
// src/oidc/errors.ts
|
|
793
797
|
/**
|
|
794
798
|
* OIDC Discovery Error Mapping
|
|
795
799
|
*
|
|
@@ -869,8 +873,7 @@ function mapDiscoveryErrorToAPIError(error) {
|
|
|
869
873
|
});
|
|
870
874
|
}
|
|
871
875
|
}
|
|
872
|
-
|
|
873
|
-
//#region src/provider-reference.ts
|
|
876
|
+
// src/provider-reference.ts
|
|
874
877
|
const SSO_PROVIDER_STATE_KEY = "ssoProviderReference";
|
|
875
878
|
function serializeCanonical(value) {
|
|
876
879
|
if (value === null) return "null";
|
|
@@ -965,8 +968,7 @@ async function isCurrentSSOProviderReference(provider, reference) {
|
|
|
965
968
|
if (source.type === "persisted" && (reference.source.type !== "persisted" || source.recordId !== reference.source.recordId)) return false;
|
|
966
969
|
return reference.authenticationConfigurationFingerprint === await computeProviderAuthenticationFingerprint(provider);
|
|
967
970
|
}
|
|
968
|
-
|
|
969
|
-
//#region src/saml/parser.ts
|
|
971
|
+
// src/saml/parser.ts
|
|
970
972
|
const xmlParser = new XMLParser({
|
|
971
973
|
ignoreAttributes: false,
|
|
972
974
|
attributeNamePrefix: "@_",
|
|
@@ -999,8 +1001,7 @@ function countAllNodes(obj, nodeName) {
|
|
|
999
1001
|
else if (typeof value === "object" && value !== null) count += countAllNodes(value, nodeName);
|
|
1000
1002
|
return count;
|
|
1001
1003
|
}
|
|
1002
|
-
|
|
1003
|
-
//#region src/saml/algorithms.ts
|
|
1004
|
+
// src/saml/algorithms.ts
|
|
1004
1005
|
const SignatureAlgorithm = {
|
|
1005
1006
|
RSA_SHA1: "http://www.w3.org/2000/09/xmldsig#rsa-sha1",
|
|
1006
1007
|
RSA_SHA256: "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256",
|
|
@@ -1177,8 +1178,7 @@ function validateConfigAlgorithms(config, options = {}) {
|
|
|
1177
1178
|
}
|
|
1178
1179
|
}
|
|
1179
1180
|
const saml = typeof samlifyNamespace.SPMetadata === "function" && typeof samlifyNamespace.setSchemaValidator === "function" ? samlifyNamespace : samlifyDefault ?? samlifyNamespace;
|
|
1180
|
-
|
|
1181
|
-
//#region src/saml/assertions.ts
|
|
1181
|
+
// src/saml/assertions.ts
|
|
1182
1182
|
const SAML_ASSERTION_NAMESPACE = "urn:oasis:names:tc:SAML:2.0:assertion";
|
|
1183
1183
|
const SAML_PROTOCOL_NAMESPACE = "urn:oasis:names:tc:SAML:2.0:protocol";
|
|
1184
1184
|
const XML_SIGNATURE_NAMESPACE = "http://www.w3.org/2000/09/xmldsig#";
|
|
@@ -1254,8 +1254,7 @@ function verifySAMLAssertionSignature(samlContent, verificationOptions) {
|
|
|
1254
1254
|
throw assertionSignatureFailure();
|
|
1255
1255
|
}
|
|
1256
1256
|
}
|
|
1257
|
-
|
|
1258
|
-
//#region src/saml/error-codes.ts
|
|
1257
|
+
// src/saml/error-codes.ts
|
|
1259
1258
|
const SAML_ERROR_CODES = defineErrorCodes({
|
|
1260
1259
|
SINGLE_LOGOUT_NOT_ENABLED: "Single Logout is not enabled",
|
|
1261
1260
|
INVALID_LOGOUT_RESPONSE: "Invalid LogoutResponse",
|
|
@@ -1265,8 +1264,7 @@ const SAML_ERROR_CODES = defineErrorCodes({
|
|
|
1265
1264
|
SAML_PROVIDER_NOT_FOUND: "SAML provider not found",
|
|
1266
1265
|
CERT_SOURCE_MISSING: "samlConfig requires either a signing certificate (cert or idpMetadata.cert) or an idpMetadata.metadata XML document."
|
|
1267
1266
|
});
|
|
1268
|
-
|
|
1269
|
-
//#region src/saml/cert.ts
|
|
1267
|
+
// src/saml/cert.ts
|
|
1270
1268
|
/**
|
|
1271
1269
|
* IdP signing-certificate rules for SAML configs. Centralized so the runtime
|
|
1272
1270
|
* verification path (`createIdP`), the sanitizer (`getSSOProvider` and
|
|
@@ -1295,8 +1293,7 @@ function validateCertSources(config) {
|
|
|
1295
1293
|
const hasExplicitCert = config.idpMetadata?.cert !== void 0 || config.cert !== void 0;
|
|
1296
1294
|
if (!hasMetadataXml && !hasExplicitCert) throw APIError.from("BAD_REQUEST", SAML_ERROR_CODES.CERT_SOURCE_MISSING);
|
|
1297
1295
|
}
|
|
1298
|
-
|
|
1299
|
-
//#region src/saml/response-binding.ts
|
|
1296
|
+
// src/saml/response-binding.ts
|
|
1300
1297
|
const SAML_HTTP_POST_BINDING = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST";
|
|
1301
1298
|
const SAML_BEARER_CONFIRMATION_METHOD = "urn:oasis:names:tc:SAML:2.0:cm:bearer";
|
|
1302
1299
|
const SAML_METADATA_NAMESPACE = "urn:oasis:names:tc:SAML:2.0:metadata";
|
|
@@ -1383,7 +1380,7 @@ function parseSAMLServiceProviderMetadata(metadata) {
|
|
|
1383
1380
|
if (value) nameIDFormats.push(value);
|
|
1384
1381
|
}
|
|
1385
1382
|
for (const service of directMetadataChildren(descriptor, "AssertionConsumerService")) {
|
|
1386
|
-
if (service.getAttribute("Binding") !==
|
|
1383
|
+
if (service.getAttribute("Binding") !== SAML_HTTP_POST_BINDING) continue;
|
|
1387
1384
|
const location = service.getAttribute("Location")?.trim();
|
|
1388
1385
|
if (!location || !isAbsoluteHttpUrl(location)) throw new Error("Invalid SAML POST AssertionConsumerService");
|
|
1389
1386
|
postAssertionConsumerServiceUrls.push(location);
|
|
@@ -1436,6 +1433,15 @@ function validateAudienceRestrictions(assertion, expectedAudiences) {
|
|
|
1436
1433
|
code: "SAML_AUDIENCE_MISMATCH"
|
|
1437
1434
|
});
|
|
1438
1435
|
}
|
|
1436
|
+
/**
|
|
1437
|
+
* Reads the user's email from a SAML assertion: the mapped attribute (default
|
|
1438
|
+
* `email`), then a NameID whose Format is `emailAddress`, then, without a
|
|
1439
|
+
* mapped attribute, the fallback email attributes. Other NameIDs are opaque or
|
|
1440
|
+
* unspecified, so they never become the email.
|
|
1441
|
+
*/
|
|
1442
|
+
function readSAMLEmail(attr, options) {
|
|
1443
|
+
return attr(options.mappedAttribute || "email") || (options.nameIDFormat === "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress" ? options.nameID : void 0) || (options.mappedAttribute ? void 0 : SAML_FALLBACK_EMAIL_ATTRIBUTES.map(attr).find(Boolean));
|
|
1444
|
+
}
|
|
1439
1445
|
function getBearerSubjectConfirmationData(assertion) {
|
|
1440
1446
|
return toNodeArray(firstNode(assertion.Subject)?.SubjectConfirmation).filter((confirmation) => confirmation["@_Method"] === SAML_BEARER_CONFIRMATION_METHOD).map((confirmation) => firstNode(confirmation.SubjectConfirmationData)).filter((data) => !!data);
|
|
1441
1447
|
}
|
|
@@ -1463,6 +1469,11 @@ function validateResponseDestination(response, expectedRecipients) {
|
|
|
1463
1469
|
code: "SAML_DESTINATION_MISMATCH"
|
|
1464
1470
|
});
|
|
1465
1471
|
}
|
|
1472
|
+
/**
|
|
1473
|
+
* Validates that the assertion is bound to this Service Provider, then returns
|
|
1474
|
+
* the NameID format so user attribute extraction need not parse the response
|
|
1475
|
+
* again.
|
|
1476
|
+
*/
|
|
1466
1477
|
function validateSAMLResponseBinding(samlContent, options) {
|
|
1467
1478
|
const expectedAudiences = toStringSet(options.expectedAudiences);
|
|
1468
1479
|
const expectedRecipients = toStringSet(options.expectedRecipients);
|
|
@@ -1472,9 +1483,10 @@ function validateSAMLResponseBinding(samlContent, options) {
|
|
|
1472
1483
|
validateAudienceRestrictions(assertion, expectedAudiences);
|
|
1473
1484
|
validateBearerRecipient(assertion, expectedRecipients);
|
|
1474
1485
|
validateResponseDestination(response, expectedRecipients);
|
|
1486
|
+
const nameIDFormat = firstNode(firstNode(assertion.Subject)?.NameID)?.["@_Format"];
|
|
1487
|
+
return { nameIDFormat: typeof nameIDFormat === "string" && nameIDFormat ? nameIDFormat : void 0 };
|
|
1475
1488
|
}
|
|
1476
|
-
|
|
1477
|
-
//#region src/saml/response-validation.ts
|
|
1489
|
+
// src/saml/response-validation.ts
|
|
1478
1490
|
function errorRedirectUrl(base, error, description) {
|
|
1479
1491
|
try {
|
|
1480
1492
|
const url = new URL(base);
|
|
@@ -1571,9 +1583,66 @@ function validateAudience(c, ctx) {
|
|
|
1571
1583
|
throw c.redirect(errorRedirectUrl(ctx.redirectUrl, "invalid_saml_response", "Audience mismatch"));
|
|
1572
1584
|
}
|
|
1573
1585
|
}
|
|
1574
|
-
|
|
1575
|
-
//#region src/user-resolution.ts
|
|
1586
|
+
// src/user-resolution.ts
|
|
1576
1587
|
const SSO_AUTHENTICATION_FAILURE = Symbol("SSO_AUTHENTICATION_FAILURE");
|
|
1588
|
+
/**
|
|
1589
|
+
* Applies `mapProfileToUser` to the profile read from the provider. Only the
|
|
1590
|
+
* documented user fields are taken from the callback's result.
|
|
1591
|
+
*/
|
|
1592
|
+
async function mapSSOProfile(options, input, logger) {
|
|
1593
|
+
if (!options?.mapProfileToUser) return input.providerUser;
|
|
1594
|
+
let mappedUser;
|
|
1595
|
+
try {
|
|
1596
|
+
const result = await options.mapProfileToUser(structuredClone(input));
|
|
1597
|
+
if (isRecord$1(result)) {
|
|
1598
|
+
const { email, name, image } = result;
|
|
1599
|
+
mappedUser = {
|
|
1600
|
+
email,
|
|
1601
|
+
name,
|
|
1602
|
+
image
|
|
1603
|
+
};
|
|
1604
|
+
}
|
|
1605
|
+
} catch (error) {
|
|
1606
|
+
logFailure(logger, "SSO profile mapping failed", {
|
|
1607
|
+
providerId: input.providerId,
|
|
1608
|
+
protocol: input.protocol,
|
|
1609
|
+
error
|
|
1610
|
+
});
|
|
1611
|
+
throw profileMappingFailure();
|
|
1612
|
+
}
|
|
1613
|
+
if (!isSSOMappedUser(mappedUser) || !isAcceptedEmail(mappedUser.email, input.providerUser.email)) {
|
|
1614
|
+
logFailure(logger, "SSO profile mapper returned an invalid profile", {
|
|
1615
|
+
providerId: input.providerId,
|
|
1616
|
+
protocol: input.protocol
|
|
1617
|
+
});
|
|
1618
|
+
throw profileMappingFailure();
|
|
1619
|
+
}
|
|
1620
|
+
const { email, name, image } = mappedUser;
|
|
1621
|
+
const providerUser = input.providerUser;
|
|
1622
|
+
const mappedEmail = email === null ? void 0 : email ?? providerUser.email;
|
|
1623
|
+
return {
|
|
1624
|
+
email: mappedEmail,
|
|
1625
|
+
emailVerified: providerUser.emailVerified && mappedEmail?.toLowerCase() === providerUser.email?.toLowerCase(),
|
|
1626
|
+
name: name ?? providerUser.name,
|
|
1627
|
+
image: image ?? providerUser.image
|
|
1628
|
+
};
|
|
1629
|
+
}
|
|
1630
|
+
function isOptionalString(value) {
|
|
1631
|
+
return value === void 0 || typeof value === "string";
|
|
1632
|
+
}
|
|
1633
|
+
function isSSOMappedUser(value) {
|
|
1634
|
+
return isRecord$1(value) && (value.email === null || isOptionalString(value.email)) && isOptionalString(value.name) && isOptionalString(value.image);
|
|
1635
|
+
}
|
|
1636
|
+
/** A changed email must be an address; the provider's own email passes as sent. */
|
|
1637
|
+
function isAcceptedEmail(email, providerEmail) {
|
|
1638
|
+
return typeof email !== "string" || email.toLowerCase() === providerEmail?.toLowerCase() || z.email().safeParse(email).success;
|
|
1639
|
+
}
|
|
1640
|
+
function profileMappingFailure() {
|
|
1641
|
+
return new APIError("INTERNAL_SERVER_ERROR", {
|
|
1642
|
+
code: "SSO_PROFILE_MAPPING_FAILED",
|
|
1643
|
+
message: "Unable to map the SSO profile"
|
|
1644
|
+
});
|
|
1645
|
+
}
|
|
1577
1646
|
function isRecord$1(value) {
|
|
1578
1647
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1579
1648
|
}
|
|
@@ -1586,9 +1655,9 @@ function isSSOUserResolution(value) {
|
|
|
1586
1655
|
if (value.action === "link") return isNonEmptyString(value.userId) && (value.profile === "preserve" || value.profile === "update");
|
|
1587
1656
|
return value.action === "reject" && isNonEmptyString(value.code) && (value.message === void 0 || typeof value.message === "string");
|
|
1588
1657
|
}
|
|
1589
|
-
function logFailure(logger, message) {
|
|
1658
|
+
function logFailure(logger, message, details) {
|
|
1590
1659
|
try {
|
|
1591
|
-
logger.error(message);
|
|
1660
|
+
logger.error(message, ...details ? [details] : []);
|
|
1592
1661
|
} catch {}
|
|
1593
1662
|
}
|
|
1594
1663
|
function resolutionFailure() {
|
|
@@ -1658,8 +1727,7 @@ function getFailedSSOAuthenticationResult(error) {
|
|
|
1658
1727
|
if (!(error instanceof Error) || !(SSO_AUTHENTICATION_FAILURE in error) || error[SSO_AUTHENTICATION_FAILURE] !== true || !("result" in error)) return;
|
|
1659
1728
|
return error.result;
|
|
1660
1729
|
}
|
|
1661
|
-
|
|
1662
|
-
//#region src/routes/helpers.ts
|
|
1730
|
+
// src/routes/helpers.ts
|
|
1663
1731
|
/**
|
|
1664
1732
|
* Same as `normalizePem`, but applied across the resolved list of IdP signing
|
|
1665
1733
|
* certificates so multi-cert rotation configs survive the line-trim step.
|
|
@@ -1693,6 +1761,7 @@ async function findSAMLProvider(providerId, options, adapter) {
|
|
|
1693
1761
|
}
|
|
1694
1762
|
function createSP(config, baseURL, providerId, opts) {
|
|
1695
1763
|
const spData = config.spMetadata;
|
|
1764
|
+
const clockSkew = opts?.clockSkew ?? 3e5;
|
|
1696
1765
|
const sloLocation = `${baseURL}/sso/saml2/sp/slo/${providerId}`;
|
|
1697
1766
|
const acsUrl = `${baseURL}/sso/saml2/sp/acs/${providerId}`;
|
|
1698
1767
|
let metadata = spData?.metadata;
|
|
@@ -1725,7 +1794,7 @@ function createSP(config, baseURL, providerId, opts) {
|
|
|
1725
1794
|
encPrivateKey: normalizePem(spData?.encPrivateKey),
|
|
1726
1795
|
encPrivateKeyPass: spData?.encPrivateKeyPass,
|
|
1727
1796
|
relayState: opts?.relayState,
|
|
1728
|
-
clockDrifts:
|
|
1797
|
+
clockDrifts: [-clockSkew, clockSkew]
|
|
1729
1798
|
});
|
|
1730
1799
|
}
|
|
1731
1800
|
/**
|
|
@@ -1822,8 +1891,7 @@ function createSAMLPostForm(action, samlParam, samlValue, relayState) {
|
|
|
1822
1891
|
const html = `<!DOCTYPE html><html><body onload="document.forms[0].submit();"><form method="POST" action="${safeAction}"><input type="hidden" name="${safeSamlParam}" value="${safeSamlValue}" />${safeRelayState ? `<input type="hidden" name="RelayState" value="${safeRelayState}" />` : ""}<noscript><input type="submit" value="Continue" /></noscript></form></body></html>`;
|
|
1823
1892
|
return new Response(html, { headers: { "Content-Type": "text/html" } });
|
|
1824
1893
|
}
|
|
1825
|
-
|
|
1826
|
-
//#region src/routes/schemas.ts
|
|
1894
|
+
// src/routes/schemas.ts
|
|
1827
1895
|
const absoluteUrlSchema = z.url();
|
|
1828
1896
|
const samlRedirectUrlSchema = z.string().refine((url) => isSafeSAMLRedirectPath(url) || absoluteUrlSchema.safeParse(url).success, { message: "Expected an absolute URL or a relative path starting with /" });
|
|
1829
1897
|
function getSSOProviderAdditionalFields$1(options) {
|
|
@@ -1966,8 +2034,7 @@ function getUpdateSSOProviderBodySchema(options) {
|
|
|
1966
2034
|
...getSSOProviderAdditionalFieldsSchema(options).partial().shape
|
|
1967
2035
|
});
|
|
1968
2036
|
}
|
|
1969
|
-
|
|
1970
|
-
//#region src/routes/providers.ts
|
|
2037
|
+
// src/routes/providers.ts
|
|
1971
2038
|
const ADMIN_ROLES = ["owner", "admin"];
|
|
1972
2039
|
const OIDC_IDENTITY_BOUNDARY_FIELDS = [
|
|
1973
2040
|
"authorizationEndpoint",
|
|
@@ -2518,8 +2585,7 @@ const deleteSSOProvider = (options) => {
|
|
|
2518
2585
|
return ctx.json({ success: true });
|
|
2519
2586
|
});
|
|
2520
2587
|
};
|
|
2521
|
-
|
|
2522
|
-
//#region src/routes/domain-verification.ts
|
|
2588
|
+
// src/routes/domain-verification.ts
|
|
2523
2589
|
const DNS_LABEL_MAX_LENGTH = 63;
|
|
2524
2590
|
const DEFAULT_TOKEN_PREFIX = "better-auth-token";
|
|
2525
2591
|
const domainVerificationBodySchema = z.object({ providerId: z.string() });
|
|
@@ -2648,8 +2714,7 @@ const verifyDomain = (options) => {
|
|
|
2648
2714
|
ctx.setStatus(204);
|
|
2649
2715
|
});
|
|
2650
2716
|
};
|
|
2651
|
-
|
|
2652
|
-
//#region src/saml-state.ts
|
|
2717
|
+
// src/saml-state.ts
|
|
2653
2718
|
async function generateRelayState(c, link, providerReference) {
|
|
2654
2719
|
const callbackURL = c.body.callbackURL;
|
|
2655
2720
|
if (!callbackURL) throw new APIError("BAD_REQUEST", { message: "callbackURL is required" });
|
|
@@ -2699,8 +2764,7 @@ async function parseRelayState(c) {
|
|
|
2699
2764
|
if (!parsedData.errorURL) parsedData.errorURL = errorURL;
|
|
2700
2765
|
return parsedData;
|
|
2701
2766
|
}
|
|
2702
|
-
|
|
2703
|
-
//#region src/saml/timestamp.ts
|
|
2767
|
+
// src/saml/timestamp.ts
|
|
2704
2768
|
/**
|
|
2705
2769
|
* Validates SAML assertion timestamp conditions (NotBefore/NotOnOrAfter).
|
|
2706
2770
|
* Prevents acceptance of expired or future-dated assertions.
|
|
@@ -2740,8 +2804,7 @@ function validateSAMLTimestamp(conditions, options = {}) {
|
|
|
2740
2804
|
});
|
|
2741
2805
|
}
|
|
2742
2806
|
}
|
|
2743
|
-
|
|
2744
|
-
//#region src/routes/saml-pipeline.ts
|
|
2807
|
+
// src/routes/saml-pipeline.ts
|
|
2745
2808
|
function isSameSSOProviderReference(left, right) {
|
|
2746
2809
|
if (left.providerId !== right.providerId || left.authenticationConfigurationFingerprint !== right.authenticationConfigurationFingerprint || left.source.type !== right.source.type) return false;
|
|
2747
2810
|
return left.source.type === "configured" ? true : right.source.type === "persisted" && left.source.recordId === right.source.recordId;
|
|
@@ -2806,17 +2869,12 @@ function getSAMLRedirectCandidates(relayStateCallbackUrl, samlConfig, samlOption
|
|
|
2806
2869
|
samlConfig?.callbackUrl
|
|
2807
2870
|
];
|
|
2808
2871
|
}
|
|
2809
|
-
function
|
|
2810
|
-
if (Array.isArray(value)) return value;
|
|
2811
|
-
return value ? [value] : [];
|
|
2812
|
-
}
|
|
2813
|
-
function getExpectedSAMLRecipients(config, baseURL, providerId, currentCallbackPath, assertionConsumerServiceUrl) {
|
|
2872
|
+
function getExpectedSAMLRecipients(config, baseURL, providerId, currentCallbackPath) {
|
|
2814
2873
|
const configuredPostAssertionConsumerServiceUrls = getSAMLPostAssertionConsumerServiceUrls(config.spMetadata?.metadata);
|
|
2815
2874
|
return [
|
|
2816
2875
|
currentCallbackPath,
|
|
2817
2876
|
`${baseURL}/sso/saml2/sp/acs/${providerId}`,
|
|
2818
|
-
...configuredPostAssertionConsumerServiceUrls
|
|
2819
|
-
...toArray(assertionConsumerServiceUrl)
|
|
2877
|
+
...configuredPostAssertionConsumerServiceUrls
|
|
2820
2878
|
];
|
|
2821
2879
|
}
|
|
2822
2880
|
async function getSAMLResponseBindingContent(sp, samlContent) {
|
|
@@ -2827,20 +2885,16 @@ async function getSAMLResponseBindingContent(sp, samlContent) {
|
|
|
2827
2885
|
/**
|
|
2828
2886
|
* Extracts the Assertion ID from a SAML response XML.
|
|
2829
2887
|
* Used for replay protection per SAML 2.0 Core section 2.3.3.
|
|
2888
|
+
*
|
|
2889
|
+
* Reads the ID through the DOM so character references are decoded the same
|
|
2890
|
+
* way the signature check decodes them: re-spelling the ID keeps the
|
|
2891
|
+
* signature valid, so it must not change the replay key.
|
|
2830
2892
|
*/
|
|
2831
2893
|
function extractAssertionId(samlContent) {
|
|
2832
2894
|
try {
|
|
2833
|
-
const
|
|
2834
|
-
|
|
2835
|
-
|
|
2836
|
-
removeNSPrefix: true
|
|
2837
|
-
}).parse(samlContent);
|
|
2838
|
-
const response = parsed.Response || parsed["samlp:Response"];
|
|
2839
|
-
if (!response) return null;
|
|
2840
|
-
const rawAssertion = response.Assertion || response["saml:Assertion"];
|
|
2841
|
-
const assertion = Array.isArray(rawAssertion) ? rawAssertion[0] : rawAssertion;
|
|
2842
|
-
if (!assertion) return null;
|
|
2843
|
-
return assertion["@_ID"] || null;
|
|
2895
|
+
const response = new DOMParser().parseFromString(samlContent, "text/xml").documentElement;
|
|
2896
|
+
if (response?.localName !== "Response") return null;
|
|
2897
|
+
return Array.from(response.childNodes).find((node) => node.nodeType === 1 && node.localName === "Assertion")?.getAttribute("ID") || null;
|
|
2844
2898
|
} catch {
|
|
2845
2899
|
return null;
|
|
2846
2900
|
}
|
|
@@ -2877,6 +2931,12 @@ async function processSAMLResponse(ctx, params, options) {
|
|
|
2877
2931
|
const samlRedirectUrl = getSafeRedirectUrl(redirectCandidates, currentCallbackPath, appOrigin, (url, settings) => ctx.context.isTrustedOrigin(url, settings));
|
|
2878
2932
|
const samlErrorRedirectUrl = getSafeRedirectUrl([relayState?.errorURL, samlRedirectUrl], currentCallbackPath, appOrigin, (url, settings) => ctx.context.isTrustedOrigin(url, settings));
|
|
2879
2933
|
params.onErrorRedirectResolved?.(samlErrorRedirectUrl);
|
|
2934
|
+
const redirectOnCodedAPIError = (e) => {
|
|
2935
|
+
if (isAPIError(e) && e.body?.code) throw ctx.redirect(buildSAMLRedirectUrl(samlErrorRedirectUrl, {
|
|
2936
|
+
error: e.body.code,
|
|
2937
|
+
...e.body.message ? { error_description: e.body.message } : {}
|
|
2938
|
+
}));
|
|
2939
|
+
};
|
|
2880
2940
|
const stateProviderReference = parseSSOProviderReference(relayState?.serverContext?.[SSO_PROVIDER_STATE_KEY]);
|
|
2881
2941
|
validateSingleAssertion(SAMLResponse);
|
|
2882
2942
|
let parsedResponse;
|
|
@@ -2899,17 +2959,17 @@ async function processSAMLResponse(ctx, params, options) {
|
|
|
2899
2959
|
logger: ctx.context.logger
|
|
2900
2960
|
});
|
|
2901
2961
|
const expectedAudiences = [sp.entityMeta.getEntityID(), parsedSamlConfig.audience];
|
|
2902
|
-
const
|
|
2903
|
-
const expectedRecipients = getExpectedSAMLRecipients(parsedSamlConfig, ctx.context.baseURL, providerId, currentCallbackPath, assertionConsumerServiceUrl);
|
|
2962
|
+
const expectedRecipients = getExpectedSAMLRecipients(parsedSamlConfig, ctx.context.baseURL, providerId, currentCallbackPath);
|
|
2904
2963
|
const serviceProviderPolicy = deriveSAMLServiceProviderPolicy(parsedSamlConfig);
|
|
2905
2964
|
let samlBindingContent;
|
|
2965
|
+
let boundAssertion;
|
|
2906
2966
|
try {
|
|
2907
2967
|
samlBindingContent = await getSAMLResponseBindingContent(sp, samlContent);
|
|
2908
2968
|
if (serviceProviderPolicy.wantAssertionsSigned) verifySAMLAssertionSignature(samlBindingContent, {
|
|
2909
2969
|
metadata: idp.entityMeta,
|
|
2910
2970
|
signatureAlgorithm: idp.entitySetting.requestSignatureAlgorithm
|
|
2911
2971
|
});
|
|
2912
|
-
validateSAMLResponseBinding(samlBindingContent, {
|
|
2972
|
+
boundAssertion = validateSAMLResponseBinding(samlBindingContent, {
|
|
2913
2973
|
expectedAudiences,
|
|
2914
2974
|
expectedRecipients
|
|
2915
2975
|
});
|
|
@@ -2995,6 +3055,7 @@ async function processSAMLResponse(ctx, params, options) {
|
|
|
2995
3055
|
for (const [name, value] of Object.entries(attributes)) if (typeof value === "string") providerAttributes[name] = value;
|
|
2996
3056
|
else if (Array.isArray(value) && value.every((entry) => typeof entry === "string")) providerAttributes[name] = value;
|
|
2997
3057
|
const mapping = parsedSamlConfig.mapping ?? {};
|
|
3058
|
+
const { nameIDFormat } = boundAssertion;
|
|
2998
3059
|
const attr = (key) => {
|
|
2999
3060
|
const value = attributes[key];
|
|
3000
3061
|
return Array.isArray(value) ? value[0] : value;
|
|
@@ -3002,34 +3063,63 @@ async function processSAMLResponse(ctx, params, options) {
|
|
|
3002
3063
|
const userInfo = {
|
|
3003
3064
|
...Object.fromEntries(Object.entries(mapping.extraFields || {}).map(([key, value]) => [key, attributes[value]])),
|
|
3004
3065
|
id: extract.nameID,
|
|
3005
|
-
email: (attr
|
|
3006
|
-
|
|
3007
|
-
|
|
3066
|
+
email: readSAMLEmail(attr, {
|
|
3067
|
+
mappedAttribute: mapping.email,
|
|
3068
|
+
nameID: extract.nameID,
|
|
3069
|
+
nameIDFormat
|
|
3070
|
+
}),
|
|
3071
|
+
name: [attr(mapping.firstName || "givenName"), attr(mapping.lastName || "surname")].filter(Boolean).join(" ") || attr(mapping.name || "displayName") || void 0,
|
|
3072
|
+
emailVerified: options?.trustEmailVerified && mapping.emailVerified ? parseStringBoolean(attr(mapping.emailVerified)) ?? false : false
|
|
3008
3073
|
};
|
|
3009
|
-
|
|
3010
|
-
|
|
3074
|
+
const providerUserAttributes = Object.fromEntries(Object.entries(userInfo).filter(([key]) => key !== "id"));
|
|
3075
|
+
const missingUserInfo = (hasNameId, providerSentEmail = false) => {
|
|
3076
|
+
ctx.context.logger.error(providerSentEmail ? "mapProfileToUser refused the email the SAML provider sent" : hasNameId ? `SAML sign-in has no email. Send an email attribute, or a NameID with Format ${SAML_EMAIL_NAME_ID_FORMAT}, from the IdP; set samlConfig.mapping.email; or return an email from mapProfileToUser.` : "SAML response has no NameID", {
|
|
3011
3077
|
providerId,
|
|
3012
3078
|
attributeNames: Object.keys(attributes),
|
|
3013
|
-
|
|
3014
|
-
|
|
3079
|
+
nameIDFormat
|
|
3080
|
+
});
|
|
3081
|
+
return new APIError("BAD_REQUEST", {
|
|
3082
|
+
code: "MISSING_USER_INFO",
|
|
3083
|
+
message: "Unable to extract user ID or email from SAML response. Map an email attribute, or create a placeholder with `mapProfileToUser`."
|
|
3015
3084
|
});
|
|
3016
|
-
throw new APIError("BAD_REQUEST", { message: "Unable to extract user ID or email from SAML response" });
|
|
3017
|
-
}
|
|
3018
|
-
const providerUserAttributes = Object.fromEntries(Object.entries(userInfo).filter(([key]) => key !== "id"));
|
|
3019
|
-
const providerUser = {
|
|
3020
|
-
...providerUserAttributes,
|
|
3021
|
-
email: userInfo.email,
|
|
3022
|
-
name: userInfo.name || userInfo.email,
|
|
3023
|
-
image: typeof providerUserAttributes.image === "string" ? providerUserAttributes.image : void 0,
|
|
3024
|
-
emailVerified: userInfo.emailVerified
|
|
3025
3085
|
};
|
|
3086
|
+
if (!userInfo.id) throw missingUserInfo(false);
|
|
3026
3087
|
const accountKey = {
|
|
3027
3088
|
issuer,
|
|
3028
3089
|
accountId: userInfo.id
|
|
3029
3090
|
};
|
|
3030
|
-
const
|
|
3091
|
+
const mappedUser = await mapSSOProfile(options, {
|
|
3092
|
+
protocol: "saml",
|
|
3093
|
+
providerId,
|
|
3094
|
+
accountKey,
|
|
3095
|
+
providerUser: {
|
|
3096
|
+
email: userInfo.email,
|
|
3097
|
+
emailVerified: userInfo.emailVerified,
|
|
3098
|
+
name: userInfo.name,
|
|
3099
|
+
image: typeof providerUserAttributes.image === "string" ? providerUserAttributes.image : void 0
|
|
3100
|
+
},
|
|
3101
|
+
providerAttributes,
|
|
3102
|
+
nameID: userInfo.id,
|
|
3103
|
+
nameIDFormat
|
|
3104
|
+
}, ctx.context.logger).catch((e) => {
|
|
3105
|
+
redirectOnCodedAPIError(e);
|
|
3106
|
+
throw e;
|
|
3107
|
+
});
|
|
3108
|
+
const email = mappedUser.email?.toLowerCase();
|
|
3109
|
+
if (!email) throw missingUserInfo(true, Boolean(userInfo.email));
|
|
3110
|
+
const providerUser = {
|
|
3111
|
+
...providerUserAttributes,
|
|
3112
|
+
email,
|
|
3113
|
+
name: mappedUser.name || email,
|
|
3114
|
+
image: mappedUser.image,
|
|
3115
|
+
emailVerified: mappedUser.emailVerified
|
|
3116
|
+
};
|
|
3117
|
+
const mappedUserInfo = {
|
|
3118
|
+
...providerUser,
|
|
3119
|
+
id: accountKey.accountId
|
|
3120
|
+
};
|
|
3121
|
+
const isTrustedProvider = "domainVerified" in provider && !!provider.domainVerified && validateEmailDomain(email, provider.domain);
|
|
3031
3122
|
const callbackUrl = redirectCandidates.some(Boolean) ? samlRedirectUrl : ctx.context.baseURL;
|
|
3032
|
-
const errorUrl = samlErrorRedirectUrl;
|
|
3033
3123
|
let result;
|
|
3034
3124
|
try {
|
|
3035
3125
|
if (options?.resolveUser) {
|
|
@@ -3057,10 +3147,7 @@ async function processSAMLResponse(ctx, params, options) {
|
|
|
3057
3147
|
...resolution.message === void 0 ? {} : { message: resolution.message }
|
|
3058
3148
|
});
|
|
3059
3149
|
const authentication = await handleOAuthUserInfo(ctx, {
|
|
3060
|
-
userInfo:
|
|
3061
|
-
...providerUser,
|
|
3062
|
-
id: userInfo.id
|
|
3063
|
-
},
|
|
3150
|
+
userInfo: mappedUserInfo,
|
|
3064
3151
|
account: {
|
|
3065
3152
|
providerId,
|
|
3066
3153
|
accountId: accountKey.accountId,
|
|
@@ -3092,17 +3179,16 @@ async function processSAMLResponse(ctx, params, options) {
|
|
|
3092
3179
|
} catch (e) {
|
|
3093
3180
|
const failedAuthentication = getFailedSSOAuthenticationResult(e);
|
|
3094
3181
|
if (failedAuthentication) result = failedAuthentication;
|
|
3095
|
-
else
|
|
3096
|
-
|
|
3097
|
-
|
|
3098
|
-
}
|
|
3099
|
-
else throw e;
|
|
3182
|
+
else {
|
|
3183
|
+
redirectOnCodedAPIError(e);
|
|
3184
|
+
throw e;
|
|
3185
|
+
}
|
|
3100
3186
|
}
|
|
3101
3187
|
if (result.error) throw ctx.redirect(buildSAMLRedirectUrl(callbackUrl, { error: result.error.split(" ").join("_") }));
|
|
3102
3188
|
const { session, user } = result.data;
|
|
3103
3189
|
if (options?.provisionUser && (result.isRegister || options.provisionUserOnEveryLogin)) await options.provisionUser({
|
|
3104
3190
|
user,
|
|
3105
|
-
userInfo,
|
|
3191
|
+
userInfo: mappedUserInfo,
|
|
3106
3192
|
provider
|
|
3107
3193
|
});
|
|
3108
3194
|
await assignOrganizationFromProvider(ctx, {
|
|
@@ -3110,9 +3196,9 @@ async function processSAMLResponse(ctx, params, options) {
|
|
|
3110
3196
|
profile: {
|
|
3111
3197
|
providerType: "saml",
|
|
3112
3198
|
providerId,
|
|
3113
|
-
accountId:
|
|
3114
|
-
email
|
|
3115
|
-
emailVerified:
|
|
3199
|
+
accountId: accountKey.accountId,
|
|
3200
|
+
email,
|
|
3201
|
+
emailVerified: providerUser.emailVerified,
|
|
3116
3202
|
rawAttributes: attributes
|
|
3117
3203
|
},
|
|
3118
3204
|
provider,
|
|
@@ -3145,8 +3231,7 @@ async function processSAMLResponse(ctx, params, options) {
|
|
|
3145
3231
|
}
|
|
3146
3232
|
return samlRedirectUrl;
|
|
3147
3233
|
}
|
|
3148
|
-
|
|
3149
|
-
//#region src/routes/sso.ts
|
|
3234
|
+
// src/routes/sso.ts
|
|
3150
3235
|
const BUILT_IN_ACCOUNT_PROVIDER_IDS = [
|
|
3151
3236
|
"credential",
|
|
3152
3237
|
"email-otp",
|
|
@@ -3944,7 +4029,7 @@ async function handleOIDCCallback(ctx, options, providerId, stateData, parsedPro
|
|
|
3944
4029
|
...Object.fromEntries(Object.entries(mapping.extraFields || {}).map(([key, value]) => [key, rawUserInfo[value]])),
|
|
3945
4030
|
id: readStringClaim(rawUserInfo, "sub"),
|
|
3946
4031
|
email: readEmailClaim(rawUserInfo),
|
|
3947
|
-
emailVerified: options?.trustEmailVerified ?
|
|
4032
|
+
emailVerified: options?.trustEmailVerified ? parseStringBoolean(rawUserInfo[mapping.emailVerified || "email_verified"]) ?? false : false,
|
|
3948
4033
|
name: readStringClaim(rawUserInfo, mapping.name || "name"),
|
|
3949
4034
|
image: readStringClaim(rawUserInfo, mapping.image || "picture")
|
|
3950
4035
|
};
|
|
@@ -3955,24 +4040,47 @@ async function handleOIDCCallback(ctx, options, providerId, stateData, parsedPro
|
|
|
3955
4040
|
...Object.fromEntries(Object.entries(mapping.extraFields || {}).map(([key, value]) => [key, idToken[value]])),
|
|
3956
4041
|
id: idToken.sub,
|
|
3957
4042
|
email: readEmailClaim(idToken),
|
|
3958
|
-
emailVerified: options?.trustEmailVerified ?
|
|
4043
|
+
emailVerified: options?.trustEmailVerified ? parseStringBoolean(idToken[mapping.emailVerified || "email_verified"]) ?? false : false,
|
|
3959
4044
|
name: readStringClaim(idToken, mapping.name || "name"),
|
|
3960
4045
|
image: readStringClaim(idToken, mapping.image || "picture")
|
|
3961
4046
|
};
|
|
3962
4047
|
} else return redirectOIDCError("invalid_provider", "user_info_endpoint_not_found");
|
|
3963
|
-
if (!userInfo.
|
|
3964
|
-
const
|
|
3965
|
-
|
|
4048
|
+
if (!userInfo.id) return redirectOIDCError("invalid_provider", "missing_user_info");
|
|
4049
|
+
const accountKey = {
|
|
4050
|
+
issuer: verifiedIdToken && readStringClaim(verifiedIdToken.payload, "iss") || provider.issuer,
|
|
4051
|
+
accountId: userInfo.id
|
|
4052
|
+
};
|
|
4053
|
+
const mappedUser = await mapSSOProfile(options, {
|
|
4054
|
+
protocol: "oidc",
|
|
4055
|
+
providerId,
|
|
4056
|
+
accountKey,
|
|
4057
|
+
providerUser: {
|
|
4058
|
+
email: userInfo.email,
|
|
4059
|
+
emailVerified: userInfo.emailVerified === true,
|
|
4060
|
+
name: userInfo.name,
|
|
4061
|
+
image: userInfo.image
|
|
4062
|
+
},
|
|
4063
|
+
providerClaims: rawProfile ?? {},
|
|
4064
|
+
verifiedIdTokenClaims: verifiedIdToken?.payload ?? {}
|
|
4065
|
+
}, ctx.context.logger).catch((e) => {
|
|
4066
|
+
if (isAPIError(e) && e.body?.code) redirectOIDCError(e.body.code, e.body.message);
|
|
4067
|
+
throw e;
|
|
4068
|
+
});
|
|
4069
|
+
if (!mappedUser.email) {
|
|
4070
|
+
ctx.context.logger.error("OIDC provider returned no email. Map an email claim, or create a placeholder with `mapProfileToUser`.", { providerId });
|
|
4071
|
+
return redirectOIDCError("invalid_provider", "missing_user_info");
|
|
4072
|
+
}
|
|
4073
|
+
const userInfoEmail = mappedUser.email;
|
|
3966
4074
|
const providerUser = {
|
|
3967
4075
|
...Object.fromEntries(Object.entries(userInfo).filter(([key]) => key !== "id")),
|
|
3968
4076
|
email: userInfoEmail,
|
|
3969
|
-
name:
|
|
3970
|
-
image:
|
|
3971
|
-
emailVerified:
|
|
4077
|
+
name: mappedUser.name ?? "",
|
|
4078
|
+
image: mappedUser.image,
|
|
4079
|
+
emailVerified: mappedUser.emailVerified
|
|
3972
4080
|
};
|
|
3973
|
-
const
|
|
3974
|
-
|
|
3975
|
-
|
|
4081
|
+
const mappedUserInfo = {
|
|
4082
|
+
...providerUser,
|
|
4083
|
+
id: accountKey.accountId
|
|
3976
4084
|
};
|
|
3977
4085
|
const isTrustedProvider = "domainVerified" in provider && provider.domainVerified === true && validateEmailDomain(userInfoEmail, provider.domain);
|
|
3978
4086
|
let linked;
|
|
@@ -4003,19 +4111,12 @@ async function handleOIDCCallback(ctx, options, providerId, stateData, parsedPro
|
|
|
4003
4111
|
...resolution.message === void 0 ? {} : { message: resolution.message }
|
|
4004
4112
|
});
|
|
4005
4113
|
const authentication = await handleOAuthUserInfo(ctx, {
|
|
4006
|
-
userInfo:
|
|
4007
|
-
...providerUser,
|
|
4008
|
-
email: providerUser.email,
|
|
4009
|
-
name: providerUser.name,
|
|
4010
|
-
id: userInfoId,
|
|
4011
|
-
image: providerUser.image,
|
|
4012
|
-
emailVerified: providerUser.emailVerified
|
|
4013
|
-
},
|
|
4114
|
+
userInfo: mappedUserInfo,
|
|
4014
4115
|
account: {
|
|
4015
4116
|
idToken: tokenResponse.idToken,
|
|
4016
4117
|
accessToken: tokenResponse.accessToken,
|
|
4017
4118
|
refreshToken: tokenResponse.refreshToken,
|
|
4018
|
-
accountId:
|
|
4119
|
+
accountId: accountKey.accountId,
|
|
4019
4120
|
providerId: provider.providerId,
|
|
4020
4121
|
accessTokenExpiresAt: tokenResponse.accessTokenExpiresAt,
|
|
4021
4122
|
refreshTokenExpiresAt: tokenResponse.refreshTokenExpiresAt,
|
|
@@ -4054,7 +4155,7 @@ async function handleOIDCCallback(ctx, options, providerId, stateData, parsedPro
|
|
|
4054
4155
|
const { session, user } = linked.data;
|
|
4055
4156
|
if (options?.provisionUser && (linked.isRegister || options.provisionUserOnEveryLogin)) await options.provisionUser({
|
|
4056
4157
|
user,
|
|
4057
|
-
userInfo,
|
|
4158
|
+
userInfo: mappedUserInfo,
|
|
4058
4159
|
token: tokenResponse,
|
|
4059
4160
|
provider
|
|
4060
4161
|
});
|
|
@@ -4063,10 +4164,10 @@ async function handleOIDCCallback(ctx, options, providerId, stateData, parsedPro
|
|
|
4063
4164
|
profile: {
|
|
4064
4165
|
providerType: "oidc",
|
|
4065
4166
|
providerId: provider.providerId,
|
|
4066
|
-
accountId:
|
|
4167
|
+
accountId: accountKey.accountId,
|
|
4067
4168
|
email: userInfoEmail,
|
|
4068
|
-
emailVerified:
|
|
4069
|
-
rawAttributes:
|
|
4169
|
+
emailVerified: providerUser.emailVerified,
|
|
4170
|
+
rawAttributes: mappedUserInfo
|
|
4070
4171
|
},
|
|
4071
4172
|
provider,
|
|
4072
4173
|
token: tokenResponse,
|
|
@@ -4428,7 +4529,7 @@ const initiateSLO = (options) => {
|
|
|
4428
4529
|
const session = ctx.context.session;
|
|
4429
4530
|
const sessionLookupKey = `${SAML_SESSION_BY_ID_PREFIX}${session.session.id}`;
|
|
4430
4531
|
const sessionLookup = await ctx.context.internalAdapter.findVerificationValue(sessionLookupKey);
|
|
4431
|
-
let nameID
|
|
4532
|
+
let nameID;
|
|
4432
4533
|
let sessionIndex;
|
|
4433
4534
|
let samlSessionKey;
|
|
4434
4535
|
if (sessionLookup) {
|
|
@@ -4437,11 +4538,15 @@ const initiateSLO = (options) => {
|
|
|
4437
4538
|
if (stored) {
|
|
4438
4539
|
const data = safeJsonParse(stored.value);
|
|
4439
4540
|
if (data) {
|
|
4440
|
-
nameID = data.nameID
|
|
4541
|
+
nameID = data.nameID;
|
|
4441
4542
|
sessionIndex = data.sessionIndex;
|
|
4442
4543
|
}
|
|
4443
4544
|
}
|
|
4444
4545
|
}
|
|
4546
|
+
if (!nameID) {
|
|
4547
|
+
const [account] = (await ctx.context.internalAdapter.findAccounts(session.user.id)).filter((account) => account.providerId === providerId).sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
|
|
4548
|
+
nameID = account?.accountId ?? session.user.email;
|
|
4549
|
+
}
|
|
4445
4550
|
const logoutRequest = sp.createLogoutRequest(idp, "redirect", {
|
|
4446
4551
|
logoutNameID: nameID,
|
|
4447
4552
|
sessionIndex,
|
|
@@ -4460,8 +4565,7 @@ const initiateSLO = (options) => {
|
|
|
4460
4565
|
throw ctx.redirect(logoutRequest.context);
|
|
4461
4566
|
});
|
|
4462
4567
|
};
|
|
4463
|
-
|
|
4464
|
-
//#region src/index.ts
|
|
4568
|
+
// src/index.ts
|
|
4465
4569
|
saml.setSchemaValidator({ async validate(xml) {
|
|
4466
4570
|
if (XMLValidator.validate(xml, { allowBooleanAttributes: true }) === true) return "SUCCESS_VALIDATE_XML";
|
|
4467
4571
|
throw "ERR_INVALID_XML";
|
|
@@ -4506,6 +4610,53 @@ function assertNoAdditionalFieldCollisions(options) {
|
|
|
4506
4610
|
if (builtInFieldNames.has(fieldName)) throw new Error(`ssoProvider additional field "${key}" maps to built-in field "${fieldName}"`);
|
|
4507
4611
|
}
|
|
4508
4612
|
}
|
|
4613
|
+
const SAML_SESSION_RECORD_PAGE_SIZE = 100;
|
|
4614
|
+
async function findSAMLSessionRecord(ctx, identifier, isOwnRecord) {
|
|
4615
|
+
for (let offset = 0;; offset += SAML_SESSION_RECORD_PAGE_SIZE) {
|
|
4616
|
+
const page = await ctx.adapter.findMany({
|
|
4617
|
+
model: "verification",
|
|
4618
|
+
where: [{
|
|
4619
|
+
field: "identifier",
|
|
4620
|
+
value: identifier
|
|
4621
|
+
}],
|
|
4622
|
+
sortBy: {
|
|
4623
|
+
field: "createdAt",
|
|
4624
|
+
direction: "desc"
|
|
4625
|
+
},
|
|
4626
|
+
limit: SAML_SESSION_RECORD_PAGE_SIZE,
|
|
4627
|
+
offset
|
|
4628
|
+
});
|
|
4629
|
+
const record = page.find(isOwnRecord);
|
|
4630
|
+
if (record || page.length < SAML_SESSION_RECORD_PAGE_SIZE) return record;
|
|
4631
|
+
}
|
|
4632
|
+
}
|
|
4633
|
+
/**
|
|
4634
|
+
* SLO records are written with the session's expiry at sign-in. Move it when a
|
|
4635
|
+
* refresh extends the session, so expired-row cleanup doesn't drop them early.
|
|
4636
|
+
*/
|
|
4637
|
+
async function extendSAMLSessionRecords(ctx, session) {
|
|
4638
|
+
const lookupKey = `${SAML_SESSION_BY_ID_PREFIX}${session.id}`;
|
|
4639
|
+
const lookup = await ctx.internalAdapter.findVerificationValue(lookupKey);
|
|
4640
|
+
const expiresAt = new Date(session.expiresAt);
|
|
4641
|
+
if (!lookup || new Date(lookup.expiresAt) >= expiresAt) return;
|
|
4642
|
+
const isOwnRecord = (record) => safeJsonParse(record.value)?.sessionId === session.id;
|
|
4643
|
+
const latest = await ctx.internalAdapter.findVerificationValue(lookup.value);
|
|
4644
|
+
const secondaryStorageOnly = ctx.options.secondaryStorage && !ctx.options.verification?.storeInDatabase;
|
|
4645
|
+
if (latest && secondaryStorageOnly) {
|
|
4646
|
+
if (isOwnRecord(latest)) await ctx.internalAdapter.updateVerificationByIdentifier(lookup.value, { expiresAt });
|
|
4647
|
+
} else if (latest) {
|
|
4648
|
+
const record = await findSAMLSessionRecord(ctx, latest.identifier, isOwnRecord);
|
|
4649
|
+
if (record) await ctx.adapter.update({
|
|
4650
|
+
model: "verification",
|
|
4651
|
+
where: [{
|
|
4652
|
+
field: "id",
|
|
4653
|
+
value: record.id
|
|
4654
|
+
}],
|
|
4655
|
+
update: { expiresAt }
|
|
4656
|
+
});
|
|
4657
|
+
}
|
|
4658
|
+
await ctx.internalAdapter.updateVerificationByIdentifier(lookupKey, { expiresAt });
|
|
4659
|
+
}
|
|
4509
4660
|
function sso(options) {
|
|
4510
4661
|
assertNoAdditionalFieldCollisions(options);
|
|
4511
4662
|
const optionsWithStore = options;
|
|
@@ -4538,8 +4689,13 @@ function sso(options) {
|
|
|
4538
4689
|
version: PACKAGE_VERSION,
|
|
4539
4690
|
init(ctx) {
|
|
4540
4691
|
const existing = ctx.skipOriginCheck;
|
|
4541
|
-
|
|
4542
|
-
|
|
4692
|
+
return {
|
|
4693
|
+
...existing !== true && { context: { skipOriginCheck: [...Array.isArray(existing) ? existing : [], ...SAML_SKIP_ORIGIN_CHECK_PATHS] } },
|
|
4694
|
+
options: { databaseHooks: { session: { update: { async after(session) {
|
|
4695
|
+
if (!options?.saml?.enableSingleLogout) return;
|
|
4696
|
+
await extendSAMLSessionRecords(ctx, session).catch((error) => ctx.logger.warn("Failed to extend SAML session records", { error }));
|
|
4697
|
+
} } } } }
|
|
4698
|
+
};
|
|
4543
4699
|
},
|
|
4544
4700
|
endpoints,
|
|
4545
4701
|
hooks: {
|
|
@@ -4629,5 +4785,4 @@ function sso(options) {
|
|
|
4629
4785
|
options
|
|
4630
4786
|
};
|
|
4631
4787
|
}
|
|
4632
|
-
//#endregion
|
|
4633
4788
|
export { DEFAULT_CLOCK_SKEW_MS, DEFAULT_MAX_SAML_METADATA_SIZE, DEFAULT_MAX_SAML_RESPONSE_SIZE, DataEncryptionAlgorithm, DigestAlgorithm, DiscoveryError, KeyEncryptionAlgorithm, REQUIRED_DISCOVERY_FIELDS, SignatureAlgorithm, computeDiscoveryUrl, deriveSAMLIdentityProviderEntityID, deriveSAMLServiceProviderPolicy, discoverOIDCConfig, fetchDiscoveryDocument, needsRuntimeDiscovery, normalizeDiscoveryUrls, normalizeUrl, selectTokenEndpointAuthMethod, sso, validateDiscoveryDocument, validateDiscoveryUrl, validateSAMLTimestamp };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@better-auth/sso",
|
|
3
|
-
"version": "1.8.0-beta.
|
|
3
|
+
"version": "1.8.0-beta.9",
|
|
4
4
|
"description": "SSO plugin for Better Auth",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -64,24 +64,24 @@
|
|
|
64
64
|
"zod": "^4.5.4"
|
|
65
65
|
},
|
|
66
66
|
"devDependencies": {
|
|
67
|
+
"@better-auth/core": "1.8.0-beta.9",
|
|
68
|
+
"@better-auth/kysely-adapter": "1.8.0-beta.9",
|
|
67
69
|
"@types/body-parser": "^1.19.6",
|
|
68
70
|
"@types/express": "^5.0.6",
|
|
71
|
+
"better-auth": "1.8.0-beta.9",
|
|
69
72
|
"better-call": "1.4.5",
|
|
70
73
|
"body-parser": "^2.3.0",
|
|
71
74
|
"express": "^5.2.1",
|
|
72
75
|
"kysely": "^0.28.17 || ^0.29.0",
|
|
73
76
|
"oauth2-mock-server": "^9.0.0",
|
|
74
|
-
"tsdown": "0.23.1"
|
|
75
|
-
"@better-auth/kysely-adapter": "1.8.0-beta.7",
|
|
76
|
-
"better-auth": "1.8.0-beta.7",
|
|
77
|
-
"@better-auth/core": "1.8.0-beta.7"
|
|
77
|
+
"tsdown": "0.23.1"
|
|
78
78
|
},
|
|
79
79
|
"peerDependencies": {
|
|
80
|
+
"@better-auth/core": "^1.8.0-beta.9",
|
|
80
81
|
"@better-auth/utils": "^0.4.2 || ^0.5.0",
|
|
81
82
|
"@better-fetch/fetch": "1.3.3",
|
|
82
|
-
"better-
|
|
83
|
-
"
|
|
84
|
-
"better-auth": "^1.8.0-beta.7"
|
|
83
|
+
"better-auth": "^1.8.0-beta.9",
|
|
84
|
+
"better-call": "1.4.5"
|
|
85
85
|
},
|
|
86
86
|
"scripts": {
|
|
87
87
|
"build": "tsdown",
|