Skip to content

src/complete-auth.ts

98 lines · 3.3 KB · TypeScript
Raw
import { deleteAuthSession, loadAuthSession } from './auth-session.ts'
import { ACCESS_TOKEN_SECRET, ARCHIVE_ENABLED, REFRESH_TOKEN_SECRET } from './config.ts'
import { clearArchiveSigning, loadDevice, saveArchiveSigning, saveDevice } from './device.ts'
import { resolveLocale } from './locales.ts'
import { extractAuthorizationCode } from './login.ts'
import { registerDevice } from './register.ts'
import { saveSecret } from './secrets.ts'
import authStatus from './status.ts'
import { ACCESS_TOKEN_DESCRIPTION, deregisterDevice, REFRESH_TOKEN_DESCRIPTION } from './tokens.ts'

export type CompleteAuthInput = {
	/** Final Amazon maplanding URL that contains openid.oa2.authorization_code. */
	redirectUrl: string
	/** Session id from start-auth. Defaults to the latest session. */
	authSessionId?: string
}

/**
 * Finish Connect: register a virtual Audible device, store its tokens as
 * package secrets, and remove the previous device from the Amazon account.
 * While NAS archive is off, the ADP token and private key are discarded. Never
 * returns credentials.
 *
 * @param input - redirectUrl, and authSessionId from start-auth
 * @returns ok, the previous device's fate, and the public status
 *
 * Internal: only the Connect page (`app.ts`) calls it.
 */
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)
	const session = await loadAuthSession(input.authSessionId)
	if (!session) {
		throw new Error('Unknown or expired login session. Start Connect again.')
	}

	const locale = resolveLocale(session.locale)
	const registered = await registerDevice({
		authorizationCode,
		codeVerifier: session.codeVerifier,
		locale,
		serial: session.serial,
		withUsername: session.withUsername,
	})
	await deleteAuthSession(session.authSessionId)

	// Uses the previous device's secrets, so it must run before they are replaced.
	const previous = await loadDevice()
	const previousDevice = previous
		? (await deregisterDevice(previous))
			? ('removed' as const)
			: ('not-removed' as const)
		: ('none' as const)

	const { packageId } = await saveSecret({
		name: REFRESH_TOKEN_SECRET,
		value: registered.refreshToken,
		description: REFRESH_TOKEN_DESCRIPTION,
		expiresAt: null,
	})
	await saveSecret({
		name: ACCESS_TOKEN_SECRET,
		value: registered.accessToken,
		description: ACCESS_TOKEN_DESCRIPTION,
		expiresAt: new Date(registered.expires * 1000).toISOString(),
	})
	await saveDevice({
		locale: locale.countryCode,
		withUsername: session.withUsername,
		serial: registered.serial,
		deviceType: registered.deviceType,
		customerId: registered.customerId,
		deviceName: registered.deviceName,
		connectedAt: new Date().toISOString(),
		accessTokenExpiresAt: registered.expires,
		...(packageId ? { packageId } : {}),
	})
	if (ARCHIVE_ENABLED) {
		await saveArchiveSigning({
			adpToken: registered.adpToken,
			devicePrivateKey: registered.devicePrivateKey,
		})
	} else {
		await clearArchiveSigning()
	}

	const status = await authStatus()
	return {
		ok: true as const,
		previousDevice,
		status,
		message: status.ready
			? 'Audible connected.'
			: 'Audible connected. Approve the listed hosts before the app can use it.',
	}
}