Skip to content
← Public packages

@kentcdodds/audible

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

src/register.ts

203 lines · 6.0 KB · TypeScript
/**
 * Audible virtual-device registration + access-token refresh.
 * Ported from mkb79/Audible register.py / auth.py (logic only).
 */

import type { AudibleAuth } from './types.ts'
import { buildClientId, DEVICE_TYPE } from './login.ts'
import type { LocaleInfo } from './locales.ts'

export type RegisterResult = AudibleAuth & {
	with_username?: boolean
}

export async function registerDevice(input: {
	authorizationCode: string
	codeVerifier: string
	locale: LocaleInfo
	serial: string
	withUsername?: boolean
}): Promise<RegisterResult> {
	const withUsername = Boolean(input.withUsername)
	const domain = input.locale.domain
	const body = {
		requested_token_type: [
			'bearer',
			'mac_dms',
			'website_cookies',
			'store_authentication_cookie',
		],
		cookies: {
			website_cookies: [],
			domain: `.amazon.${domain}`,
		},
		registration_data: {
			domain: 'Device',
			app_version: '3.56.2',
			device_serial: input.serial,
			device_type: DEVICE_TYPE,
			device_name:
				'%FIRST_NAME%%FIRST_NAME_POSSESSIVE_STRING%%DUPE_STRATEGY_1ST%Audible for iPhone',
			os_version: '15.0.0',
			software_version: '35602678',
			device_model: 'iPhone',
			app_name: 'Audible',
		},
		auth_data: {
			client_id: buildClientId(input.serial),
			authorization_code: input.authorizationCode,
			code_verifier: input.codeVerifier,
			code_algorithm: 'SHA-256',
			client_domain: 'DeviceLegacy',
		},
		requested_extensions: ['device_info', 'customer_info'],
	}

	const target = withUsername ? 'audible' : 'amazon'
	const url = `https://api.${target}.${domain}/auth/register`
	const resp = await fetch(url, {
		method: 'POST',
		headers: {
			'Content-Type': 'application/json',
			Accept: 'application/json',
		},
		body: JSON.stringify(body),
	})
	const respJson = (await resp.json().catch(() => null)) as Record<
		string,
		unknown
	> | null
	if (!resp.ok) {
		const message =
			respJson && typeof respJson === 'object'
				? JSON.stringify(summarizeRegisterError(respJson))
				: `HTTP ${resp.status}`
		throw new Error(`Device registration failed: ${message}`)
	}
	if (!respJson) throw new Error('Device registration returned empty body.')

	const success = (
		(respJson.response as Record<string, unknown> | undefined)?.success as
			| Record<string, unknown>
			| undefined
	)
	if (!success) {
		throw new Error('Device registration response missing success payload.')
	}

	const tokens = success.tokens as Record<string, unknown>
	const mac = tokens.mac_dms as Record<string, string>
	const bearer = tokens.bearer as Record<string, unknown>
	const extensions = success.extensions as Record<string, unknown>

	const expiresIn = Number(bearer.expires_in ?? 0)
	const expires = Math.floor(Date.now() / 1000) + expiresIn

	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, '')
			}
		}
	}

	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,
		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: input.locale.countryCode,
		with_username: withUsername,
	}
}

function summarizeRegisterError(body: Record<string, unknown>): unknown {
	// Never echo tokens if Amazon somehow included them in an error payload.
	const clone = { ...body }
	for (const key of Object.keys(clone)) {
		const lower = key.toLowerCase()
		if (
			lower.includes('token') ||
			lower.includes('key') ||
			lower.includes('cookie') ||
			lower.includes('secret')
		) {
			clone[key] = '[redacted]'
		}
	}
	return clone
}

/** Refresh bearer access_token from refresh_token (valid ~60 minutes). */
export async function refreshAccessToken(input: {
	refreshToken: string
	domain: string
	withUsername?: boolean
}): Promise<{ access_token: string; expires: number }> {
	const target = input.withUsername ? 'audible' : 'amazon'
	const url = `https://api.${target}.${input.domain}/auth/token`
	const form = new URLSearchParams({
		app_name: 'Audible',
		app_version: '3.56.2',
		source_token: input.refreshToken,
		requested_token_type: 'access_token',
		source_token_type: 'refresh_token',
	})
	const resp = await fetch(url, {
		method: 'POST',
		headers: {
			'Content-Type': 'application/x-www-form-urlencoded',
			Accept: 'application/json',
		},
		body: form.toString(),
	})
	if (!resp.ok) {
		const text = await resp.text().catch(() => '')
		throw new Error(
			`Access token refresh failed (HTTP ${resp.status})${text ? `: ${text.slice(0, 200)}` : ''}`,
		)
	}
	const data = (await resp.json()) as {
		access_token?: string
		expires_in?: number | string
	}
	if (typeof data.access_token !== 'string') {
		throw new Error('Refresh response missing access_token.')
	}
	const expiresIn = Number(data.expires_in ?? 3600)
	return {
		access_token: data.access_token,
		expires: Math.floor(Date.now() / 1000) + expiresIn,
	}
}

/** audible-cli-compatible auth JSON (no secrets echoed to callers). */
export function toAudibleCliAuthJson(auth: RegisterResult): string {
	const payload = {
		adp_token: auth.adp_token,
		device_private_key: auth.device_private_key,
		access_token: auth.access_token,
		refresh_token: auth.refresh_token,
		expires: auth.expires,
		website_cookies: auth.website_cookies,
		store_authentication_cookie: auth.store_authentication_cookie,
		device_info: auth.device_info,
		customer_info: auth.customer_info,
		locale_code: auth.locale,
		locale: auth.locale,
		with_username: auth.with_username ?? false,
	}
	return JSON.stringify(payload, null, 2)
}