1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
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>
}
|