Skip to content

src/license.ts

112 lines · 3.8 KB · TypeScript
Raw
import { ARCHIVE_ENABLED } from './config.ts'
import { loadArchiveSigning, requireDevice } from './device.ts'
import { resolveLocale } from './locales.ts'
import { isAsin } from './policy.ts'
import { signRequest } from './sign.ts'
import { decryptVoucher } from './voucher.ts'

export class ArchiveDisabledError extends Error {
	constructor(message: string) {
		super(message)
		this.name = 'ArchiveDisabledError'
	}
}

export type LicenseBody = {
	drm_type?: string
	supported_drm_types?: string[]
	quality?: 'High' | 'Normal'
	consumption_type?: 'Download' | 'Offline' | 'Streaming'
	response_groups?: string
	chapter_titles_type?: string
}

const BODY_KEYS = new Set([
	'drm_type',
	'supported_drm_types',
	'quality',
	'consumption_type',
	'response_groups',
	'chapter_titles_type',
])

/**
 * Request an AAXC content license for one owned ASIN and decrypt its voucher.
 * Throws ArchiveDisabledError while NAS archive is off in this package. The
 * returned voucher holds per-title content keys, not account credentials.
 *
 * @param input - ASIN and the licenserequest body
 * @returns contentLicense (without the encrypted voucher) and the decrypted voucher
 *
 * @example
 * import requestLicense from 'kody:@danj/audible-auth/license'
 *
 * const { contentLicense, voucher } = await requestLicense({ asin, body: { drm_type: 'Adrm' } })
 */
export default async function requestLicense(input: { asin: string; body: LicenseBody }) {
	if (!ARCHIVE_ENABLED) {
		throw new ArchiveDisabledError(
			'NAS archive is off, so Books cannot be saved to the NAS. To turn it on, ask your agent to follow “Turning NAS archive on” in the README of the package that holds your Audible credentials.',
		)
	}
	if (!isAsin(input.asin)) throw new Error('asin must be a 10-character Audible ASIN.')
	for (const key of Object.keys(input.body ?? {})) {
		if (!BODY_KEYS.has(key)) throw new Error(`License body key "${key}" is not allowed.`)
	}
	const device = await requireDevice()
	const signing = await loadArchiveSigning()
	if (!signing) {
		throw new ArchiveDisabledError('NAS archive is on, but this device has no signing key yet. Connect again on the Connect page.')
	}

	const path = `/1.0/content/${input.asin}/licenserequest`
	const bodyText = JSON.stringify(input.body)
	const signed = await signRequest({
		method: 'POST',
		path,
		body: bodyText,
		adpToken: signing.adpToken,
		devicePrivateKey: signing.devicePrivateKey,
	})
	const response = await fetch(`https://${resolveLocale(device.locale).apiHost}${path}`, {
		method: 'POST',
		headers: { Accept: 'application/json', 'Content-Type': 'application/json', ...signed },
		body: bodyText,
	})
	const parsed = (await response.json().catch(() => null)) as {
		content_license?: Record<string, unknown>
	} | null
	if (!response.ok) {
		throw new Error(`Audible licenserequest for ${input.asin} → HTTP ${response.status}`)
	}

	const { license_response: encrypted, ...contentLicense } = parsed?.content_license ?? {}
	let voucher: { key?: string; iv?: string; rules?: unknown } | undefined
	if (typeof encrypted === 'string') {
		try {
			if (!device.customerId) throw new Error('This device has no customer id.')
			voucher = await decryptVoucher({
				deviceType: device.deviceType,
				serial: device.serial,
				customerId: device.customerId,
				asin: String(contentLicense.asin ?? input.asin),
				encryptedVoucher: encrypted,
			})
		} catch (error) {
			voucher = { rules: { decryptError: String((error as Error)?.message ?? error) } }
		}
	} else if (encrypted && typeof encrypted === 'object') {
		const plain = encrypted as Record<string, unknown>
		voucher = {
			key: typeof plain.key === 'string' ? plain.key : undefined,
			iv: typeof plain.iv === 'string' ? plain.iv : undefined,
			rules: plain.rules,
		}
	}
	return {
		contentLicense,
		voucher,
		locale: resolveLocale(device.locale).countryCode,
		apiHost: resolveLocale(device.locale).apiHost,
	}
}