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.
This commit is contained in:
Gustavo Valverde
2026-10-14 10:50:46 +01:00
parent 9e0b1780b0
commit 7484c5dd3e
12 changed files with 526 additions and 50 deletions
+19
View File
@@ -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),
}),
});
```
+30 -1
View File
@@ -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.
<Callout type="warn">
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).
</Callout>
### 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"
+9
View File
@@ -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";
+8
View File
@@ -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,
+104
View File
@@ -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,
});
});
});
+53 -31
View File
@@ -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,
+32 -14
View File
@@ -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,
+169 -4
View File
@@ -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: [
+1
View File
@@ -15,6 +15,7 @@ export {
} from "./assertions";
export { resolveSigningCerts, validateCertSources } from "./cert";
export {
getSAMLNameIDFormat,
getSAMLPostAssertionConsumerServiceUrls,
hasSAMLEncryptedAssertion,
SAML_HTTP_POST_BINDING,
@@ -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);
+71
View File
@@ -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<string, unknown>;
}
/** 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<string, string | readonly string[]>;
/** 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<SSOMappedUser>)
| undefined;
/**
* Resolve a verified provider identity to a Better Auth user.
*
+22
View File
@@ -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<SSOProfileMappingInput["providerUser"]> {
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<ReturnType<typeof handleOAuthUserInfo>>;
type SSOAuthenticationFailure = Error & {
readonly [SSO_AUTHENTICATION_FAILURE]: true;