Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ function makeProvider(over: Partial<IdentityProvider>): IdentityProvider {
autoCreateUsers: true,
autoProvisionRole: 'user',
attributeMapping: null,
profileMapping: null,
showButton: false,
detailsChangedAt: null,
lastSuccessfulTestAt: null,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ function makeProvider(over: Partial<IdentityProvider>): IdentityProvider {
autoCreateUsers: true,
autoProvisionRole: 'user',
attributeMapping: null,
profileMapping: null,
showButton: false,
detailsChangedAt: null,
lastSuccessfulTestAt: null,
Expand Down
165 changes: 163 additions & 2 deletions apps/web/src/lib/server/auth/__tests__/provider-registration.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
import { describe, it, expect } from 'vitest'
import { buildGenericOAuthConfigs } from '../build-oauth-configs'
import { describe, it, expect, afterEach, vi } from 'vitest'
import { buildGenericOAuthConfigs, buildProfileMappingGetUserInfo } from '../build-oauth-configs'
import { getAllAuthProviders } from '../auth-providers'

/** Unsigned JWT with the given payload — the login path never verifies. */
function fakeJwt(payload: Record<string, unknown>): string {
const b64 = (o: object) => Buffer.from(JSON.stringify(o)).toString('base64url')
return `${b64({ alg: 'HS256', typ: 'JWT' })}.${b64(payload)}.sig`
}

describe('buildGenericOAuthConfigs', () => {
it('registers one config per enabled provider under its registrationId', async () => {
const cfgs = await buildGenericOAuthConfigs({
Expand Down Expand Up @@ -54,6 +60,161 @@ describe('buildGenericOAuthConfigs', () => {
})
})

describe('profile mapping (getUserInfo)', () => {
afterEach(() => {
vi.unstubAllGlobals()
})

it('attaches getUserInfo only when the provider has a profileMapping', async () => {
const cfgs = await buildGenericOAuthConfigs({
providers: [
{
id: 'idp_plain',
registrationId: 'oidc_plain',
enabled: true,
autoCreateUsers: true,
discoveryUrl: 'https://x/.well-known/openid-configuration',
},
{
id: 'idp_eve',
registrationId: 'oidc_eve',
enabled: true,
autoCreateUsers: true,
discoveryUrl: 'https://login.eveonline.com/.well-known/openid-configuration',
profileMapping: { source: 'accessTokenJwt' },
},
] as any,
creds: async () => ({ clientId: 'c', clientSecret: 's' }),
tierAllowsOidc: true,
})
expect(cfgs).toHaveLength(2)
expect(cfgs.find((c) => c.providerId === 'oidc_plain')?.getUserInfo).toBeUndefined()
expect(cfgs.find((c) => c.providerId === 'oidc_eve')?.getUserInfo).toBeTypeOf('function')
})

it('accessTokenJwt: decodes the access token and synthesizes a sanitized fallback email', async () => {
const getUserInfo = buildProfileMappingGetUserInfo(
{ userInfoUrl: null, discoveryUrl: 'https://login.eveonline.com/.well-known/oidc' },
{ source: 'accessTokenJwt', emailFallback: '{id}@eve.example.com' }
)
const user = await getUserInfo({
accessToken: fakeJwt({
sub: 'CHARACTER:EVE:2119123456',
name: 'Some Pilot',
owner: 'abc123=',
}),
})
expect(user).toMatchObject({
id: 'CHARACTER:EVE:2119123456',
name: 'Some Pilot',
email: 'character.eve.2119123456@eve.example.com',
emailVerified: true,
})
// Raw claims are spread through for mapProfileToUser consumers.
expect(user?.owner).toBe('abc123=')
})

it('accessTokenJwt: prefers a real email claim over the fallback', async () => {
const getUserInfo = buildProfileMappingGetUserInfo(
{ userInfoUrl: null, discoveryUrl: null },
{ source: 'accessTokenJwt', emailFallback: '{id}@sso.example.com' }
)
const user = await getUserInfo({
accessToken: fakeJwt({ sub: 'u1', name: 'N', email: 'real@example.com' }),
})
expect(user?.email).toBe('real@example.com')
// Claim-sourced email is only verified when the IdP says so.
expect(user?.emailVerified).toBe(false)
})

it('accessTokenJwt: returns null on a malformed token or missing id claim', async () => {
const getUserInfo = buildProfileMappingGetUserInfo(
{ userInfoUrl: null, discoveryUrl: null },
{ source: 'accessTokenJwt' }
)
expect(await getUserInfo({ accessToken: 'not-a-jwt' })).toBeNull()
expect(await getUserInfo({})).toBeNull()
expect(await getUserInfo({ accessToken: fakeJwt({ name: 'no sub here' }) })).toBeNull()
})

it('accessTokenJwt: omits email when absent and no fallback is configured', async () => {
// Better-Auth then reports the accurate email_is_missing error.
const getUserInfo = buildProfileMappingGetUserInfo(
{ userInfoUrl: null, discoveryUrl: null },
{ source: 'accessTokenJwt' }
)
const user = await getUserInfo({ accessToken: fakeJwt({ sub: 'u1', name: 'N' }) })
expect(user?.id).toBe('u1')
expect(user?.email).toBeUndefined()
})

it('userinfo: fetches the row userInfoUrl with the bearer token and maps custom claim paths', async () => {
const fetchMock = vi.fn(async () => ({
ok: true,
json: async () => ({
CharacterID: 2119123456,
CharacterName: 'Some Pilot',
email_verified: true,
contact: { email: 'pilot@example.com' },
}),
}))
vi.stubGlobal('fetch', fetchMock)

const getUserInfo = buildProfileMappingGetUserInfo(
{ userInfoUrl: 'https://idp.example.com/userinfo', discoveryUrl: null },
{
source: 'userinfo',
idClaim: 'CharacterID',
nameClaim: 'CharacterName',
emailClaim: 'contact.email',
}
)
const user = await getUserInfo({ accessToken: 'opaque-token' })
expect(fetchMock).toHaveBeenCalledWith('https://idp.example.com/userinfo', {
headers: { authorization: 'Bearer opaque-token' },
})
expect(user).toMatchObject({
id: '2119123456',
name: 'Some Pilot',
email: 'pilot@example.com',
emailVerified: true,
})
})

it('userinfo: resolves the endpoint from discovery once and caches it', async () => {
const fetchMock = vi.fn(async (url: string) =>
url === 'https://idp.example.com/.well-known/openid-configuration'
? { ok: true, json: async () => ({ userinfo_endpoint: 'https://idp.example.com/me' }) }
: { ok: true, json: async () => ({ sub: 'u1', name: 'N', email: 'e@example.com' }) }
)
vi.stubGlobal('fetch', fetchMock)

const getUserInfo = buildProfileMappingGetUserInfo(
{
userInfoUrl: null,
discoveryUrl: 'https://idp.example.com/.well-known/openid-configuration',
},
{ source: 'userinfo' }
)
expect((await getUserInfo({ accessToken: 't1' }))?.id).toBe('u1')
expect((await getUserInfo({ accessToken: 't2' }))?.id).toBe('u1')
// 1 discovery fetch + 2 userinfo fetches — discovery cached after the first.
expect(fetchMock).toHaveBeenCalledTimes(3)
})

it('userinfo: returns null when the userinfo fetch fails', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () => ({ ok: false, json: async () => ({}) }))
)
const getUserInfo = buildProfileMappingGetUserInfo(
{ userInfoUrl: 'https://idp.example.com/userinfo', discoveryUrl: null },
{ source: 'userinfo' }
)
expect(await getUserInfo({ accessToken: 't' })).toBeNull()
})
})

describe('social provider registration regression (H3)', () => {
it('still exposes the 10 built-in social providers for the social loop', () => {
// After OIDC moved to the identity_provider list, the only
Expand Down
144 changes: 144 additions & 0 deletions apps/web/src/lib/server/auth/build-oauth-configs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
*/

import type { IdentityProvider } from '@/lib/server/domains/settings/identity-providers.service'
import type { IdentityProviderProfileMapping } from '@/lib/server/db'

/**
* Default OIDC scopes requested when a provider has no explicit `scopes`.
Expand All @@ -28,6 +29,19 @@ import type { IdentityProvider } from '@/lib/server/domains/settings/identity-pr
*/
export const DEFAULT_OIDC_SCOPES = ['openid', 'email', 'profile'] as const

/**
* Profile shape returned by a custom `getUserInfo`. The mapped identity
* fields satisfy Better-Auth's `OAuth2UserInfo`; the raw claims are spread
* alongside so `mapProfileToUser` (locale) still sees them.
*/
export type MappedUserInfo = {
id: string
name?: string
email?: string
image?: string
emailVerified: boolean
} & Record<string, unknown>

/** A single entry in the genericOAuth plugin's `config` array. */
export interface GenericOAuthConfig {
providerId: string
Expand All @@ -39,6 +53,16 @@ export interface GenericOAuthConfig {
authorizationUrl?: string
tokenUrl?: string
scopes?: string[]
/**
* Custom user-info resolution, attached only when the provider row has a
* `profile_mapping`. Replaces Better-Auth's id_token/userinfo default for
* IdPs whose claims don't fit it (e.g. EVE Online: no id_token, no email,
* identity in the JWT access token).
*/
getUserInfo?: (tokens: {
accessToken?: string
idToken?: string
}) => Promise<MappedUserInfo | null>
mapProfileToUser?: (profile: unknown) => Record<string, unknown>
// Force the IdP account picker so admins notice when they're already
// signed in as a different identity.
Expand Down Expand Up @@ -67,6 +91,121 @@ export type ProviderCredentials = {
discoveryUrl?: string
} | null

/** Resolve a dotted claim path (same convention as `attributeMapping.claimPath`). */
function resolveClaim(claims: Record<string, unknown>, path: string): unknown {
// Namespaced claims (e.g. "https://acme.com/roles") contain dots that are
// not path separators — prefer an exact key match before splitting.
if (path in claims) return claims[path]
let current: unknown = claims
for (const segment of path.split('.')) {
if (current === null || typeof current !== 'object') return undefined
current = (current as Record<string, unknown>)[segment]
}
return current
}

/** Decode a JWT's payload without verification — mirrors how Better-Auth's
* generic-oauth login path treats id_tokens (bare `decodeJwt`). The token
* was just received first-hand from the IdP's token endpoint over TLS, so
* possession is the trust anchor; there is no third-party token to verify. */
function decodeJwtPayload(token: string): Record<string, unknown> | null {
const parts = token.split('.')
if (parts.length !== 3) return null
try {
const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf-8'))
return payload !== null && typeof payload === 'object'
? (payload as Record<string, unknown>)
: null
} catch {
return null
}
}

/**
* Synthesize an address from the `emailFallback` template. The `{id}`
* placeholder is sanitized to `[a-z0-9._-]` so a structured id like EVE's
* `CHARACTER:EVE:2119…` becomes a valid local part (`character.eve.2119…`).
*/
function synthesizeEmail(template: string, id: string): string {
const sanitized = id
.toLowerCase()
.replace(/[^a-z0-9._-]+/g, '.')
.replace(/^\.+|\.+$/g, '')
return template.replaceAll('{id}', sanitized)
}

/**
* Build the custom `getUserInfo` for a provider with a `profile_mapping`.
*
* Claim source is either the JWT access token's payload (`accessTokenJwt`)
* or the provider's userinfo endpoint (`userinfo` — the row's `userInfoUrl`,
* else resolved from the discovery document once and cached). Identity
* fields resolve via the configured claim paths; a missing email falls back
* to the `emailFallback` template (such users are marked emailVerified —
* there is no real inbox to verify). Returns null (→ Better-Auth's
* `user_info_is_missing` redirect) when claims can't be obtained or the id
* claim is absent.
*/
export function buildProfileMappingGetUserInfo(
provider: Pick<IdentityProvider, 'userInfoUrl' | 'discoveryUrl'>,
mapping: IdentityProviderProfileMapping
): NonNullable<GenericOAuthConfig['getUserInfo']> {
// Discovery resolution is once-per-auth-instance: the closure lives as
// long as the built config, which resetAuth() discards on config change.
let cachedUserInfoUrl: string | null = provider.userInfoUrl

async function fetchClaims(accessToken: string): Promise<Record<string, unknown> | null> {
if (!cachedUserInfoUrl && provider.discoveryUrl) {
const res = await fetch(provider.discoveryUrl)
if (!res.ok) return null
const doc = (await res.json()) as { userinfo_endpoint?: unknown }
if (typeof doc.userinfo_endpoint !== 'string') return null
cachedUserInfoUrl = doc.userinfo_endpoint
}
if (!cachedUserInfoUrl) return null
const res = await fetch(cachedUserInfoUrl, {
headers: { authorization: `Bearer ${accessToken}` },
})
if (!res.ok) return null
const claims = (await res.json()) as unknown
return claims !== null && typeof claims === 'object'
? (claims as Record<string, unknown>)
: null
}

return async (tokens) => {
if (!tokens.accessToken) return null

const claims =
mapping.source === 'accessTokenJwt'
? decodeJwtPayload(tokens.accessToken)
: await fetchClaims(tokens.accessToken)
if (!claims) return null

const id = resolveClaim(claims, mapping.idClaim ?? 'sub')
if (id === undefined || id === null || id === '') return null

const name = resolveClaim(claims, mapping.nameClaim ?? 'name')
const emailClaim = resolveClaim(claims, mapping.emailClaim ?? 'email')
let email = typeof emailClaim === 'string' && emailClaim !== '' ? emailClaim : undefined
let emailVerified = resolveClaim(claims, 'email_verified') === true
if (!email && mapping.emailFallback) {
email = synthesizeEmail(mapping.emailFallback, String(id))
emailVerified = true
}

// A still-missing email is returned as-is so Better-Auth reports the
// accurate `email_is_missing` (not `user_info_is_missing`).
return {
...claims,
id: String(id),
...(typeof name === 'string' && name !== '' ? { name } : {}),
...(email ? { email } : {}),
emailVerified,
}
}
}

export interface BuildGenericOAuthConfigsArgs {
providers: IdentityProvider[]
/** Fetches the decrypted credential blob for a provider's registrationId. */
Expand Down Expand Up @@ -137,6 +276,11 @@ export async function buildGenericOAuthConfigs({
// handleOAuthUserInfo before any user/session is created. Existing
// users still link via accountLinking.trustedProviders.
disableSignUp: provider.autoCreateUsers === false,
// Custom profile-claim resolution (opt-in via the profile_mapping
// column) for IdPs whose user info doesn't fit the OIDC defaults.
...(provider.profileMapping
? { getUserInfo: buildProfileMappingGetUserInfo(provider, provider.profileMapping) }
: {}),
...(mapProfileToUser ? { mapProfileToUser } : {}),
...(buildLoginHintParams ? { authorizationUrlParams: buildLoginHintParams } : {}),
})
Expand Down
5 changes: 4 additions & 1 deletion apps/web/src/lib/server/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -275,7 +275,10 @@ export {

// Re-export schema types not covered by @quackback/db/types
export type { ServiceMetadata } from '@quackback/db'
export type { IdentityProviderAttributeMapping } from '@quackback/db'
export type {
IdentityProviderAttributeMapping,
IdentityProviderProfileMapping,
} from '@quackback/db'

// Re-export types (for client components that need types without side effects)
export * from '@quackback/db/types'
Loading