diff options
| author | ivar <i@oiee.no> | 2025-12-10 00:17:27 +0100 |
|---|---|---|
| committer | ivar <i@oiee.no> | 2025-12-10 00:17:27 +0100 |
| commit | 57861411f37a07af3cd8fcf1520843e5a5e44bfc (patch) | |
| tree | 86fdb1024fdadfcf6551cbb5da274beb791499d9 /app/src/routes/sb1.remote.ts | |
| download | sparebank1-actualbudget-57861411f37a07af3cd8fcf1520843e5a5e44bfc.tar.xz sparebank1-actualbudget-57861411f37a07af3cd8fcf1520843e5a5e44bfc.zip | |
Initial commit
Diffstat (limited to 'app/src/routes/sb1.remote.ts')
| -rw-r--r-- | app/src/routes/sb1.remote.ts | 184 |
1 files changed, 184 insertions, 0 deletions
diff --git a/app/src/routes/sb1.remote.ts b/app/src/routes/sb1.remote.ts new file mode 100644 index 0000000..3d5763e --- /dev/null +++ b/app/src/routes/sb1.remote.ts @@ -0,0 +1,184 @@ +import { SB1_FIN_INST, SB1_ID, SB1_REDIRECT_URI, SB1_SECRET } from "$env/static/private"; +import { randomUUID } from "node:crypto"; +import { db } from "../lib/server/db"; +import { syncSession } from "../lib/server/db/schema"; +import * as v from "valibot" +import { command, query } from "$app/server"; +import { eq } from "drizzle-orm"; +import { Temporal } from "temporal-polyfill"; + +export const createSb1SyncSessionAndReturnLoginUrl = command(async () => { + return createSb1Auth() +}) + +async function createSb1Auth() { + const state = randomUUID() + + await db.insert(syncSession).values({ + authzState: state + }) + + const authorizeUrl = new URL("https://api.sparebank1.no/oauth/authorize"); + + authorizeUrl.searchParams.set("client_id", SB1_ID); + authorizeUrl.searchParams.set("state", state); + authorizeUrl.searchParams.set("redirect_uri", SB1_REDIRECT_URI); + authorizeUrl.searchParams.set("finInst", SB1_FIN_INST); + authorizeUrl.searchParams.set("response_type", "code"); + + return authorizeUrl.toString() +} + +export const getAccounts = query(async () => { + const token = await getSb1AccessToken() + if (!token) return [] + const url = new URL( + "https://api.sparebank1.no/personal/banking/accounts", + ); + const response = await fetch(url, { + headers: { + Authorization: `Bearer ${token}` + }, + }); + if (response.ok) { + const json = await response.json() + console.log(json) + return json + } + else console.error(await response.text()) +}) + +export const getTransactions = query(v.string(), async (accountKey: string) => { + const token = await getSb1AccessToken() + if (token) return undefined + const url = new URL( + "https://api.sparebank1.no/personal/banking/transactions", + ); + url.searchParams.set("accountKey", accountKey); + console.log(token) + const response = await fetch(url, { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + return (await response.json()) as TransactionsResponse; +}) + +async function getSb1AccessToken() { + const entity = await db.select({ + tokens: syncSession.tokens + }).from(syncSession) + const { tokens } = entity[0] + if (!tokens) return null + const parsed = JSON.parse(tokens) + return parsed.access_token as string +} + +export const clearTokens = query(async () => { + await db.delete(syncSession) +}) + +export const getTokenExpires = query(async () => { + const entity = await db.select({ + refreshTokenCreated: syncSession.refreshTokenCreated, + accessTokenCreated: syncSession.accessTokenCreated, + tokens: syncSession.tokens + }).from(syncSession) + if (!entity[0]) return undefined + const { tokens, accessTokenCreated, refreshTokenCreated } = entity[0] + const tokensParsed = JSON.parse(tokens ?? "") + return { + accessToken: { + expires: tokensParsed?.expires_in ?? 0, + created: Temporal.Instant.fromEpochMilliseconds(Number(accessTokenCreated)) + }, + refreshToken: { + expires: tokensParsed?.refresh_token_expires_in ?? 0, + created: Temporal.Instant.fromEpochMilliseconds(Number(refreshTokenCreated)) + }, + } +}) + +type Sb1Tokens = { + access_token: string, + expires_in: number, + refresh_token_expires_in: number, + refresh_token_absolute_expires_in: number, + token_type: string, + refresh_token: string +} + +type TokenAction = "Empty" + +const auth = { + async ready(): Promise<boolean> { + const token = await this.tokenOrAction() + const ping = await fetch("https://developer.sparebank1.no/helloworld/ping", { + headers: { + "Authorization": "Bearer " + token + } + }) + return ping.ok + }, + async tokenOrAction(): Promise<TokenAction | string> { + const entity = await db.select({ + refreshTokenCreated: syncSession.refreshTokenCreated, + accessTokenCreated: syncSession.accessTokenCreated, + tokens: syncSession.tokens + }).from(syncSession) + const { tokens, accessTokenCreated, refreshTokenCreated } = entity[0] + if (!tokens) return "Empty" + const json = JSON.parse(tokens) as Sb1Tokens + if (!Object.hasOwn(json, "access_token")) return TokenAction.Empty + return json.access_token + }, + getAccessToken() { }, + async getRefreshToken() { + const entity = await db.select({ + tokens: syncSession.tokens, + id: syncSession.id + }).from(syncSession) + + const { tokens, id } = entity[0] + + if (!tokens) return null + + const parsed = JSON.parse(tokens) + + if (!parsed.refresh_token) throw new Error("No refresh token"); + + const fd = new URLSearchParams(); + + fd.set("client_id", SB1_ID); + fd.set("client_secret", SB1_SECRET); + fd.set("refresh_token", parsed.refresh_token); + fd.set("grant_type", "refresh_token"); + + const res = await fetch("https://api.sparebank1.no/oauth/token", { + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + method: "post", + body: fd, + }); + + const text = await res.text(); + const epoch = Temporal.Now.instant().epochMilliseconds + await db.update(syncSession).set({ tokens: text, accessTokenCreated: epoch, refreshTokenCreated: epoch }).where(eq(syncSession.id, id)) + } +} + +export const refreshSB1Token = command(async () => { + auth.getRefreshToken() +}); + +export type Transaction = { + description: string; + amount: number; + date: string; + mcc: string; +} + +export type TransactionsResponse = { + transactions: Array<Transaction>; +} |
