/** Access-token refresh and device deregistration through secret placeholders. */ import { ACCESS_TOKEN_SECRET, REFRESH_TOKEN_SECRET } from './config.ts' import { saveDevice, type DeviceRecord } from './device.ts' import { resolveLocale } from './locales.ts' import { refreshFormBody } from './policy.ts' import { saveSecret, secretRef } from './secrets.ts' const REFRESH_MARGIN_SECONDS = 120 export const ACCESS_TOKEN_DESCRIPTION = 'Audible bearer token. Written by Connect and the hourly refresh.' export const REFRESH_TOKEN_DESCRIPTION = 'Audible refresh token. Written by Connect.' function authHost(device: Pick): string { const { domain } = resolveLocale(device.locale) return `api.${device.withUsername ? 'audible' : 'amazon'}.${domain}` } /** Hosts each secret must be approved for. */ export function requiredHosts(device: Pick) { const { apiHost } = resolveLocale(device.locale) const amazon = authHost(device) return { [ACCESS_TOKEN_SECRET]: [apiHost, amazon], [REFRESH_TOKEN_SECRET]: [amazon], } } export function accessTokenIsFresh(device: DeviceRecord, nowSeconds = Date.now() / 1000): boolean { return device.accessTokenExpiresAt - nowSeconds > REFRESH_MARGIN_SECONDS } /** * Exchange the refresh token for a new access token. Kody fills in the * refresh token; the new access token passes through this code only until * `secretSet` stores it. */ export async function refreshAccessToken(device: DeviceRecord): Promise { const ref = await secretRef(REFRESH_TOKEN_SECRET) const response = await fetch(`https://${authHost(device)}/auth/token`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json', }, body: refreshFormBody(ref), }) if (!response.ok) { throw new Error(`Audible token refresh failed (HTTP ${response.status}).`) } const data = (await response.json()) as { access_token?: unknown; expires_in?: unknown } if (typeof data.access_token !== 'string') { throw new Error('Audible token refresh returned no access token.') } const expiresAt = Math.floor(Date.now() / 1000) + Number(data.expires_in ?? 3600) await saveSecret({ name: ACCESS_TOKEN_SECRET, value: data.access_token, description: ACCESS_TOKEN_DESCRIPTION, expiresAt: new Date(expiresAt * 1000).toISOString(), }) const updated = { ...device, accessTokenExpiresAt: expiresAt } await saveDevice(updated) return updated } export async function freshDevice(device: DeviceRecord): Promise { return accessTokenIsFresh(device) ? device : refreshAccessToken(device) } /** Remove this virtual device from the Amazon account. Best effort. */ export async function deregisterDevice(device: DeviceRecord): Promise { try { const current = await freshDevice(device) const ref = await secretRef(ACCESS_TOKEN_SECRET) const response = await fetch(`https://${authHost(current)}/auth/deregister`, { method: 'POST', headers: { Authorization: `Bearer ${ref}`, 'Content-Type': 'application/json', Accept: 'application/json', }, body: JSON.stringify({ deregister_all_existing_accounts: false }), }) return response.ok } catch { return false } }