diff options
| author | ivar <i@oiee.no> | 2026-03-09 23:05:38 +0100 |
|---|---|---|
| committer | ivar <i@oiee.no> | 2026-03-09 23:05:38 +0100 |
| commit | 69448e29a85cad3a94b3be3ad33efbc52764528f (patch) | |
| tree | c32b8c817322fdf26edbbb3fa75b9505a7020ae8 /cli/src/commands/auth.ts | |
| parent | b35302fa020ec82a9d67a6cb34379d42983d3cfc (diff) | |
| download | sparebank1-actualbudget-69448e29a85cad3a94b3be3ad33efbc52764528f.tar.xz sparebank1-actualbudget-69448e29a85cad3a94b3be3ad33efbc52764528f.zip | |
Diffstat (limited to 'cli/src/commands/auth.ts')
| -rw-r--r-- | cli/src/commands/auth.ts | 137 |
1 files changed, 137 insertions, 0 deletions
diff --git a/cli/src/commands/auth.ts b/cli/src/commands/auth.ts new file mode 100644 index 0000000..70cbffb --- /dev/null +++ b/cli/src/commands/auth.ts @@ -0,0 +1,137 @@ +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<typeof loadConfig>) { + 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<string> { + 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<Sb1Tokens> { + 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<Sb1Tokens> +} |
