import { type BetterAuthOptions, betterAuth } from 'better-auth'; import { APIError, createAuthMiddleware } from 'better-auth/api'; import { type OAuthProvider, verifyProviderIdToken } from 'better-auth/oauth2'; import { genericOAuth } from 'better-auth/plugins'; const ISSUER = 'https://id.lssm.co/api/auth'; export interface ExternalIdentityConfig { readonly origin: string; readonly clientId: string; readonly clientSecret: string; readonly applicationId: string; readonly sessionSecret: string; readonly database: BetterAuthOptions['database']; } /** * Confidential web OIDC consumer: complete the browser-bound authorization-code * flow with state, S256 PKCE and an exact server-generated nonce. A signed ID * token is mandatory; userinfo may only supplement its verified subject, and * browser-submitted tokens may not establish an identity. Final account binding * uses that subject even without discovery algorithm hints; profile `id` is not * an identity fallback. No workspace imports or shared browser cookies. */ export function externalIdentityOptions( config: ExternalIdentityConfig ): BetterAuthOptions { const origin = new URL(config.origin); if ( origin.protocol !== 'https:' || origin.origin !== config.origin || origin.username || origin.password ) throw new Error('EXTERNAL_APP_HTTPS_ORIGIN_REQUIRED'); if ( !/^[a-zA-Z0-9_-]{1,200}$/.test(config.applicationId) || !config.clientId || config.clientSecret.length < 32 || config.sessionSecret.length < 32 || !config.database ) throw new Error('EXTERNAL_APP_CONFIG_REQUIRED'); return { appName: 'LSSM ID external reference app', baseURL: config.origin, secret: config.sessionSecret, database: config.database, emailAndPassword: { enabled: false }, account: { accountLinking: { enabled: false } }, trustedOrigins: [config.origin], advanced: { cookiePrefix: 'external-lssm-id', useSecureCookies: true }, plugins: [ verifiedLssmProvider({ config: [ { providerId: 'lssm', clientId: config.clientId, clientSecret: config.clientSecret, discoveryUrl: 'https://id.lssm.co/.well-known/openid-configuration', scopes: [ 'openid', 'profile', 'email', `https://lssm.co/scopes/${config.applicationId}`, ], redirectURI: `${config.origin}/api/auth/callback/lssm`, requireIdTokenVerification: true, pkce: true, authentication: 'basic', disableProviderLogout: true, disableImplicitSignUp: false, }, ], }), ], }; } /** * Better Auth 1.7.14 compatibility fence, matching the monorepo's reviewed OIDC * binding without a workspace dependency. Discovery metadata alone does not * require an actual ID token in this SDK. Rebind after every discovery retry, * reject userinfo-only fallback, and disable the separate client-token branch. * Signature, issuer, audience, expiry and nonce checks use its public verifier. */ function verifiedLssmProvider(options: Parameters[0]) { const plugin = genericOAuth(options); const clientId = options.config.find( (provider) => provider.providerId === 'lssm' )?.clientId; if (!clientId) throw new Error('LSSM_CLIENT_ID_REQUIRED'); const wrapped = new WeakSet(); const bind = (provider: OAuthProvider): OAuthProvider | undefined => { if (provider.id !== 'lssm' || wrapped.has(provider)) return provider; const verification = provider.idToken; if ( provider.issuer !== ISSUER || !verification || !('jwks' in verification) || verification.issuer !== ISSUER || verification.audience !== clientId ) return undefined; const idTokenVerification = { ...verification, issuer: ISSUER, audience: clientId, allowOpaqueToken: false, nonceComparison: 'exact' as const, }; const pinned: OAuthProvider = { ...provider, idToken: idTokenVerification, // Native Generic OAuth switches to profile.id when discovery omits // algorithm hints. This confidential OIDC binding always uses the sub // already compared with the verified token by getUserInfo below. accountSubject: ({ profile }) => { const subject = profile && typeof profile === 'object' ? Reflect.get(profile, 'sub') : undefined; return typeof subject === 'string' ? subject : ''; }, requiresIdTokenNonce: true, options: { ...provider.options, disableIdTokenSignIn: true }, getUserInfo: async (tokens) => { let verifiedSubject: string | undefined; if ( !tokens.idToken || !tokens.expectedIdTokenNonce || !(await verifyProviderIdToken( // Enable only this private verification view. The provider // itself keeps client-token sign-in disabled. Omitting other // option overrides forces the trusted discovery/JWKS proof. { ...pinned, options: { disableIdTokenSignIn: false }, idToken: { ...idTokenVerification, verifyClaims: (claims) => { // Capture proof before any trusted claims hook can // mutate the payload object passed by the verifier. const subject = typeof claims.sub === 'string' ? claims.sub : undefined; if ( idTokenVerification.verifyClaims && !idTokenVerification.verifyClaims(claims) ) return false; // The SDK calls this only after signature, claim // and nonce verification; no local JWT parser. verifiedSubject = subject; return Boolean(verifiedSubject); }, }, }, tokens.idToken, tokens.expectedIdTokenNonce )) ) return null; const info = await provider.getUserInfo(tokens); if ( !info?.data || typeof info.data !== 'object' || Reflect.get(info.data, 'sub') !== verifiedSubject ) return null; return info; }, }; wrapped.add(pinned); return pinned; }; const bindAll = (providers: OAuthProvider[]) => { let rejected = false; const next = providers.flatMap((provider) => { const pinned = bind(provider); if (!pinned) rejected = true; return pinned ? [pinned] : []; }); providers.splice(0, providers.length, ...next); if (rejected) throw new APIError('SERVICE_UNAVAILABLE', { code: 'LSSM_ISSUER_MISMATCH', message: 'The identity provider is unavailable.', }); }; return { ...plugin, init: async (context: Parameters[0]) => { const result = await plugin.init(context); bindAll(result.context.socialProviders); return result; }, hooks: { ...plugin.hooks, before: [ ...plugin.hooks.before, { matcher: () => true, handler: createAuthMiddleware(async (context) => { bindAll(context.context.socialProviders); if ( context.path?.startsWith('/callback/') && context.params?.id === 'lssm' ) { const queryIssuer: unknown = context.query && typeof context.query === 'object' ? Reflect.get(context.query, 'iss') : undefined; const bodyIssuer: unknown = context.method === 'POST' && context.body && typeof context.body === 'object' ? Reflect.get(context.body, 'iss') : undefined; // LSSM advertises RFC 9207 and emits iss on code responses. // Match the SDK's query-first form-post normalization. if ( (queryIssuer === undefined ? bodyIssuer : queryIssuer) !== ISSUER ) throw new APIError('FORBIDDEN', { code: 'LSSM_AUTHORIZATION_ISSUER_REQUIRED', message: 'The authorization response is invalid.', }); } }), }, ], }, }; } export function createExternalIdentityAuth(config: ExternalIdentityConfig) { return betterAuth(externalIdentityOptions(config)); }