aboutsummaryrefslogtreecommitdiffstats
path: root/cli/src/commands/auth.ts
diff options
context:
space:
mode:
Diffstat (limited to 'cli/src/commands/auth.ts')
-rw-r--r--cli/src/commands/auth.ts137
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>
+}