From 7484c5dd3e6da0e597bdf64bf39ecc0c4d293fa6 Mon Sep 17 00:00:00 2001 From: Gustavo Valverde Date: Wed, 14 Oct 2026 10:50:46 +0100 Subject: [PATCH] feat(sso)!: add mapProfileToUser and use only email-format NameIDs as emails SSO sign-in failed whenever the provider sent no email, with no hook to supply one. mapProfileToUser runs on every OIDC and SAML sign-in before the user is looked up and can return the email, name, image, and emailVerified. A SAML NameID now becomes the email only when its Format is emailAddress, matching Keycloak and Ory Polis. Persistent and transient NameIDs are opaque and were being stored as user emails. --- .changeset/sso-email-less-profiles.md | 19 +++ docs/content/docs/plugins/sso.mdx | 31 +++- packages/sso/src/constants.ts | 9 ++ packages/sso/src/index.ts | 8 + packages/sso/src/oidc.test.ts | 104 +++++++++++++ packages/sso/src/routes/saml-pipeline.ts | 84 +++++++---- packages/sso/src/routes/sso.ts | 46 ++++-- packages/sso/src/saml.test.ts | 173 +++++++++++++++++++++- packages/sso/src/saml/index.ts | 1 + packages/sso/src/saml/response-binding.ts | 8 + packages/sso/src/types.ts | 71 +++++++++ packages/sso/src/user-resolution.ts | 22 +++ 12 files changed, 526 insertions(+), 50 deletions(-) create mode 100644 .changeset/sso-email-less-profiles.md diff --git a/.changeset/sso-email-less-profiles.md b/.changeset/sso-email-less-profiles.md new file mode 100644 index 0000000000..d8046d4020 --- /dev/null +++ b/.changeset/sso-email-less-profiles.md @@ -0,0 +1,19 @@ +--- +"@better-auth/sso": minor +--- + +SSO providers that send no email can now sign users in through the new `mapProfileToUser` option. It runs on every OIDC and SAML sign-in before the user is looked up, receives the provider's account key, profile, and raw claims or attributes, and can return the `email`, `name`, `image`, and `emailVerified` to use, such as a placeholder on the reserved `.invalid` domain. Previously these sign-ins failed with `missing_user_info`, and there was no way to supply an email. + +**Breaking:** a SAML `NameID` now becomes the user's email only when its `Format` is `urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress`. Previously, a response without an email attribute used any NameID as the email, so an opaque persistent or transient NameID was stored as the user's email. Those sign-ins are now rejected, including for users created that way before. Providers that send an email attribute, or an `emailAddress`-format NameID, are unaffected. + +To keep the previous behavior for affected providers, return the NameID from `mapProfileToUser`: + +```ts +sso({ + mapProfileToUser: (input) => ({ + email: + input.providerUser.email ?? + (input.protocol === "saml" ? input.nameID : undefined), + }), +}); +``` diff --git a/docs/content/docs/plugins/sso.mdx b/docs/content/docs/plugins/sso.mdx index 9fb725f166..fee800ed8a 100644 --- a/docs/content/docs/plugins/sso.mdx +++ b/docs/content/docs/plugins/sso.mdx @@ -416,6 +416,35 @@ The callback receives the active transaction adapter as `context.database`. Keep Database writes made through the transaction adapter during resolution, account linking, profile updates, and database session creation commit or roll back together. Network calls and other external side effects performed by the resolver or database hooks cannot be rolled back. Account and session cookies are published only after commit. Secondary-storage session mirroring and other deferred hooks run after commit; failures are logged without undoing the committed database session or suppressing its cookies. +### Providers Without an Email + +Better Auth needs an email to find, link, and create the user. It reads the email from the provider's `mapping`, which defaults to the `email` claim or attribute. For SAML, when that attribute is missing, the `NameID` becomes the email only when its `Format` is `urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress`. Persistent, transient, and unspecified NameIDs are opaque identifiers and never become the email. When no email is found, OIDC sign-in redirects with `missing_user_info` and SAML sign-in redirects with an error. + +To sign in users from a provider that sends no email, return one from `mapProfileToUser`. It runs on every OIDC and SAML sign-in, after `mapping` is applied and before the user is looked up: + +```ts title="auth.ts" +import { betterAuth } from "better-auth"; +import { sso } from "@better-auth/sso"; + +export const auth = betterAuth({ + plugins: [ + sso({ + mapProfileToUser: ({ providerId, accountKey, providerUser }) => ({ + email: + providerUser.email ?? + `${accountKey.accountId}@${providerId}.placeholder.invalid`, + }), + }), + ], +}); +``` + +The callback receives `protocol`, `providerId`, `accountKey`, and `providerUser`, plus `providerClaims` for OIDC or `providerAttributes`, `nameID`, and `nameIDFormat` for SAML. It can return `email`, `name`, `image`, and `emailVerified`. Return `email: null` to discard the email the provider sent. The account identity always stays the verified `sub` claim or `NameID`. If that value can contain characters that are not valid in an email address, encode it before building a placeholder. + + + The returned email is used to match existing users and, for a provider with a verified domain, to decide whether the account links automatically. Return only addresses the provider vouches for, or a placeholder on the reserved `.invalid` domain. Placeholder emails cannot receive mail; see [Handling Providers Without Email](/docs/concepts/oauth#handling-providers-without-email). + + ### Register a SAML Provider To register a SAML provider, use the `registerSSOProvider` endpoint with SAML configuration details. The provider will act as a Service Provider (SP) and integrate with your Identity Provider (IdP). @@ -1109,7 +1138,7 @@ Configure how SAML attributes map to user fields: ```ts mapping: { - email: "email", // Default: "email" or "nameID" + email: "email", // Default: "email" name: "displayName", // Default: "displayName" firstName: "givenName", // Default: "givenName" lastName: "surname", // Default: "surname" diff --git a/packages/sso/src/constants.ts b/packages/sso/src/constants.ts index 8651e66c5c..fe52f0bccc 100644 --- a/packages/sso/src/constants.ts +++ b/packages/sso/src/constants.ts @@ -77,3 +77,12 @@ export const DEFAULT_MAX_SAML_METADATA_SIZE = 100 * 1024; // ============================================================================ export const SAML_STATUS_SUCCESS = "urn:oasis:names:tc:SAML:2.0:status:Success"; + +/** + * NameID format whose value is an email address. Other formats, such as + * persistent and transient, are opaque identifiers. + * + * @see https://docs.oasis-open.org/security/saml/v2.0/saml-core-2.0-os.pdf (ยง8.3.2) + */ +export const SAML_EMAIL_NAME_ID_FORMAT = + "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress"; diff --git a/packages/sso/src/index.ts b/packages/sso/src/index.ts index caab8c8082..b5f67f95df 100644 --- a/packages/sso/src/index.ts +++ b/packages/sso/src/index.ts @@ -54,14 +54,18 @@ import type { OIDCConfig, SAMLConfig, SAMLIdentityProviderMetadata, + SSOMappedUser, + SSOOIDCProfileMappingInput, SSOOIDCUserResolutionInput, SSOOptions, + SSOProfileMappingInput, SSOProvider, SSOProviderMutationGuardContext, SSOProviderMutationGuardInput, SSOProviderReference, SSOProviderSchema, SSOProviderUserProfile, + SSOSAMLProfileMappingInput, SSOSAMLUserResolutionInput, SSOUserResolution, SSOUserResolutionContext, @@ -73,13 +77,17 @@ export type { OIDCConfig, SAMLConfig, SAMLIdentityProviderMetadata, + SSOMappedUser, + SSOOIDCProfileMappingInput, SSOOIDCUserResolutionInput, SSOOptions, + SSOProfileMappingInput, SSOProvider, SSOProviderMutationGuardContext, SSOProviderMutationGuardInput, SSOProviderReference, SSOProviderUserProfile, + SSOSAMLProfileMappingInput, SSOSAMLUserResolutionInput, SSOUserResolution, SSOUserResolutionContext, diff --git a/packages/sso/src/oidc.test.ts b/packages/sso/src/oidc.test.ts index 0f6e859760..78f556c3f4 100644 --- a/packages/sso/src/oidc.test.ts +++ b/packages/sso/src/oidc.test.ts @@ -9,6 +9,7 @@ import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import { getAuthStateVerificationIdentifier } from "../../better-auth/src/state"; import { sso } from "."; import { ssoClient } from "./client"; +import type { SSOProfileMappingInput } from "./types"; const server = new OAuth2Server(); @@ -2571,3 +2572,106 @@ describe("SSO OIDC IDP-initiated bounce", async () => { expect(res.headers.get("location")).toContain("state_not_found"); }); }); + +/** + * @see https://github.com/better-auth/better-auth/issues/10209 + */ +describe("OIDC providers without an email claim", async () => { + let mappingInput: SSOProfileMappingInput | undefined; + const { auth, client, signInWithTestUser, customFetchImpl, cookieSetter } = + await getTestInstance( + { + trustedOrigins: ["http://localhost:8080"], + plugins: [ + sso({ + mapProfileToUser(input) { + mappingInput = input; + return { + email: + input.providerUser.email ?? + `${input.accountKey.accountId}@${input.providerId}.placeholder.invalid`, + }; + }, + }), + ], + }, + { clientOptions: { plugins: [ssoClient()] } }, + ); + + beforeAll(async () => { + await server.issuer.keys.generate("RS256"); + await server.start(8080, "localhost"); + server.service.on("beforeTokenSigning", (token) => { + token.payload.sub = "subject-without-email"; + token.payload.name = "No Email User"; + // Other suites register listeners that set an email on the shared server. + token.payload.email = undefined; + }); + }); + + afterAll(async () => { + server.service.removeAllListeners("beforeTokenSigning"); + await server.stop(); + }); + + it("creates the user with the email returned by mapProfileToUser", async () => { + const { headers } = await signInWithTestUser(); + await auth.api.registerSSOProvider({ + body: { + providerId: "no-email-oidc", + issuer: server.issuer.url!, + domain: "no-email.example.com", + oidcConfig: { + clientId: "no-email-client", + clientSecret: "test-client-secret", + skipDiscovery: true, + authorizationEndpoint: `${server.issuer.url!}/authorize`, + tokenEndpoint: `${server.issuer.url!}/token`, + jwksEndpoint: `${server.issuer.url!}/jwks`, + pkce: false, + }, + }, + headers, + }); + + const signInHeaders = new Headers(); + const res = await client.signIn.sso({ + providerId: "no-email-oidc", + callbackURL: "/dashboard", + fetchOptions: { throw: true, onSuccess: cookieSetter(signInHeaders) }, + }); + let location: string | null = null; + await betterFetch(res.url, { + method: "GET", + redirect: "manual", + onError(context) { + location = context.response.headers.get("location"); + }, + }); + let callbackURL = ""; + const sessionHeaders = new Headers(); + await betterFetch(location!, { + method: "GET", + customFetchImpl, + headers: signInHeaders, + onError(context) { + callbackURL = context.response.headers.get("location") || ""; + cookieSetter(sessionHeaders)(context); + }, + }); + + expect(callbackURL).toContain("/dashboard"); + expect(mappingInput).toMatchObject({ + protocol: "oidc", + providerId: "no-email-oidc", + accountKey: { accountId: "subject-without-email" }, + providerClaims: { sub: "subject-without-email" }, + }); + expect(mappingInput?.providerUser.email).toBeUndefined(); + const session = await auth.api.getSession({ headers: sessionHeaders }); + expect(session?.user).toMatchObject({ + email: "subject-without-email@no-email-oidc.placeholder.invalid", + emailVerified: false, + }); + }); +}); diff --git a/packages/sso/src/routes/saml-pipeline.ts b/packages/sso/src/routes/saml-pipeline.ts index 63c5ef5ee1..f5bb626b37 100644 --- a/packages/sso/src/routes/saml-pipeline.ts +++ b/packages/sso/src/routes/saml-pipeline.ts @@ -19,6 +19,7 @@ import { SSO_PROVIDER_STATE_KEY, } from "../provider-reference"; import { + getSAMLNameIDFormat, getSAMLPostAssertionConsumerServiceUrls, hasSAMLEncryptedAssertion, SAML_HTTP_POST_BINDING, @@ -46,6 +47,7 @@ import { assertSSOUserResolutionNativeTransactionSupport, assertSSOUserResolutionSessionStorage, getFailedSSOAuthenticationResult, + mapSSOProfile, requireSuccessfulSSOAuthentication, resolveSSOUser, } from "../user-resolution"; @@ -610,6 +612,7 @@ export async function processSAMLResponse( } } const mapping = parsedSamlConfig.mapping ?? {}; + const nameIDFormat = getSAMLNameIDFormat(samlBindingContent); // samlify >= 2.13 types attribute values as `string | string[]` to support // multi-valued attributes. The identity fields below are single-valued. @@ -626,11 +629,11 @@ export async function processSAMLResponse( ]), ), id: extract.nameID, - email: ( + email: attr(mapping.email || "email") || - extract.nameID || - "" - ).toLowerCase(), + (nameIDFormat === constants.SAML_EMAIL_NAME_ID_FORMAT + ? extract.nameID + : undefined), name: [ attr(mapping.firstName || "givenName"), @@ -645,33 +648,52 @@ export async function processSAMLResponse( ? parseProviderEmailVerified(attr(mapping.emailVerified)) : false, }; - if (!userInfo.id || !userInfo.email) { - ctx.context.logger.error("Missing essential user info from SAML response", { - providerId, - attributeNames: Object.keys(attributes), - hasNameId: Boolean(userInfo.id), - hasEmail: Boolean(userInfo.email), - }); - throw new APIError("BAD_REQUEST", { - message: "Unable to extract user ID or email from SAML response", - }); - } const providerUserAttributes = Object.fromEntries( Object.entries(userInfo).filter(([key]) => key !== "id"), ); + const missingUserInfo = (hasNameId: boolean, hasEmail: boolean) => { + ctx.context.logger.error("Missing essential user info from SAML response", { + providerId, + attributeNames: Object.keys(attributes), + hasNameId, + hasEmail, + }); + return new APIError("BAD_REQUEST", { + message: + "Unable to extract user ID or email from SAML response. Map an email attribute, or create a placeholder with `mapProfileToUser`.", + }); + }; + if (!userInfo.id) { + throw missingUserInfo(false, Boolean(userInfo.email)); + } + const accountKey = { issuer, accountId: userInfo.id }; + const mappedUser = await mapSSOProfile(options, { + protocol: "saml", + providerId, + accountKey, + providerUser: { + email: userInfo.email, + emailVerified: userInfo.emailVerified, + name: userInfo.name, + image: + typeof providerUserAttributes.image === "string" + ? providerUserAttributes.image + : undefined, + }, + providerAttributes, + nameID: userInfo.id, + nameIDFormat, + }); + const email = mappedUser.email?.toLowerCase(); + if (!email) { + throw missingUserInfo(true, false); + } const providerUser = { ...providerUserAttributes, - email: userInfo.email as string, - name: (userInfo.name || userInfo.email) as string, - image: - typeof providerUserAttributes.image === "string" - ? providerUserAttributes.image - : undefined, - emailVerified: userInfo.emailVerified, - }; - const accountKey = { - issuer, - accountId: userInfo.id as string, + email, + name: mappedUser.name || email, + image: mappedUser.image, + emailVerified: mappedUser.emailVerified, }; // 16. Session creation @@ -682,7 +704,7 @@ export async function processSAMLResponse( const isTrustedProvider: boolean = "domainVerified" in provider && !!(provider as { domainVerified?: boolean }).domainVerified && - validateEmailDomain(userInfo.email as string, provider.domain); + validateEmailDomain(email, provider.domain); const callbackUrl = redirectCandidates.some(Boolean) ? samlRedirectUrl @@ -744,7 +766,7 @@ export async function processSAMLResponse( const authentication = await handleOAuthUserInfo(ctx, { userInfo: { ...providerUser, - id: userInfo.id as string, + id: accountKey.accountId, }, account: { providerId, @@ -826,9 +848,9 @@ export async function processSAMLResponse( profile: { providerType: "saml", providerId, - accountId: userInfo.id as string, - email: userInfo.email as string, - emailVerified: userInfo.emailVerified, + accountId: accountKey.accountId, + email, + emailVerified: providerUser.emailVerified, rawAttributes: attributes, }, provider, diff --git a/packages/sso/src/routes/sso.ts b/packages/sso/src/routes/sso.ts index 0494876c0f..199d75dce2 100644 --- a/packages/sso/src/routes/sso.ts +++ b/packages/sso/src/routes/sso.ts @@ -76,6 +76,7 @@ import { assertSSOUserResolutionNativeTransactionSupport, assertSSOUserResolutionSessionStorage, getFailedSSOAuthenticationResult, + mapSSOProfile, requireSuccessfulSSOAuthentication, resolveSSOUser, } from "../user-resolution"; @@ -1640,10 +1641,35 @@ async function handleOIDCCallback( ); } - if (!userInfo.email || !userInfo.id) { + if (!userInfo.id) { return redirectOIDCError("invalid_provider", "missing_user_info"); } - const userInfoEmail = userInfo.email; + const accountKey = { + issuer: + (verifiedIdToken && readStringClaim(verifiedIdToken.payload, "iss")) || + provider.issuer, + accountId: userInfo.id, + }; + const mappedUser = await mapSSOProfile(options, { + protocol: "oidc", + providerId, + accountKey, + providerUser: { + email: userInfo.email, + emailVerified: userInfo.emailVerified === true, + name: userInfo.name, + image: userInfo.image, + }, + providerClaims: rawProfile ?? {}, + }); + if (!mappedUser.email) { + ctx.context.logger.error( + "OIDC provider returned no email. Map an email claim, or create a placeholder with `mapProfileToUser`.", + { providerId }, + ); + return redirectOIDCError("invalid_provider", "missing_user_info"); + } + const userInfoEmail = mappedUser.email; const userInfoId = userInfo.id; const providerUserAttributes = Object.fromEntries( Object.entries(userInfo).filter(([key]) => key !== "id"), @@ -1651,17 +1677,9 @@ async function handleOIDCCallback( const providerUser = { ...providerUserAttributes, email: userInfoEmail, - name: typeof userInfo.name === "string" ? userInfo.name : "", - image: typeof userInfo.image === "string" ? userInfo.image : undefined, - emailVerified: options?.trustEmailVerified - ? userInfo.emailVerified === true - : false, - }; - const accountKey = { - issuer: - (verifiedIdToken && readStringClaim(verifiedIdToken.payload, "iss")) || - provider.issuer, - accountId: userInfoId, + name: mappedUser.name ?? "", + image: mappedUser.image, + emailVerified: mappedUser.emailVerified, }; const isTrustedProvider = "domainVerified" in provider && @@ -1811,7 +1829,7 @@ async function handleOIDCCallback( providerId: provider.providerId, accountId: userInfoId, email: userInfoEmail, - emailVerified: Boolean(userInfo.emailVerified), + emailVerified: providerUser.emailVerified, rawAttributes: userInfo, }, provider, diff --git a/packages/sso/src/saml.test.ts b/packages/sso/src/saml.test.ts index da6b475c14..2b58142ce1 100644 --- a/packages/sso/src/saml.test.ts +++ b/packages/sso/src/saml.test.ts @@ -39,12 +39,16 @@ import { getMigrations } from "../../better-auth/src/db/get-migration"; import { getAuthStateVerificationIdentifier } from "../../better-auth/src/state"; import { sso, validateSAMLTimestamp } from "."; import { ssoClient } from "./client"; -import { DEFAULT_CLOCK_SKEW_MS } from "./constants"; +import { DEFAULT_CLOCK_SKEW_MS, SAML_EMAIL_NAME_ID_FORMAT } from "./constants"; import { computeSSOProviderReference } from "./provider-reference"; import { findSAMLProvider } from "./routes/helpers"; import { getSafeRedirectUrl } from "./routes/saml-pipeline"; import { saml } from "./samlify"; -import type { SSOOptions, SSOUserResolutionInput } from "./types"; +import type { + SSOOptions, + SSOProfileMappingInput, + SSOUserResolutionInput, +} from "./types"; import { normalizePem } from "./utils"; const spMetadata = ` @@ -372,6 +376,8 @@ interface MockSAMLTemplateOverrides { audience?: string; destination?: string; inResponseTo?: string; + nameID?: string; + nameIDFormat?: string; subjectRecipient?: string; } @@ -411,8 +417,8 @@ const createTemplateCallback = ConditionsNotBefore: now.toISOString(), ConditionsNotOnOrAfter: fiveMinutesLater.toISOString(), SubjectConfirmationDataNotOnOrAfter: fiveMinutesLater.toISOString(), - NameIDFormat: selectedNameIDFormat, - NameID: email, + NameIDFormat: overrides.nameIDFormat ?? selectedNameIDFormat, + NameID: overrides.nameID ?? email, InResponseTo: overrides.inResponseTo ?? "null", AuthnStatement: "", attrFirstName: "Test", @@ -592,6 +598,8 @@ const createMockSAMLIdP = (port: number, options: MockIdPOptions = {}) => { : typeof requestId === "string" ? requestId : "null", + nameID: queryValue(req.query.nameID), + nameIDFormat: queryValue(req.query.nameIDFormat), subjectRecipient: queryValue(req.query.recipient), }; const { context, entityEndpoint } = (await idp.createLoginResponse( @@ -2495,6 +2503,163 @@ describe("SAML SSO", async () => { expect(redirectLocation).toContain("error=invalid_saml_response"); }); + /** + * @see https://github.com/better-auth/better-auth/issues/10209 + */ + describe("profiles without an email attribute", () => { + const PERSISTENT_NAME_ID_FORMAT = + "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent"; + + async function signInWithNameID( + options: SSOOptions, + nameID: string, + nameIDFormat: string, + ) { + const { auth, signInWithTestUser } = await getTestInstance({ + plugins: [sso({ ...options, saml: { allowIdpInitiated: true } })], + }); + const { headers } = await signInWithTestUser(); + await auth.api.registerSSOProvider({ + body: { + providerId: "nameid-provider", + issuer: "http://localhost:8081", + domain: "http://localhost:8081", + samlConfig: { + entryPoint: "http://localhost:8081/api/sso/saml2/idp/post", + cert: certificate, + wantAssertionsSigned: false, + signatureAlgorithm: "sha256", + digestAlgorithm: "sha256", + idpMetadata: { metadata: idpMetadata }, + spMetadata: { metadata: spMetadata }, + // The mock IdP sends no `mail` attribute. + mapping: { email: "mail", name: "displayName" }, + }, + }, + headers, + }); + const idpResponseUrl = new URL( + "http://localhost:8081/api/sso/saml2/idp/post", + ); + idpResponseUrl.searchParams.set("idpInitiated", "true"); + idpResponseUrl.searchParams.set("nameID", nameID); + idpResponseUrl.searchParams.set("nameIDFormat", nameIDFormat); + let samlResponse: MockSAMLResponse | undefined; + await betterFetch(idpResponseUrl.toString(), { + onSuccess: async (context) => { + samlResponse = (await context.data) as MockSAMLResponse; + }, + }); + const response = await auth.handler( + new Request( + "http://localhost:3000/api/auth/sso/saml2/sp/acs/nameid-provider", + { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + SAMLResponse: samlResponse!.samlResponse, + }), + }, + ), + ); + const context = await auth.$context; + const users = await context.adapter.findMany<{ + email: string; + emailVerified: boolean; + }>({ model: "user" }); + return { + location: response.headers.get("location") ?? "", + ssoUsers: users.filter((user) => user.email !== "test@test.com"), + }; + } + + it("does not use a persistent NameID as the email", async () => { + const { location, ssoUsers } = await signInWithNameID( + {}, + "a1b2c3d4-opaque", + PERSISTENT_NAME_ID_FORMAT, + ); + + expect(location).toContain("error="); + expect(ssoUsers).toEqual([]); + }); + + it("uses an emailAddress-format NameID as the email", async () => { + const { location, ssoUsers } = await signInWithNameID( + {}, + "nameid-user@example.com", + SAML_EMAIL_NAME_ID_FORMAT, + ); + + expect(location).not.toContain("error="); + expect(ssoUsers.map((user) => user.email)).toEqual([ + "nameid-user@example.com", + ]); + }); + + it("keeps using an opaque NameID as the email when mapProfileToUser returns it", async () => { + const { location, ssoUsers } = await signInWithNameID( + { + mapProfileToUser: (input) => ({ + email: + input.providerUser.email ?? + (input.protocol === "saml" ? input.nameID : undefined), + }), + }, + "A1B2C3D4-Opaque", + PERSISTENT_NAME_ID_FORMAT, + ); + + expect(location).not.toContain("error="); + expect(ssoUsers.map((user) => user.email)).toEqual(["a1b2c3d4-opaque"]); + }); + + it("rejects the sign-in when mapProfileToUser discards the email", async () => { + const { location, ssoUsers } = await signInWithNameID( + { mapProfileToUser: () => ({ email: null }) }, + "nameid-user@example.com", + SAML_EMAIL_NAME_ID_FORMAT, + ); + + expect(location).toContain("error="); + expect(ssoUsers).toEqual([]); + }); + + it("creates the user with the email returned by mapProfileToUser", async () => { + let mappingInput: SSOProfileMappingInput | undefined; + const { location, ssoUsers } = await signInWithNameID( + { + mapProfileToUser(input) { + mappingInput = input; + return { + email: + input.providerUser.email ?? + `${input.accountKey.accountId}@${input.providerId}.placeholder.invalid`, + }; + }, + }, + "a1b2c3d4-opaque", + PERSISTENT_NAME_ID_FORMAT, + ); + + expect(location).not.toContain("error="); + expect(mappingInput).toMatchObject({ + protocol: "saml", + providerId: "nameid-provider", + accountKey: { accountId: "a1b2c3d4-opaque" }, + nameID: "a1b2c3d4-opaque", + nameIDFormat: PERSISTENT_NAME_ID_FORMAT, + }); + expect(mappingInput?.providerUser.email).toBeUndefined(); + expect(ssoUsers).toEqual([ + expect.objectContaining({ + email: "a1b2c3d4-opaque@nameid-provider.placeholder.invalid", + emailVerified: false, + }), + ]); + }); + }); + it("should allow unsolicited SAML response when allowIdpInitiated is explicitly true", async () => { const { auth, signInWithTestUser } = await getTestInstance({ plugins: [ diff --git a/packages/sso/src/saml/index.ts b/packages/sso/src/saml/index.ts index 92ee2d236e..db4fe2fb9c 100644 --- a/packages/sso/src/saml/index.ts +++ b/packages/sso/src/saml/index.ts @@ -15,6 +15,7 @@ export { } from "./assertions"; export { resolveSigningCerts, validateCertSources } from "./cert"; export { + getSAMLNameIDFormat, getSAMLPostAssertionConsumerServiceUrls, hasSAMLEncryptedAssertion, SAML_HTTP_POST_BINDING, diff --git a/packages/sso/src/saml/response-binding.ts b/packages/sso/src/saml/response-binding.ts index 94cdc07339..ac994ee3ed 100644 --- a/packages/sso/src/saml/response-binding.ts +++ b/packages/sso/src/saml/response-binding.ts @@ -297,6 +297,14 @@ function validateAudienceRestrictions( } } +/** Returns the `Format` of the assertion's Subject NameID, when it sets one. */ +export function getSAMLNameIDFormat(samlContent: string): string | undefined { + const parsed = parseSAMLContent(samlContent); + const assertion = getAssertionNode(parsed, getResponseNode(parsed)); + const format = firstNode(firstNode(assertion.Subject)?.NameID)?.["@_Format"]; + return typeof format === "string" ? format : undefined; +} + function getBearerSubjectConfirmationData(assertion: XmlNode): XmlNode[] { const subject = firstNode(assertion.Subject); const confirmations = toNodeArray(subject?.SubjectConfirmation); diff --git a/packages/sso/src/types.ts b/packages/sso/src/types.ts index dec30039f8..37c096fc7e 100644 --- a/packages/sso/src/types.ts +++ b/packages/sso/src/types.ts @@ -358,6 +358,58 @@ export interface SSOUserResolutionContext { database: DBTransactionAdapter; } +interface BaseSSOProfileMappingInput { + providerId: string; + accountKey: BaseSSOUserResolutionInput["accountKey"]; + /** + * Profile fields read from the provider with the provider's `mapping`. + * `email` is absent when the provider sent no usable email. + */ + providerUser: { + email?: string | undefined; + emailVerified: boolean; + name?: string | undefined; + image?: string | undefined; + }; +} + +/** OIDC profile data available to `mapProfileToUser`. */ +export interface SSOOIDCProfileMappingInput extends BaseSSOProfileMappingInput { + protocol: "oidc"; + /** Raw claims from UserInfo, or the verified ID Token when UserInfo is absent. */ + providerClaims: Record; +} + +/** SAML profile data available to `mapProfileToUser`. */ +export interface SSOSAMLProfileMappingInput extends BaseSSOProfileMappingInput { + protocol: "saml"; + /** + * Attributes from the verified assertion. Multi-valued attributes remain + * arrays and all scalar values remain strings. + */ + providerAttributes: Record; + /** The verified assertion's NameID, which is also `accountKey.accountId`. */ + nameID: string; + /** The NameID `Format` attribute, when the assertion sets one. */ + nameIDFormat?: string | undefined; +} + +/** Provider profile data available to `mapProfileToUser`. */ +export type SSOProfileMappingInput = + | SSOOIDCProfileMappingInput + | SSOSAMLProfileMappingInput; + +/** User fields an application can set from `mapProfileToUser`. */ +export interface SSOMappedUser { + /** The account subject comes from the verified `sub` claim or NameID. */ + id?: never; + /** Return `null` to discard the email the provider sent. */ + email?: string | null | undefined; + emailVerified?: boolean | undefined; + name?: string | undefined; + image?: string | undefined; +} + interface BaseSSOProviderMutationGuardInput { provider: { id: string; @@ -392,6 +444,25 @@ export interface SSOProviderMutationGuardContext { } export interface SSOOptions { + /** + * Map a provider profile to user fields before the user is looked up or + * created. + * + * Runs on every OIDC and SAML sign-in, after the provider's `mapping` is + * applied. Use it for providers that send no email: sign-in fails when + * neither the provider nor this callback yields one. A SAML NameID is used + * as the email only when its `Format` is + * `urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress`. + * + * The returned email is used to find, link, and create the user, and to + * decide whether the provider's verified domain trusts it. Return only + * addresses the provider vouches for, or a placeholder on the reserved + * `.invalid` domain, such as + * `${accountKey.accountId}@${providerId}.placeholder.invalid`. + */ + mapProfileToUser?: + | ((input: SSOProfileMappingInput) => Awaitable) + | undefined; /** * Resolve a verified provider identity to a Better Auth user. * diff --git a/packages/sso/src/user-resolution.ts b/packages/sso/src/user-resolution.ts index 72a1fee707..7f997b3b40 100644 --- a/packages/sso/src/user-resolution.ts +++ b/packages/sso/src/user-resolution.ts @@ -8,12 +8,34 @@ import { APIError } from "better-auth/api"; import type { handleOAuthUserInfo } from "better-auth/oauth2"; import type { SSOOptions, + SSOProfileMappingInput, SSOUserResolution, SSOUserResolutionInput, } from "./types"; const SSO_AUTHENTICATION_FAILURE = Symbol("SSO_AUTHENTICATION_FAILURE"); +/** + * Applies `mapProfileToUser` to the profile read from the provider. Only the + * documented user fields are taken from the callback's result. + */ +export async function mapSSOProfile( + options: SSOOptions | undefined, + input: SSOProfileMappingInput, +): Promise { + if (!options?.mapProfileToUser) { + return input.providerUser; + } + const { email, emailVerified, name, image } = + await options.mapProfileToUser(input); + return { + email: email === null ? undefined : (email ?? input.providerUser.email), + emailVerified: emailVerified ?? input.providerUser.emailVerified, + name: name ?? input.providerUser.name, + image: image ?? input.providerUser.image, + }; +} + type SSOAuthenticationResult = Awaited>; type SSOAuthenticationFailure = Error & { readonly [SSO_AUTHENTICATION_FAILURE]: true;