import * as plugins from './plugins.js'; import type { TDelegableGlobalCapability } from '../data/authorization.js'; export const devIdpManifestSchemaVersion = 3 as const; export const devIdpResultSchemaVersion = 1 as const; export const devIdpInternalPort = 2999 as const; export const devIdpDerivedSubjectSentinel = 'derived' as const; export const devIdpPersonaAuthorityProfile = 'platform-personas-v1' as const; export const devIdpPersonaNames = Object.freeze([ 'testuser', 'testmoderator', 'testsuperadmin', ] as const); export const devIdpHostnames = Object.freeze([ 'idp.localhost', 'login.idp.localhost', 'app.idp.localhost', 'superadmin.idp.localhost', ] as const); export const devIdpDegradedDependencies = Object.freeze({ audit: 'ephemeral-memory', platform: 'test-authorization', } as const); export type TDevIdpPersonaName = typeof devIdpPersonaNames[number]; export const devIdpModeratorCapabilities = Object.freeze([ 'global.alert.manage', 'global.alert.read', 'global.user.manage', 'global.user.read', ] as const satisfies readonly TDelegableGlobalCapability[]); export interface IDevIdpPersonaAuthority { readonly isGlobalAdmin: boolean; readonly globalCapabilities: readonly TDelegableGlobalCapability[]; } const devIdpPersonaAuthorities = Object.freeze({ testuser: Object.freeze({ isGlobalAdmin: false, globalCapabilities: Object.freeze([]), }), testmoderator: Object.freeze({ isGlobalAdmin: false, globalCapabilities: devIdpModeratorCapabilities, }), testsuperadmin: Object.freeze({ isGlobalAdmin: true, globalCapabilities: Object.freeze([]), }), } satisfies Record); export interface IDevIdpOrigins { issuer: string; login: string; app: string; superadmin: string; } export interface IDevIdpRpClientManifest { name: string; appUrl: string; redirectUris: string[]; postLogoutRedirectUris: string[]; tlsHostnames: string[]; clientSecretFile: string; } export interface IDevIdpPersonaManifest { name: TDevIdpPersonaName; subject: typeof devIdpDerivedSubjectSentinel; email: string; displayName: string; passwordFile: string; } export interface IDevIdpManifest { schemaVersion: typeof devIdpManifestSchemaVersion; personaAuthorityProfile: typeof devIdpPersonaAuthorityProfile; namespace: string; origins: IDevIdpOrigins; rpClient: IDevIdpRpClientManifest; auditProtectorSecretFile: string; personas: IDevIdpPersonaManifest[]; } export interface IDevIdpResult { schemaVersion: typeof devIdpResultSchemaVersion; namespace: string; manifestSha256: string; ready: true; status: 'ready'; origins: IDevIdpOrigins; clientId: string; personas: Array<{ name: TDevIdpPersonaName; subject: string }>; internalPort: typeof devIdpInternalPort; degraded: typeof devIdpDegradedDependencies; } const fail = (messageArg: string): never => { throw new Error(messageArg); }; const isRecord = (valueArg: unknown): valueArg is Record => ( Boolean(valueArg) && typeof valueArg === 'object' && !Array.isArray(valueArg) ); const assertExactKeys = ( valueArg: unknown, expectedKeysArg: readonly string[], labelArg: string, ): Record => { if (!isRecord(valueArg)) { throw new Error(`${labelArg} must be an object.`); } const actualKeys = Object.keys(valueArg).sort(); const expectedKeys = [...expectedKeysArg].sort(); if ( actualKeys.length !== expectedKeys.length || actualKeys.some((keyArg, indexArg) => keyArg !== expectedKeys[indexArg]) ) { fail(`${labelArg} carries an unexpected field set.`); } return valueArg; }; const requireExactText = ( valueArg: unknown, labelArg: string, maximumLengthArg: number, ): string => { if ( typeof valueArg !== 'string' || valueArg.length === 0 || valueArg.length > maximumLengthArg || valueArg.trim() !== valueArg || /\p{Cc}/u.test(valueArg) ) throw new Error(`${labelArg} is malformed.`); return valueArg; }; const requireArray = ( valueArg: unknown, labelArg: string, minimumLengthArg: number, maximumLengthArg: number, ): unknown[] => { if ( !Array.isArray(valueArg) || valueArg.length < minimumLengthArg || valueArg.length > maximumLengthArg ) throw new Error(`${labelArg} is malformed.`); return valueArg; }; const requireCanonicalAbsoluteFilePath = ( valueArg: unknown, labelArg: string, ): string => { const value = requireExactText(valueArg, labelArg, 4_096); if ( !plugins.path.posix.isAbsolute(value) || plugins.path.posix.normalize(value) !== value ) fail(`${labelArg} must be a canonical absolute path.`); return value; }; const requireCanonicalHttpsOrigin = ( valueArg: unknown, labelArg: string, expectedHostnameArg: string, ): string => { const value = requireExactText(valueArg, labelArg, 2_048); let parsed: URL; try { parsed = new URL(value); } catch { fail(`${labelArg} must be an absolute HTTPS origin.`); } if ( parsed!.protocol !== 'https:' || parsed!.hostname !== expectedHostnameArg || parsed!.port.length === 0 || parsed!.origin !== value || parsed!.username || parsed!.password || parsed!.pathname !== '/' || parsed!.search || parsed!.hash ) fail(`${labelArg} must be the canonical explicit-port HTTPS origin for ${expectedHostnameArg}.`); return value; }; const normalizeOrigins = (originsArg: unknown): IDevIdpOrigins => { const origins = assertExactKeys(originsArg, [ 'issuer', 'login', 'app', 'superadmin', ], 'Development IdP origins'); const normalized: IDevIdpOrigins = { issuer: requireCanonicalHttpsOrigin( origins.issuer, 'Development IdP issuer origin', devIdpHostnames[0], ), login: requireCanonicalHttpsOrigin( origins.login, 'Development IdP login origin', devIdpHostnames[1], ), app: requireCanonicalHttpsOrigin( origins.app, 'Development IdP app origin', devIdpHostnames[2], ), superadmin: requireCanonicalHttpsOrigin( origins.superadmin, 'Development IdP superadmin origin', devIdpHostnames[3], ), }; const originValues = Object.values(normalized); if ( new Set(originValues).size !== originValues.length || new Set(originValues.map((originArg) => new URL(originArg).port)).size !== 1 ) fail('Development IdP origins must be distinct and use one explicit port.'); return normalized; }; const requireCanonicalHttpsUri = ( valueArg: unknown, labelArg: string, ): string => { const value = requireExactText(valueArg, labelArg, 2_048); if (value.includes('*') || value.includes('\\')) { fail(`${labelArg} must be an exact HTTPS URI.`); } let parsed: URL; try { parsed = new URL(value); } catch { fail(`${labelArg} must be an exact HTTPS URI.`); } if ( parsed!.protocol !== 'https:' || !parsed!.hostname || parsed!.username || parsed!.password || parsed!.hash || parsed!.toString() !== value ) fail(`${labelArg} must be a canonical, credential-free, exact HTTPS URI.`); return value; }; const requireUniqueCanonicalUriArray = ( valueArg: unknown, labelArg: string, ): string[] => { const valuesArg = requireArray(valueArg, labelArg, 1, 64); const values = valuesArg.map((entryArg, indexArg) => ( requireCanonicalHttpsUri(entryArg, `${labelArg}[${indexArg}]`) )); if (new Set(values).size !== values.length) { fail(`${labelArg} must not contain duplicates.`); } return values.sort(); }; const requireCanonicalLocalhostHostname = ( valueArg: unknown, labelArg: string, ): string => { const value = requireExactText(valueArg, labelArg, 253); const labels = value.split('.'); const isLocalhostName = value === 'localhost' || ( labels.length >= 2 && labels.at(-1) === 'localhost' ); if ( !isLocalhostName || labels.some((hostnameLabelArg) => ( hostnameLabelArg.length > 63 || !/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(hostnameLabelArg) )) ) fail(`${labelArg} must be localhost or a concrete lowercase .localhost hostname.`); return value; }; const requireSortedUniqueLocalhostHostnameArray = ( valueArg: unknown, labelArg: string, ): string[] => { const valuesArg = requireArray(valueArg, labelArg, 1, 64); const values = valuesArg.map((entryArg, indexArg) => ( requireCanonicalLocalhostHostname(entryArg, `${labelArg}[${indexArg}]`) )); if (new Set(values).size !== values.length) { fail(`${labelArg} must not contain duplicates.`); } const sortedValues = [...values].sort(); if (values.some((valueArg, indexArg) => valueArg !== sortedValues[indexArg])) { fail(`${labelArg} must already be sorted in lexicographic order.`); } return values; }; const sha256Hex = (valueArg: string): string => plugins.crypto .createHash('sha256') .update(valueArg, 'utf8') .digest('hex'); export const normalizeDevIdpNamespace = (namespaceArg: unknown): string => { if ( typeof namespaceArg !== 'string' || !/^[a-z0-9][a-z0-9.-]{2,63}$/.test(namespaceArg) || namespaceArg.endsWith('.') || namespaceArg.includes('..') ) throw new Error('Development IdP namespace is malformed.'); return namespaceArg; }; const isDevIdpPersonaName = (valueArg: unknown): valueArg is TDevIdpPersonaName => ( typeof valueArg === 'string' && devIdpPersonaNames.some((nameArg) => nameArg === valueArg) ); const requireDevIdpPersonaName = ( valueArg: unknown, labelArg: string, ): TDevIdpPersonaName => { if (!isDevIdpPersonaName(valueArg)) throw new Error(`${labelArg} is unsupported.`); return valueArg; }; export const getDevIdpPersonaAuthority = ( personaNameArg: unknown, ): IDevIdpPersonaAuthority => devIdpPersonaAuthorities[ requireDevIdpPersonaName(personaNameArg, 'Development IdP persona name') ]; export const deriveDevIdpNamespaceDigest = (namespaceArg: string): string => { const namespace = normalizeDevIdpNamespace(namespaceArg); return sha256Hex(`idp.global/devidp/namespace/v1\u0000${namespace}`); }; const deriveId = ( namespaceArg: string, purposeArg: string, prefixArg: string, lengthArg = 24, ): string => { const namespaceDigest = deriveDevIdpNamespaceDigest(namespaceArg); return `${prefixArg}${sha256Hex( `idp.global/devidp/id/v1\u0000${namespaceDigest}\u0000${purposeArg}`, ).slice(0, lengthArg)}`; }; export const deriveDevIdpDatabaseName = (namespaceArg: string): string => ( `idp_devidp_${deriveDevIdpNamespaceDigest(namespaceArg).slice(0, 24)}` ); export const deriveDevIdpMongoAlias = (namespaceArg: string): string => ( `devidp-mongo-${deriveDevIdpNamespaceDigest(namespaceArg).slice(0, 12)}` ); export const deriveDevIdpAppId = (namespaceArg: string): string => ( deriveId(namespaceArg, 'rp-client:app', 'devidp-app-') ); export const deriveDevIdpClientId = (namespaceArg: string): string => ( deriveId(namespaceArg, 'rp-client:client', 'devidp-client-') ); export const deriveDevIdpPersonaSubject = ( namespaceArg: string, personaNameArg: TDevIdpPersonaName, ): string => { if (!isDevIdpPersonaName(personaNameArg)) { fail('Development IdP persona name is unsupported.'); } return deriveId(namespaceArg, `persona:${personaNameArg}:subject`, 'devidp-user-'); }; export const normalizeDevIdpManifest = (manifestArg: unknown): IDevIdpManifest => { const manifest = assertExactKeys(manifestArg, [ 'schemaVersion', 'personaAuthorityProfile', 'namespace', 'origins', 'rpClient', 'auditProtectorSecretFile', 'personas', ], 'Development IdP manifest'); if (manifest.schemaVersion !== devIdpManifestSchemaVersion) { fail(`Development IdP manifest schemaVersion must be ${devIdpManifestSchemaVersion}.`); } if (manifest.personaAuthorityProfile !== devIdpPersonaAuthorityProfile) { fail('Development IdP persona authority profile is unsupported.'); } const namespace = normalizeDevIdpNamespace(manifest.namespace); const origins = normalizeOrigins(manifest.origins); const rawRpClient = assertExactKeys(manifest.rpClient, [ 'name', 'appUrl', 'redirectUris', 'postLogoutRedirectUris', 'tlsHostnames', 'clientSecretFile', ], 'Development IdP relying-party client'); const rpClient: IDevIdpRpClientManifest = { name: requireExactText(rawRpClient.name, 'Development IdP client name', 200), appUrl: requireCanonicalHttpsUri( rawRpClient.appUrl, 'Development IdP client appUrl', ), redirectUris: requireUniqueCanonicalUriArray( rawRpClient.redirectUris, 'Development IdP client redirectUris', ), postLogoutRedirectUris: requireUniqueCanonicalUriArray( rawRpClient.postLogoutRedirectUris, 'Development IdP client postLogoutRedirectUris', ), tlsHostnames: requireSortedUniqueLocalhostHostnameArray( rawRpClient.tlsHostnames, 'Development IdP client tlsHostnames', ), clientSecretFile: requireCanonicalAbsoluteFilePath( rawRpClient.clientSecretFile, 'Development IdP client secret file', ), }; const declaredTlsHostnames = new Set(rpClient.tlsHostnames); const rpUris = [ rpClient.appUrl, ...rpClient.redirectUris, ...rpClient.postLogoutRedirectUris, ]; if (rpUris.some((uriArg) => !declaredTlsHostnames.has(new URL(uriArg).hostname))) { fail('Development IdP client tlsHostnames must cover every relying-party URI hostname.'); } const rawPersonas = requireArray( manifest.personas, 'Development IdP manifest personas', 3, 3, ); const emailValidator = new plugins.smartmail.EmailAddressValidator({ skipOnlineDomainFetch: true, }); const personas = rawPersonas.map((personaArg, indexArg) => { const rawPersona = assertExactKeys(personaArg, [ 'name', 'subject', 'email', 'displayName', 'passwordFile', ], `Development IdP persona ${indexArg}`); const nameValue = requireExactText( rawPersona.name, `Development IdP persona ${indexArg} name`, 64, ); const name = requireDevIdpPersonaName(nameValue, 'Development IdP persona name'); if (rawPersona.subject !== devIdpDerivedSubjectSentinel) { fail(`Development IdP persona ${name} subject must be app-derived.`); } const email = requireExactText( rawPersona.email, `Development IdP persona ${name} email`, 320, ); if (email.trim().toLowerCase() !== email || !emailValidator.isValidEmailFormat(email)) { fail(`Development IdP persona ${name} email is not canonical.`); } return { name, subject: devIdpDerivedSubjectSentinel, email, displayName: requireExactText( rawPersona.displayName, `Development IdP persona ${name} displayName`, 200, ), passwordFile: requireCanonicalAbsoluteFilePath( rawPersona.passwordFile, `Development IdP persona ${name} password file`, ), }; }).sort((leftArg, rightArg) => ( devIdpPersonaNames.indexOf(leftArg.name) - devIdpPersonaNames.indexOf(rightArg.name) )); if ( personas.some((personaArg, indexArg) => personaArg.name !== devIdpPersonaNames[indexArg]) || new Set(personas.map((personaArg) => personaArg.email)).size !== personas.length ) fail('Development IdP persona names and emails must be unique.'); const auditProtectorSecretFile = requireCanonicalAbsoluteFilePath( manifest.auditProtectorSecretFile, 'Development IdP audit protector secret file', ); const secretPaths = [ rpClient.clientSecretFile, auditProtectorSecretFile, ...personas.map((personaArg) => personaArg.passwordFile), ]; if (new Set(secretPaths).size !== secretPaths.length) { fail('Development IdP secret file paths must be distinct.'); } return { schemaVersion: devIdpManifestSchemaVersion, personaAuthorityProfile: devIdpPersonaAuthorityProfile, namespace, origins, rpClient, auditProtectorSecretFile, personas, }; }; const canonicalJson = (valueArg: unknown): string => { if (Array.isArray(valueArg)) { return `[${valueArg.map((entryArg) => canonicalJson(entryArg)).join(',')}]`; } if (isRecord(valueArg)) { return `{${Object.keys(valueArg).sort().map((keyArg) => ( `${JSON.stringify(keyArg)}:${canonicalJson(valueArg[keyArg])}` )).join(',')}}`; } const serialized = JSON.stringify(valueArg); if (serialized === undefined) fail('Development IdP value is not canonical JSON.'); return serialized; }; export const canonicalizeDevIdpManifest = (manifestArg: unknown): string => ( canonicalJson(normalizeDevIdpManifest(manifestArg)) ); export const fingerprintDevIdpManifest = (manifestArg: unknown): string => ( sha256Hex(canonicalizeDevIdpManifest(manifestArg)) ); export const normalizeDevIdpResult = (resultArg: unknown): IDevIdpResult => { const result = assertExactKeys(resultArg, [ 'schemaVersion', 'namespace', 'manifestSha256', 'ready', 'status', 'origins', 'clientId', 'personas', 'internalPort', 'degraded', ], 'Development IdP result'); const namespace = normalizeDevIdpNamespace(result.namespace); const origins = normalizeOrigins(result.origins); const manifestSha256 = requireExactText( result.manifestSha256, 'Development IdP result manifest fingerprint', 64, ); const clientId = requireExactText( result.clientId, 'Development IdP result client id', 256, ); if ( result.schemaVersion !== devIdpResultSchemaVersion || !/^[a-f0-9]{64}$/.test(manifestSha256) || result.ready !== true || result.status !== 'ready' || result.internalPort !== devIdpInternalPort || clientId !== deriveDevIdpClientId(namespace) ) fail('Development IdP result is malformed.'); const degraded = assertExactKeys(result.degraded, [ 'audit', 'platform', ], 'Development IdP degraded dependencies'); if ( degraded.audit !== devIdpDegradedDependencies.audit || degraded.platform !== devIdpDegradedDependencies.platform ) fail('Development IdP degraded dependency evidence is malformed.'); const rawPersonas = requireArray( result.personas, 'Development IdP result personas', devIdpPersonaNames.length, devIdpPersonaNames.length, ); const personas = rawPersonas.map((personaArg, indexArg) => { const persona = assertExactKeys( personaArg, ['name', 'subject'], `Development IdP result persona ${indexArg}`, ); const name = requireDevIdpPersonaName( persona.name, 'Development IdP result persona name', ); const subject = requireExactText( persona.subject, `Development IdP result persona ${name} subject`, 256, ); if (subject !== deriveDevIdpPersonaSubject(namespace, name)) { fail(`Development IdP result persona ${name} subject is malformed.`); } return { name, subject }; }).sort((leftArg, rightArg) => ( devIdpPersonaNames.indexOf(leftArg.name) - devIdpPersonaNames.indexOf(rightArg.name) )); if (personas.some((personaArg, indexArg) => personaArg.name !== devIdpPersonaNames[indexArg])) { fail('Development IdP result persona names must be unique.'); } return { schemaVersion: devIdpResultSchemaVersion, namespace, manifestSha256, ready: true, status: 'ready', origins, clientId, personas, internalPort: devIdpInternalPort, degraded: devIdpDegradedDependencies, }; }; export const assertDevIdpResultMatchesManifest = ( resultArg: unknown, manifestArg: unknown, ): IDevIdpResult => { const manifest = normalizeDevIdpManifest(manifestArg); const result = normalizeDevIdpResult(resultArg); const originKeys: Array = ['issuer', 'login', 'app', 'superadmin']; if ( result.namespace !== manifest.namespace || result.manifestSha256 !== fingerprintDevIdpManifest(manifest) || originKeys.some((keyArg) => result.origins[keyArg] !== manifest.origins[keyArg]) ) fail('Development IdP result does not match its manifest.'); return result; };