Skip to content

feat(sso): per-provider profile mapping for non-standard OIDC IdPs#337

Open
snipereagle1 wants to merge 1 commit into
QuackbackIO:mainfrom
snipereagle1:feat/idp-profile-mapping
Open

feat(sso): per-provider profile mapping for non-standard OIDC IdPs#337
snipereagle1 wants to merge 1 commit into
QuackbackIO:mainfrom
snipereagle1:feat/idp-profile-mapping

Conversation

@snipereagle1

Copy link
Copy Markdown

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):

  • Advertises OIDC discovery but returns no id_token at all
  • Its userinfo endpoint (/v2/oauth/verify) returns PascalCase fields (CharacterID, CharacterName, …) with no sub/id
  • Never releases an email — EVE identities don't have one
  • The actual identity lives in the JWT access token: sub: "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_mapping jsonb column:

interface IdentityProviderProfileMapping {
  /** Where profile claims come from. 'userinfo' (default) = fetch the
   *  userinfo endpoint, mapping applied on top; 'accessTokenJwt' decodes
   *  the JWT access token's payload. */
  source?: 'userinfo' | 'accessTokenJwt'
  idClaim?: string // default 'sub'   — dotted paths supported
  nameClaim?: string // default 'name'
  emailClaim?: string // default 'email'
  /** Template used when the email claim is absent, e.g. "{id}@sso.example.com".
   *  {id} = resolved id claim, sanitized to [a-z0-9._-]. Presence of this
   *  field is the opt-in for synthesized emails; such users are marked
   *  emailVerified (no real inbox exists to verify). */
  emailFallback?: string
}

When profile_mapping is non-null, buildGenericOAuthConfigs attaches a custom getUserInfo to that provider's generic-oauth config:

  • accessTokenJwt — decodes the access token's payload (unverified, matching the trust model of the login path's existing bare decodeJwt of id_tokens: the token arrives first-hand from the token endpoint over TLS; possession is the trust anchor)
  • userinfo — fetches the row's userInfoUrl (or resolves userinfo_endpoint from the discovery document once, cached per auth instance) with the bearer token, then maps claims on top

Claim resolution supports dotted paths with an exact-key match preferred first (so namespaced claims like https://acme.com/email still work). A missing email with no fallback is returned as-is so Better-Auth reports the accurate email_is_missing rather than user_info_is_missing. Providers without a mapping are completely untouched — no behavior change.

Composes cleanly with the existing mapProfileToUser locale passthrough: raw claims are spread into the returned profile, and Better-Auth falls back to the getUserInfo result for id/email/name.

Example: working EVE Online provider row

discovery_url:   https://login.eveonline.com/.well-known/openid-configuration
scopes:          ' '   (single space — EVE auth-only apps reject any scope, including openid)
profile_mapping: {"source": "accessTokenJwt", "emailFallback": "{id}@sso.example.com"}

Signs in as the character: user <Character Name>, account_id CHARACTER:EVE:<id>, email character.eve.<id>@sso.example.com (verified, synthesized).

Files changed

  • packages/db/src/schema/auth.tsIdentityProviderProfileMapping type + profile_mapping jsonb column
  • packages/db/drizzle/0126_identity_provider_profile_mapping.sql — migration (hand-authored, matching recent migrations' convention)
  • apps/web/src/lib/server/auth/build-oauth-configs.tsgetUserInfo factory (buildProfileMappingGetUserInfo), kept pure/no DB imports per the file's philosophy
  • apps/web/src/lib/server/domains/settings/identity-providers.service.ts — carry profileMapping through 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)
  • Tests: 9 new cases in provider-registration.test.ts covering 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 handling

Verified

  • Unit: 12/12 in provider-registration.test.ts; monorepo lint 0 errors; typecheck adds no new errors over main
  • Live end-to-end: full docker-compose.prod stack built from this branch; real EVE character login through the portal button → callback 302, JIT user + oidc_eve account + session created, synthesized verified email, no /api/auth/error bounce

Known limitations (called out, not regressions)

  1. Admin "Test sign-in" diagnostic (sso-test-handshake.ts) hard-requires an id_token + JWKS verification, so it will always fail for an accessTokenJwt provider. 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.
  2. Role attributeMapping reads claims from the stored account.id_token, so accessTokenJwt providers resolve the default role. Follow-up could read the access-token claims when the provider's mapping source says so.
  3. No editor UI yet — this PR is deliberately backend-only to get agreement on the mechanism first. If the approach lands, a follow-up adds a Scopes field (the column + API already exist but the editor renders no input) and an "Advanced" profile-mapping section to provider-editor.tsx. Until then the field is settable via the upsert server function.
  4. Synthesized-email users can't receive notifications or magic links (documented on the type; inherent to email-less IdPs).
  5. Reproducing the EVE case requires EVE developer credentials, which can't be created without an account that has purchased game time — reviewers may not be able to test against EVE directly. The userinfo mapping path is testable with any IdP (or the unit tests' mocked flows), and I'm happy to work together to get the accessTokenJwt path 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.

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.
@CLAassistant

CLAassistant commented Jul 23, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

@mortondev mortondev self-assigned this Jul 24, 2026
@mortondev
mortondev self-requested a review July 24, 2026 08:20
@mortondev mortondev removed their assignment Jul 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants