aboutsummaryrefslogtreecommitdiffstats
path: root/app/src/routes/sb1.remote.ts
diff options
context:
space:
mode:
authorivar <i@oiee.no>2025-12-10 00:17:27 +0100
committerivar <i@oiee.no>2025-12-10 00:17:27 +0100
commit57861411f37a07af3cd8fcf1520843e5a5e44bfc (patch)
tree86fdb1024fdadfcf6551cbb5da274beb791499d9 /app/src/routes/sb1.remote.ts
downloadsparebank1-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.ts184
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>;
+}