← Public packages
@danj/audible-auth
Locked Audible credential gateway: Connect, token refresh, and narrow Audible calls for other Kody packages.
src/license.ts
112 lines · 3.8 KB · TypeScriptimport { 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,
}
}