import { randomUUID } from "node:crypto" import { spawn } from "node:child_process" import { createServer } from "node:http" import * as p from "@clack/prompts" import { Temporal } from "temporal-polyfill" import { loadConfig } from "../config" import { loadTokens, saveTokens } from "../tokens" import { getAccounts } from "../actual" import type { Sb1Tokens } from "../types" export async function auth() { const config = loadConfig() p.intro("Session status") reportSb1Status() await reportActualStatus(config) const confirmed = await p.confirm({ message: "Re-authenticate with Sparebanken 1?" }) if (p.isCancel(confirmed) || !confirmed) { p.outro("Done.") return } const { clientId, clientSecret, finInst } = config.sb1 const redirectUri = "http://localhost:3123/callback" const state = randomUUID() const authorizeUrl = new URL("https://api.sparebank1.no/oauth/authorize") authorizeUrl.searchParams.set("client_id", clientId) authorizeUrl.searchParams.set("state", state) authorizeUrl.searchParams.set("redirect_uri", redirectUri) authorizeUrl.searchParams.set("finInst", finInst) authorizeUrl.searchParams.set("response_type", "code") p.log.info(`If the browser doesn't open, visit:\n ${authorizeUrl}`) spawn("open", [authorizeUrl.toString()], { detached: true, stdio: "ignore" }) const spinner = p.spinner() spinner.start("Waiting for callback...") const code = await waitForCallback(3123, state) const tokens = await exchangeCode(code, clientId, clientSecret, redirectUri) saveTokens(tokens) spinner.stop("SB1 authenticated.") reportSb1Status() await reportActualStatus(config) p.outro("Done.") } function reportSb1Status() { const stored = loadTokens() if (!stored) { p.log.warn("SB1: not authenticated") return } const now = Temporal.Now.instant() const accessExpiry = Temporal.Instant.fromEpochMilliseconds(stored.accessTokenCreated) .add({ seconds: stored.expires_in }) const refreshExpiry = Temporal.Instant.fromEpochMilliseconds(stored.refreshTokenCreated) .add({ seconds: stored.refresh_token_expires_in }) const accessValid = Temporal.Instant.compare(now, accessExpiry) < 0 const refreshValid = Temporal.Instant.compare(now, refreshExpiry) < 0 if (accessValid) { p.log.success(`SB1: access token valid until ${formatInstant(accessExpiry)}`) } else if (refreshValid) { p.log.warn(`SB1: access token expired — refresh token valid until ${formatInstant(refreshExpiry)}`) } else { p.log.error(`SB1: session fully expired — re-authentication required`) } } async function reportActualStatus(config: ReturnType) { try { const accounts = await getAccounts(config.actual) const open = accounts.filter(a => !a.closed) p.log.success(`Actual: connected — ${open.length} open account(s)`) } catch (e: any) { p.log.error(`Actual: cannot connect — ${e.message}`) } } function formatInstant(instant: Temporal.Instant): string { return instant.toZonedDateTimeISO("Europe/Oslo").toPlainDateTime().toString().replace("T", " ").slice(0, 16) } function waitForCallback(port: number, expectedState: string): Promise { return new Promise((resolve, reject) => { const server = createServer((req, res) => { const url = new URL(req.url!, `http://localhost:${port}`) const code = url.searchParams.get("code") const state = url.searchParams.get("state") const error = url.searchParams.get("error") if (error) { res.end("Authentication failed. You can close this tab.") server.close() reject(new Error(`Auth error: ${error}`)) return } if (!code || state !== expectedState) { res.writeHead(400).end("Invalid callback.") return } res.end("Authentication successful! You can close this tab.") server.close() resolve(code) }) server.listen(port) }) } async function exchangeCode(code: string, clientId: string, clientSecret: string, redirectUri: string): Promise { const params = new URLSearchParams({ client_id: clientId, client_secret: clientSecret, code, redirect_uri: redirectUri, grant_type: "authorization_code", }) const res = await fetch("https://api.sparebank1.no/oauth/token", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: params, }) if (!res.ok) throw new Error(`Token exchange failed: ${await res.text()}`) return res.json() as Promise }