Skip to content
← Public packages

@kentcdodds/audible

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

src/login.ts

140 lines · 4.5 KB · TypeScript
/**
 * Amazon OpenID Authorization Code + PKCE helpers for Audible device login.
 * Ported from mkb79/Audible login.py (logic only — no AGPL Python vendored).
 */

import { resolveLocale, type LocaleInfo } from './locales.ts'

const DEVICE_TYPE = 'A2CZJZGLK2JJVM'
const USER_AGENT =
	'Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148'

export { USER_AGENT, DEVICE_TYPE }

function bytesToBase64Url(bytes: Uint8Array): string {
	let binary = ''
	for (const b of bytes) binary += String.fromCharCode(b)
	return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '')
}

export function createCodeVerifier(length = 32): string {
	const bytes = crypto.getRandomValues(new Uint8Array(length))
	return bytesToBase64Url(bytes)
}

export async function createS256CodeChallenge(verifier: string): Promise<string> {
	const digest = await crypto.subtle.digest(
		'SHA-256',
		new TextEncoder().encode(verifier),
	)
	return bytesToBase64Url(new Uint8Array(digest))
}

export function buildDeviceSerial(): string {
	const bytes = crypto.getRandomValues(new Uint8Array(16))
	return Array.from(bytes, (b) => b.toString(16).padStart(2, '0'))
		.join('')
		.toUpperCase()
}

export function buildClientId(serial: string): string {
	const raw = new TextEncoder().encode(`${serial}#${DEVICE_TYPE}`)
	return Array.from(raw, (b) => b.toString(16).padStart(2, '0')).join('')
}

export type OAuthBuildResult = {
	loginUrl: string
	serial: string
	locale: LocaleInfo
}

/**
 * Build the Amazon (or Audible-username) OpenID+PKCE sign-in URL for an iOS Audible device.
 */
export async function buildOAuthUrl(input: {
	locale?: string | null
	codeVerifier: string
	serial?: string | null
	withUsername?: boolean
}): Promise<OAuthBuildResult> {
	const locale = resolveLocale(input.locale ?? 'us')
	const withUsername = Boolean(input.withUsername)
	if (
		withUsername &&
		!['de', 'com', 'co.uk'].includes(locale.domain.toLowerCase())
	) {
		throw new Error(
			'Login with Audible username is only supported for DE, US, and UK marketplaces.',
		)
	}

	const serial = input.serial?.trim() || buildDeviceSerial()
	const clientId = buildClientId(serial)
	const codeChallenge = await createS256CodeChallenge(input.codeVerifier)
	const country = locale.countryCode

	let baseUrl = `https://www.amazon.${locale.domain}/ap/signin`
	let returnTo = `https://www.amazon.${locale.domain}/ap/maplanding`
	let assocHandle = `amzn_audible_ios_${country}`
	let pageId = 'amzn_audible_ios'

	if (withUsername) {
		baseUrl = `https://www.audible.${locale.domain}/ap/signin`
		returnTo = `https://www.audible.${locale.domain}/ap/maplanding`
		assocHandle = `amzn_audible_ios_lap_${country}`
		pageId = 'amzn_audible_ios_privatepool'
	}

	const params = new URLSearchParams({
		'openid.oa2.response_type': 'code',
		'openid.oa2.code_challenge_method': 'S256',
		'openid.oa2.code_challenge': codeChallenge,
		'openid.return_to': returnTo,
		'openid.assoc_handle': assocHandle,
		'openid.identity': 'http://specs.openid.net/auth/2.0/identifier_select',
		pageId,
		accountStatusPolicy: 'P1',
		'openid.claimed_id': 'http://specs.openid.net/auth/2.0/identifier_select',
		'openid.mode': 'checkid_setup',
		'openid.ns.oa2': 'http://www.amazon.com/ap/ext/oauth/2',
		'openid.oa2.client_id': `device:${clientId}`,
		'openid.ns.pape': 'http://specs.openid.net/extensions/pape/1.0',
		marketPlaceId: locale.marketPlaceId,
		'openid.oa2.scope': 'device_auth_access',
		forceMobileLayout: 'true',
		'openid.ns': 'http://specs.openid.net/auth/2.0',
		'openid.pape.max_auth_age': '0',
	})

	return {
		loginUrl: `${baseUrl}?${params.toString()}`,
		serial,
		locale,
	}
}

/** Extract openid.oa2.authorization_code from the final maplanding (or error) URL. */
export function extractAuthorizationCode(redirectUrl: string): string {
	let url: URL
	try {
		url = new URL(redirectUrl.trim())
	} catch {
		throw new Error(
			'redirectUrl is not a valid URL. Paste the full address-bar URL after Amazon login (…/ap/maplanding?…&openid.oa2.authorization_code=…).',
		)
	}
	const code =
		url.searchParams.get('openid.oa2.authorization_code') ??
		url.searchParams.get('openid.oa2.authorization_code'.toLowerCase())
	if (!code) {
		throw new Error(
			'No openid.oa2.authorization_code in redirectUrl. After Amazon sign-in you should land on a 404/error maplanding page — copy that full URL.',
		)
	}
	return code
}

export function shortAuthSessionId(): string {
	const bytes = crypto.getRandomValues(new Uint8Array(6))
	return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('')
}