← 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('')
}