Skip to content
← Public packages

@kentcdodds/audible

Personal Audible library app — browser re-auth, library, wishlist, NAS archive stub.

src/auth.ts

337 lines · 10.8 KB · TypeScript
import { packageSecrets, packageStorage } from 'kody:runtime'
import type { AudibleAuth } from './types.ts'
import { AUTH_RELATED_HOSTS } from './locales.ts'

export const AUDIBLE_AUTH_SECRET = 'audibleAuth'

/** packageStorage key for audible-cli JSON used for ADP signing (never returned from exports). */
export const AUDIBLE_AUTH_STORAGE_KEY = 'audible-auth-v1'

export function audibleAuthSetupUrl(
	packageId?: string,
	scope: 'user' | 'package' = 'user',
): string {
	const hosts = AUTH_RELATED_HOSTS.join(',')
	const params = new URLSearchParams({
		name: AUDIBLE_AUTH_SECRET,
		description:
			'Audible device auth JSON (browser OpenID+PKCE / audible-cli / OpenAudible)',
		allowedHosts: hosts,
		scope,
	})
	if (packageId) params.set('allowedPackages', packageId)
	return `https://kody.codes/account/secrets/new?${params.toString()}`
}

/** True when a mount value is an unresolved `{{secret:…}}` placeholder (opaque; not parseable). */
export function isSecretPlaceholder(value: unknown): boolean {
	if (typeof value !== 'string') return false
	const trimmed = value.trim()
	return /^\{\{\s*secret\s*:/i.test(trimmed)
}

type MountedSecrets = {
	get?: (alias: string) => unknown
	[alias: string]: unknown
} | ((alias: string) => unknown)

async function readMountedSecretRaw(alias: string): Promise<unknown> {
	try {
		const mounts = packageSecrets as MountedSecrets | null
		if (typeof mounts === 'function') {
			return await (mounts as (name: string) => unknown)(alias)
		}
		if (mounts && typeof mounts === 'object') {
			if (typeof mounts.get === 'function') {
				return await mounts.get(alias)
			}
			const direct = mounts[alias]
			if (direct && typeof direct === 'object' && 'value' in direct) {
				return (direct as { value?: unknown }).value
			}
			return direct
		}
		const env = (
			globalThis as { process?: { env?: Record<string, string | undefined> } }
		).process?.env
		return env?.[alias] ?? env?.[alias.toUpperCase()] ?? null
	} catch {
		return null
	}
}

/** Read audible-cli auth JSON from packageStorage (preferred post opaque-secrets). */
export async function readAuthFromPackageStorage(): Promise<string | null> {
	try {
		const raw = await packageStorage().get(AUDIBLE_AUTH_STORAGE_KEY)
		if (raw == null) return null
		if (typeof raw === 'string' && raw.trim()) return raw.trim()
		if (raw && typeof raw === 'object') return JSON.stringify(raw)
	} catch {
		return null
	}
	return null
}

/** Write audible-cli auth JSON to packageStorage. Never log or return the value. */
export async function writeAuthToPackageStorage(authJson: string): Promise<void> {
	await packageStorage().set(AUDIBLE_AUTH_STORAGE_KEY, authJson)
}

/** Normalize OpenAudible credentials.json or audible-cli auth JSON into AudibleAuth. */
export function fromAuthFile(raw: unknown): AudibleAuth {
	if (raw == null) {
		throw new Error('Auth file content is empty.')
	}
	if (isSecretPlaceholder(raw)) {
		throw new Error(
			'Opaque secret placeholder cannot be parsed for ADP signing. Complete Connect re-auth so auth is stored in package storage.',
		)
	}
	const data =
		typeof raw === 'string'
			? (JSON.parse(raw) as Record<string, unknown>)
			: (raw as Record<string, unknown>)

	// OpenAudible multi-device map: { "Device Name": { tokens, extensions, additionnel } }
	const deviceEntries = Object.entries(data).filter(
		([, v]) =>
			v &&
			typeof v === 'object' &&
			'tokens' in (v as object) &&
			'extensions' in (v as object),
	)
	if (deviceEntries.length > 0) {
		const [, first] = deviceEntries[0]!
		return convertOpenAudibleDevice(first as Record<string, unknown>)
	}

	// Single OpenAudible device object
	if (data.tokens && typeof data.tokens === 'object') {
		return convertOpenAudibleDevice(data)
	}

	// Already audible-cli shape
	if (typeof data.adp_token === 'string' && typeof data.device_private_key === 'string') {
		return {
			adp_token: data.adp_token,
			device_private_key: data.device_private_key,
			access_token:
				typeof data.access_token === 'string' ? data.access_token : undefined,
			refresh_token:
				typeof data.refresh_token === 'string' ? data.refresh_token : undefined,
			expires: typeof data.expires === 'number' ? data.expires : undefined,
			website_cookies:
				data.website_cookies && typeof data.website_cookies === 'object'
					? (data.website_cookies as Record<string, string>)
					: undefined,
			store_authentication_cookie: data.store_authentication_cookie as
				| Record<string, unknown>
				| string
				| undefined,
			device_info:
				data.device_info && typeof data.device_info === 'object'
					? (data.device_info as AudibleAuth['device_info'])
					: undefined,
			customer_info:
				data.customer_info && typeof data.customer_info === 'object'
					? (data.customer_info as AudibleAuth['customer_info'])
					: undefined,
			locale:
				typeof data.locale === 'string'
					? data.locale
					: typeof data.country_code === 'string'
						? data.country_code
						: undefined,
		}
	}

	throw new Error(
		'Unrecognized auth JSON. Expected audible-cli auth file (adp_token + device_private_key) or OpenAudible credentials.json.',
	)
}

function convertOpenAudibleDevice(origin: Record<string, unknown>): AudibleAuth {
	const tokens = origin.tokens as Record<string, unknown>
	const mac = tokens.mac_dms as Record<string, string>
	const bearer = tokens.bearer as Record<string, unknown>
	const extensions = origin.extensions as Record<string, unknown>
	const additionnel = (origin.additionnel ?? origin.additional ?? {}) as Record<
		string,
		unknown
	>

	const websiteCookies: Record<string, string> = {}
	const rawCookies = tokens.website_cookies
	if (Array.isArray(rawCookies)) {
		for (const cookie of rawCookies as Array<{ Name?: string; Value?: string }>) {
			if (cookie.Name && cookie.Value != null) {
				websiteCookies[cookie.Name] = String(cookie.Value).replace(/"/g, '')
			}
		}
	} else if (rawCookies && typeof rawCookies === 'object') {
		Object.assign(websiteCookies, rawCookies as Record<string, string>)
	}

	return {
		adp_token: mac.adp_token,
		device_private_key: mac.device_private_key,
		access_token:
			typeof bearer?.access_token === 'string' ? bearer.access_token : undefined,
		refresh_token:
			typeof bearer?.refresh_token === 'string' ? bearer.refresh_token : undefined,
		expires:
			typeof additionnel.expires === 'number'
				? additionnel.expires
				: undefined,
		website_cookies: websiteCookies,
		store_authentication_cookie: tokens.store_authentication_cookie as
			| Record<string, unknown>
			| string
			| undefined,
		device_info: extensions?.device_info as AudibleAuth['device_info'],
		customer_info: extensions?.customer_info as AudibleAuth['customer_info'],
		locale:
			typeof additionnel.region === 'string'
				? String(additionnel.region).toLowerCase()
				: typeof additionnel.locale === 'string'
					? String(additionnel.locale).toLowerCase()
					: undefined,
	}
}

export async function loadAuthFromSecret(
	secretAlias = AUDIBLE_AUTH_SECRET,
): Promise<
	| { ok: true; auth: AudibleAuth; pendingSecret: false }
	| { ok: false; auth: null; pendingSecret: true; setupUrl: string; reason: string }
> {
	const setupUrl = audibleAuthSetupUrl()

	// Prefer packageStorage — opaque secret mounts are placeholders and cannot ADP-sign.
	const stored = await readAuthFromPackageStorage()
	if (stored) {
		try {
			const auth = fromAuthFile(stored)
			if (!auth.adp_token || !auth.device_private_key) {
				return {
					ok: false,
					auth: null,
					pendingSecret: true,
					setupUrl,
					reason:
						'packageStorage audible-auth-v1 is present but missing adp_token or device_private_key. Re-run Connect.',
				}
			}
			return { ok: true, auth, pendingSecret: false }
		} catch (error) {
			return {
				ok: false,
				auth: null,
				pendingSecret: true,
				setupUrl,
				reason: `packageStorage audible-auth-v1 could not be parsed: ${String((error as Error)?.message ?? error)}. Re-run Connect.`,
			}
		}
	}

	const mounted = await readMountedSecretRaw(secretAlias)
	if (mounted != null && isSecretPlaceholder(mounted)) {
		return {
			ok: false,
			auth: null,
			pendingSecret: true,
			setupUrl,
			reason:
				'audibleAuth is an opaque secret placeholder ({{secret:…}}) and cannot be JSON.parsed for ADP signing after the opaque-secrets change. Complete Connect once — auth is stored in package storage for signing. Opaque secrets cannot be migrated.',
		}
	}

	if (mounted != null && typeof mounted === 'string' && mounted.trim() && !isSecretPlaceholder(mounted)) {
		// Legacy readable string (pre-opaque) — try once, then prefer migrating via Connect.
		try {
			const auth = fromAuthFile(mounted.trim())
			if (auth.adp_token && auth.device_private_key) {
				try {
					await writeAuthToPackageStorage(mounted.trim())
				} catch {
					// best-effort migrate into packageStorage
				}
				return { ok: true, auth, pendingSecret: false }
			}
		} catch {
			// fall through
		}
	}

	if (mounted != null && typeof mounted === 'object' && !isSecretPlaceholder(mounted)) {
		try {
			const auth = fromAuthFile(mounted)
			if (auth.adp_token && auth.device_private_key) {
				try {
					await writeAuthToPackageStorage(JSON.stringify(mounted))
				} catch {
					// best-effort
				}
				return { ok: true, auth, pendingSecret: false }
			}
		} catch {
			// fall through
		}
	}

	return {
		ok: false,
		auth: null,
		pendingSecret: true,
		setupUrl,
		reason:
			'No audible auth in package storage (audible-auth-v1). Open the package app Connect tab (or start-auth → complete-auth) once — after opaque secrets, ADP signing keys live in package storage, not in readable secret mounts.',
	}
}

export function accessTokenFreshness(auth: AudibleAuth): {
	hasAccessToken: boolean
	expiresAt: string | null
	expired: boolean | null
	secondsRemaining: number | null
} {
	const hasAccessToken = Boolean(auth.access_token)
	if (typeof auth.expires !== 'number') {
		return {
			hasAccessToken,
			expiresAt: null,
			expired: null,
			secondsRemaining: null,
		}
	}
	const expiresAtMs = auth.expires * 1000
	const secondsRemaining = Math.floor((expiresAtMs - Date.now()) / 1000)
	return {
		hasAccessToken,
		expiresAt: new Date(expiresAtMs).toISOString(),
		expired: secondsRemaining <= 0,
		secondsRemaining,
	}
}

/** Public metadata about auth — never includes token or key material. */
export function authPublicSummary(auth: AudibleAuth) {
	const freshness = accessTokenFreshness(auth)
	return {
		hasAdpToken: Boolean(auth.adp_token),
		hasDevicePrivateKey: Boolean(auth.device_private_key),
		hasRefreshToken: Boolean(auth.refresh_token),
		hasWebsiteCookies: Boolean(
			auth.website_cookies && Object.keys(auth.website_cookies).length > 0,
		),
		hasDeviceInfo: Boolean(auth.device_info?.device_serial_number),
		hasCustomerInfo: Boolean(auth.customer_info?.user_id),
		locale: auth.locale ?? 'us',
		deviceName:
			typeof auth.device_info?.device_name === 'string'
				? auth.device_info.device_name
				: null,
		...freshness,
	}
}