feat(sso): per-provider profile mapping for non-standard OIDC IdPs#337
Open
snipereagle1 wants to merge 1 commit into
Open
feat(sso): per-provider profile mapping for non-standard OIDC IdPs#337snipereagle1 wants to merge 1 commit into
snipereagle1 wants to merge 1 commit into
Conversation
Some OIDC providers don't fit Better-Auth's default user-info resolution
(id_token claims or a userinfo endpoint returning sub/name/email). EVE
Online SSO, for example, returns no id_token and no email — the identity
lives in the JWT access token (sub/name), and its userinfo response uses
PascalCase keys with no sub at all, so generic-oauth sign-in dies with
user_info_is_missing.
Add a nullable identity_provider.profile_mapping jsonb column:
{ source?: 'userinfo' | 'accessTokenJwt',
idClaim?, nameClaim?, emailClaim?, // dotted paths, OIDC defaults
emailFallback? } // e.g. "{id}@sso.example.com"
When set, buildGenericOAuthConfigs attaches a custom getUserInfo to that
provider's config: 'accessTokenJwt' decodes the access token's payload
(unverified — same trust model as the login path's bare decodeJwt of
id_tokens; the token arrives first-hand from the token endpoint over
TLS), 'userinfo' fetches the row's userInfoUrl (or resolves it from
discovery once) and maps claims on top. A missing email falls back to
the emailFallback template with {id} sanitized to [a-z0-9._-]; such
users are marked emailVerified since no real inbox exists. Providers
without a mapping are untouched.
Known limitations (follow-ups, not regressions):
- The admin "Test sign-in" diagnostic hard-requires an id_token + JWKS
verification, so it always fails for accessTokenJwt providers; sign-in
itself works, only the SSO-enforcement unlock is gated on a passing
test.
- Role attributeMapping reads claims from the stored account.id_token,
so accessTokenJwt providers resolve the default role.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
Some OIDC providers can't complete sign-in through the Custom OIDC (generic-oauth) path because their user info doesn't fit Better-Auth's default resolution — id_token claims, or a userinfo endpoint returning
sub/name/email.Concrete motivating case, EVE Online SSO (
login.eveonline.com, docs):/v2/oauth/verify) returns PascalCase fields (CharacterID,CharacterName, …) with nosub/idsub: "CHARACTER:EVE:<id>",name: "<character name>"Result today: authorization + PKCE + token exchange all succeed, then the callback dies with
user_info_is_missing. (Verified live before writing this patch.)This is shaped as a generic per-provider profile mapping, not an EVE special case — the same mechanism covers any IdP with non-standard claim names, claims nested in objects, or identities without email.
What this adds
A nullable
identity_provider.profile_mappingjsonb column:When
profile_mappingis non-null,buildGenericOAuthConfigsattaches a customgetUserInfoto that provider's generic-oauth config:accessTokenJwt— decodes the access token's payload (unverified, matching the trust model of the login path's existing baredecodeJwtof id_tokens: the token arrives first-hand from the token endpoint over TLS; possession is the trust anchor)userinfo— fetches the row'suserInfoUrl(or resolvesuserinfo_endpointfrom the discovery document once, cached per auth instance) with the bearer token, then maps claims on topClaim resolution supports dotted paths with an exact-key match preferred first (so namespaced claims like
https://acme.com/emailstill work). A missing email with no fallback is returned as-is so Better-Auth reports the accurateemail_is_missingrather thanuser_info_is_missing. Providers without a mapping are completely untouched — no behavior change.Composes cleanly with the existing
mapProfileToUserlocale passthrough: raw claims are spread into the returned profile, and Better-Auth falls back to thegetUserInforesult forid/email/name.Example: working EVE Online provider row
Signs in as the character: user
<Character Name>, account_idCHARACTER:EVE:<id>, emailcharacter.eve.<id>@sso.example.com(verified, synthesized).Files changed
packages/db/src/schema/auth.ts—IdentityProviderProfileMappingtype +profile_mappingjsonb columnpackages/db/drizzle/0126_identity_provider_profile_mapping.sql— migration (hand-authored, matching recent migrations' convention)apps/web/src/lib/server/auth/build-oauth-configs.ts—getUserInfofactory (buildProfileMappingGetUserInfo), kept pure/no DB imports per the file's philosophyapps/web/src/lib/server/domains/settings/identity-providers.service.ts— carryprofileMappingthrough the interface, list mapping, and upsert (patch semantics preserved)apps/web/src/lib/server/functions/sso.ts— zod schema for the new field (emailFallback validated as an email-shaped template)provider-registration.test.tscovering attach-only-when-configured, JWT decode + claim mapping, email synthesis + sanitization, real-email preference, malformed-token/missing-id nulls, userinfo fetch with custom claim paths, discovery resolution + caching, and fetch-failure handlingVerified
provider-registration.test.ts; monorepo lint 0 errors; typecheck adds no new errors overmain302, JIT user +oidc_eveaccount + session created, synthesized verified email, no/api/auth/errorbounceKnown limitations (called out, not regressions)
sso-test-handshake.ts) hard-requires an id_token + JWKS verification, so it will always fail for anaccessTokenJwtprovider. Sign-in itself works; only the SSO enforcement unlock is gated on a passing test. Teaching the handshake about profile mapping is a natural follow-up.attributeMappingreads claims from the storedaccount.id_token, soaccessTokenJwtproviders resolve the default role. Follow-up could read the access-token claims when the provider's mapping source says so.provider-editor.tsx. Until then the field is settable via the upsert server function.userinfomapping path is testable with any IdP (or the unit tests' mocked flows), and I'm happy to work together to get theaccessTokenJwtpath verified on your side.I really like what you're doing with this project, and I'd like to use it with a couple of apps that I build, but requiring an email is a non-starter for my audience. I took this PR-first approach to show a working example of what I'm trying to do, but I'm not super tied to any aspect of it as long as I can get to a point where there's a working "sign in with eve online" option. Happy to contribute UI as well but wanted to get on the same page with the backend options first.