import { DEF_OIDC_ACCOUNT_LINKING, DEF_OIDC_PROVIDER_API, DEFAULT_ALIAS } from './consts.js' import { assertContext, createService } from '@owlmeans/context' import type { AccountLinkingService, Config, Context, OidcClientService, ProviderApiService, OidcClientAdapter, OidcTokenSet, OidcTokenSetParameters, OidcIntrospectionResponse } from './types.js' import { type AuthorizationCodeGrantChecks, Configuration } from 'openid-client' import { AuthManagerError } from '@owlmeans/auth' import { makeSecurityHelper } from '@owlmeans/config' import * as client from 'openid-client' import type { OidcProviderConfig } from '@owlmeans/oidc' import { _configFlag, matchCriteria } from './consts.local.js' import type { TemporaryConfig } from './types.local.js' // import { URL as SrvURL } from 'node:url' export const makeOidcClientService = (alias: string = DEFAULT_ALIAS): OidcClientService => { const service: OidcClientService = createService(alias, { getConfiguration: async clientId => { const context = assertContext(service.ctx as Context, alias) const cfg = await service.getConfig(clientId) // `discoveryUrl` is the canonical, fully-qualified issuer locator: when it is present the // provider URL is used verbatim and `service`/`basePath` are irrelevant. Those two are only // required by the legacy fallback that reassembles the issuer from a registered service host // plus a base path — a shape that forces the relying party to know the provider's URL layout // (and to keep it in sync with however the provider itself builds its issuer). const discoveryUrl = cfg?.discoveryUrl != null && cfg.discoveryUrl !== '' ? cfg.discoveryUrl : null if (discoveryUrl == null) { if (cfg?.basePath == null) { throw new AuthManagerError('oidc.client.basepath') } if (cfg.service == null) { throw new AuthManagerError('oidc.client.service') } } if (typeof clientId === 'object' && clientId.clientId == null && cfg?.clientId == null) { throw new AuthManagerError('oidc.client.client-id') } if (cfg == null) { throw new AuthManagerError('oidc.client.config') } // @TODO Add support for any domain (not just registered services) const security = makeSecurityHelper(context) const url = discoveryUrl ?? security.makeUrl(context.cfg.services[cfg.service!], cfg.basePath!) return await client.discovery( new URL(url) as URL, cfg.idOverride ?? ( typeof clientId === 'string' ? clientId : clientId.clientId! ), cfg.secret, undefined, { execute: [ // @TODO Properly handle it with the config client.allowInsecureRequests ] }) }, getClient: async clientId => { let params: string | OidcProviderConfig let descriptor: Configuration | null = null let _clientId: string if (clientId instanceof Configuration) { descriptor = clientId _clientId = params = clientId.serverMetadata().issuer } else if (typeof clientId === 'string') { _clientId = params = clientId } else { params = clientId as OidcProviderConfig _clientId = params.clientId } let metadata = await descriptor?.serverMetadata() // @TODO Do not forget to delete it if (_clientId == null) { throw new AuthManagerError('oidc.client.client-id') } descriptor ??= await service.getConfiguration(params) const cfg = await service.getConfig(params) if (cfg?.secret == null) { throw new AuthManagerError('oidc.client.secert') } metadata ??= await descriptor.serverMetadata() // return new issuer.Client({ client_id: _clientId, client_secret: cfg?.secret }) return { getMetadata: () => metadata, getClientId: () => cfg.idOverride ?? _clientId, getConfig: () => cfg, makeAuthUrl: params => client.buildAuthorizationUrl(descriptor, params).toString(), grantWithCredentials: async () => client.clientCredentialsGrant(descriptor) as Promise, grantWithCode: async (url, checks, params) => { const urlObj = new URL(url) Object.entries(params).forEach(([key, value]) => { if (key === 'flow') { return } urlObj.searchParams.set(key, value) }) checks.idTokenExpected ??= true return client.authorizationCodeGrant( descriptor, urlObj, checks as AuthorizationCodeGrantChecks ) as Promise }, refresh: async tokenSet => { const refreshToken = typeof tokenSet === 'string' ? tokenSet : tokenSet.refresh_token if (refreshToken == null) { throw new AuthManagerError('refresh-token') } return client.refreshTokenGrant(descriptor, refreshToken) as Promise }, introspect: async (tokenSet, type = 'access_token') => { const tokenValue = type === 'refresh_token' ? tokenSet.refresh_token : type === 'id_token' ? tokenSet.id_token : tokenSet.access_token return client.tokenIntrospection(descriptor, tokenValue as string) as Promise }, userinfo: async (tokenSet, expectedSubject) => { if (tokenSet.access_token == null) { throw new AuthManagerError('access-token') } const claims = await client.fetchUserInfo(descriptor, tokenSet.access_token, expectedSubject) return claims as Record }, } satisfies OidcClientAdapter }, getConfig: async clientId => { const context = assertContext(service.ctx as Context, alias) if (typeof clientId === 'object' && clientId.clientId == null && clientId.service == null) { throw new AuthManagerError('oidc.client.client-id') } return context.cfg.oidc.providers?.find(consumer => { if (typeof clientId === 'string') { return consumer.clientId === clientId } return Object.entries(clientId).every( ([key, value]) => value == null || consumer[key as keyof typeof consumer] === value ) }) }, getDefault: () => { const context = assertContext(service.ctx as Context, alias) return context.cfg.oidc.providers?.find(consumer => consumer.def)?.clientId }, providerApi: () => { const context = assertContext(service.ctx as Context, alias) const cfg = context.cfg.oidc const providerApiService = cfg.providerApiService ?? DEF_OIDC_PROVIDER_API if (context.hasService(providerApiService)) { return context.service(providerApiService) } return null }, accountLinking: () => { const context = assertContext(service.ctx as Context, alias) const cfg = context.cfg.oidc const accountLinkingService = cfg.accountLinkingService ?? DEF_OIDC_ACCOUNT_LINKING if (context.hasService(accountLinkingService)) { return context.service(accountLinkingService) } return null }, findProvider: predicate => { const context = assertContext(service.ctx as Context, alias) return (context.cfg.oidc.providers ?? []).find(predicate) }, hasProvider: params => { const ctx = service.assertCtx() if (ctx.cfg.oidc.providers == null) { ctx.cfg.oidc.providers = [] } return ctx.cfg.oidc.providers.some(provider => { if (typeof params === 'string') { return provider.clientId === params } return Object.entries(params).every( ([key, value]) => provider[key as keyof typeof provider] === value ) }) }, registerTemporaryProvider: config => { const ctx = service.assertCtx() if (ctx.cfg.oidc.providers == null) { ctx.cfg.oidc.providers = [] } const providers = ctx.cfg.oidc.providers as TemporaryConfig[] let provider = providers.find(provider => matchCriteria.every( key => provider[key as keyof typeof provider] === config[key as keyof typeof config] )) if (provider == null) { provider = { ...config, [_configFlag]: 0 } providers.push(provider) } else { Object.assign(provider, config) provider[_configFlag] ??= 0 provider[_configFlag]++ } return provider }, entityToClientId: params => { const ctx = service.assertCtx() if (ctx.cfg.oidc.providers == null) { ctx.cfg.oidc.providers = [] } const provider = ctx.cfg.oidc.providers.find( provider => Object.entries(params).every( ([key, value]) => provider[key as keyof typeof provider] === value ) ) if (provider == null) { throw new AuthManagerError('oidc.client.entity-id') } return provider.clientId }, unregisterTemporaryProvider: params => { const ctx = service.assertCtx() if (ctx.cfg.oidc.providers == null) { ctx.cfg.oidc.providers = [] } const providers = ctx.cfg.oidc.providers as TemporaryConfig[] const idx = providers.findIndex( provider => matchCriteria.every( key => !(key in params) || provider[key as keyof typeof provider] === params[key as keyof typeof params] ) ) if (idx < 0) { return } const provider = providers[idx] if (--provider[_configFlag] < 1) { providers.splice(idx, 1) } } }) return service }