Skip to content
← Public packages

@kentcdodds/audible

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

src/complete-auth.ts

143 lines · 4.5 KB · TypeScript
/**
 * Finish browser OpenID+PKCE login: exchange code, register device, persist audibleAuth.
 */

import { extractAuthorizationCode } from './login.ts'
import { registerDevice, toAudibleCliAuthJson } from './register.ts'
import { deleteAuthSession, loadAuthSession } from './auth-session.ts'
import { persistAudibleAuth } from './persist-auth.ts'
import { resolveLocale } from './locales.ts'
import { authPublicSummary, AUDIBLE_AUTH_SECRET } from './auth.ts'
import { packageStorage } from 'kody:runtime'

export type CompleteAuthInput = {
	/** Final Amazon maplanding URL containing openid.oa2.authorization_code. */
	redirectUrl: string
	/** Session id from start-auth (preferred). */
	authSessionId?: string
	/** Fallback if authSessionId omitted: code_verifier from a parallel start. */
	codeVerifier?: string
	locale?: string
	serial?: string
	withUsername?: boolean
}

/**
 * Complete browser re-auth: parse the maplanding redirect URL, register a
 * virtual Audible device, and persist audible-cli-compatible auth JSON in
 * packageStorage (`audible-auth-v1`) for ADP signing. Optional best-effort
 * secretSet as `audibleAuth` for host naming only. Never returns secret values.
 *
 * @param input - redirectUrl required; authSessionId from start-auth preferred
 * @returns ok + public authStatus (or setupUrl / reason when persist failed)
 *
 * @example
 * import completeAuth from 'kody:@kentcdodds/audible/complete-auth'
 *
 * const result = await completeAuth({
 *   redirectUrl: 'https://www.amazon.com/ap/maplanding?...',
 *   authSessionId: 'abc123',
 * })
 */
export default async function completeAuth(input: CompleteAuthInput) {
	if (!input?.redirectUrl || typeof input.redirectUrl !== 'string') {
		throw new Error('redirectUrl is required (paste the full maplanding URL).')
	}

	const authorizationCode = extractAuthorizationCode(input.redirectUrl)

	let codeVerifier = input.codeVerifier?.trim() || ''
	let serial = input.serial?.trim() || ''
	let localeCode = input.locale?.trim() || 'us'
	let withUsername = Boolean(input.withUsername)
	let sessionId = input.authSessionId?.trim() || ''

	if (sessionId) {
		const session = await loadAuthSession(sessionId)
		if (!session) {
			throw new Error(
				`Unknown or expired authSessionId "${sessionId}". Call start-auth again and use the new login URL.`,
			)
		}
		codeVerifier = session.codeVerifier
		serial = session.serial
		localeCode = session.locale
		withUsername = session.withUsername
	} else if (!codeVerifier || !serial) {
		// Try latest session pointer if present
		const latest = await loadLatestSessionId()
		if (latest) {
			const session = await loadAuthSession(latest)
			if (session) {
				codeVerifier = session.codeVerifier
				serial = session.serial
				localeCode = session.locale
				withUsername = session.withUsername
				sessionId = latest
			}
		}
	}

	if (!codeVerifier) {
		throw new Error(
			'Missing PKCE code verifier. Pass authSessionId from start-auth (or codeVerifier + serial).',
		)
	}
	if (!serial) {
		throw new Error(
			'Missing device serial. Pass authSessionId from start-auth (or serial).',
		)
	}

	const locale = resolveLocale(localeCode)
	const registered = await registerDevice({
		authorizationCode,
		codeVerifier,
		locale,
		serial,
		withUsername,
	})

	const authJson = toAudibleCliAuthJson(registered)
	const persisted = await persistAudibleAuth(authJson)

	if (sessionId) await deleteAuthSession(sessionId)

	if (!persisted.ok) {
		return {
			ok: false as const,
			secretName: AUDIBLE_AUTH_SECRET,
			authStatus: authPublicSummary(registered),
			setupUrl: persisted.setupUrl,
			hostsToApprove: persisted.hostsToApprove,
			reason: persisted.reason,
			message:
				'Device registered, but packageStorage could not persist audible-auth-v1. Retry complete-auth or open the Connect tab again (do not paste auth JSON into chat).',
		}
	}

	return {
		ok: true as const,
		secretName: persisted.secretName,
		secretScope: persisted.scope,
		authStatus: persisted.authStatus,
		setupUrl: persisted.setupUrl,
		hostsToApprove: persisted.hostsToApprove,
		note: persisted.note,
		message:
			'Audible device registered; ADP signing auth saved in package storage. Call auth-status next; re-auth only if the device is invalidated.',
	}
}

async function loadLatestSessionId(): Promise<string | null> {
	try {
		const raw = await packageStorage().get('auth-session:latest')
		if (typeof raw === 'string' && raw.trim()) return raw.trim()
		if (raw && typeof raw === 'object' && 'authSessionId' in (raw as object)) {
			return String((raw as { authSessionId: string }).authSessionId)
		}
	} catch {
		// ignore
	}
	return null
}