← Public packages
@danj/audible-auth
Locked Audible credential gateway: Connect, token refresh, and narrow Audible calls for other Kody packages.
src/tokens.ts
91 lines · 3.2 KB · TypeScript/** 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
}
}