Skip to content

src/tokens.ts

91 lines · 3.2 KB · TypeScript
Raw
/** 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<DeviceRecord, 'locale' | 'withUsername'>): 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<DeviceRecord, 'locale' | 'withUsername'>) {
	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<DeviceRecord> {
	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<DeviceRecord> {
	return accessTokenIsFresh(device) ? device : refreshAccessToken(device)
}

/** Remove this virtual device from the Amazon account. Best effort. */
export async function deregisterDevice(device: DeviceRecord): Promise<boolean> {
	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
	}
}