import { SB1_FIN_INST, SB1_ID, SB1_REDIRECT_URI, SB1_SECRET } from "$env/static/private"; import { eq } from "drizzle-orm"; import { Temporal } from "temporal-polyfill"; import { randomUUID } from "node:crypto"; import { db } from "./db"; import { syncSession } from "./db/schema"; 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 } export type Transaction = { id: string nonUniqueId: string description: string cleanedDescription: string accountNumber: AccountNumber amount: number date: number interestDate: number typeCode: string typeText: string currencyCode: string canShowDetails: boolean source: string isConfidential: boolean bookingStatus: string accountName: string accountKey: string accountCurrency: string isFromCurrencyAccount: boolean classificationInput: ClassificationInput } export type AccountNumber = { value: string formatted: string unformatted: string } export type ClassificationInput = { id: string amount: number type: string text: string date: string } const auth = { async is_ready(): Promise { const token = await this.get_access_token() return token !== "" }, async get_auth_info() { 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] if (!tokens) return undefined const tokensParsed = JSON.parse(tokens) if (!tokensParsed) return undefined const refreshTokenExpires = Temporal.Instant.fromEpochMilliseconds(Number(refreshTokenCreated)).add({ seconds: tokensParsed?.refresh_token_expires_in }) const accessTokenExpires = Temporal.Instant.fromEpochMilliseconds(Number(accessTokenCreated)).add({ seconds: tokensParsed?.expires_in }) return { refreshTokenExpires, accessTokenExpires } }, async init_auth_session(): Promise { 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() }, async get_access_token() { const result = await db.select({ tokens: syncSession.tokens, refreshTokenCreated: syncSession.refreshTokenCreated, accessTokenCreated: syncSession.accessTokenCreated }).from(syncSession) const { tokens: _tokens, accessTokenCreated, refreshTokenCreated } = result[0] let tokens = JSON.parse(_tokens ?? "") as Sb1Tokens const nowInstant = Temporal.Now.instant() const accessTokenExpiredInstant = Temporal.Instant.fromEpochMilliseconds(accessTokenCreated ?? 0).add({ seconds: tokens.expires_in }) if (Temporal.Instant.compare(nowInstant, accessTokenExpiredInstant) >= 0) { const refreshedTokens = await this.refresh_token() if (refreshedTokens) return refreshedTokens.access_token } const refreshTokenExpiredInstant = Temporal.Instant.fromEpochMilliseconds(refreshTokenCreated ?? 0).add({ seconds: tokens.refresh_token_expires_in }) if (Temporal.Instant.compare(nowInstant, refreshTokenExpiredInstant) >= 0) { return undefined } return tokens?.access_token as string }, async refresh_token(): Promise { console.log("Refreshing tokens") 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) as Sb1Tokens if (!parsed.refresh_token) throw new Error("No refresh token"); const params = new URLSearchParams(); params.set("client_id", SB1_ID); params.set("client_secret", SB1_SECRET); params.set("refresh_token", parsed.refresh_token); params.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: params, }); const text = await res.text() const epoch = Temporal.Now.instant().epochMilliseconds if (res.ok) { await db.update(syncSession).set({ tokens: text, accessTokenCreated: epoch, refreshTokenCreated: epoch }).where(eq(syncSession.id, id)) return JSON.parse(text) as Sb1Tokens } else { console.error("Failed to refresh tokens", text) return null } } } const data = { async get_accounts() { const token = await auth.get_access_token() if (!token) return undefined const url = new URL( "https://api.sparebank1.no/personal/banking/accounts", ); const response = await fetch(url, { headers: { Authorization: `Bearer ${token}` } }) if (response.ok) return await response.json() as { accounts: Array } else console.error(await response.text()) }, async get_transactions(accountKey: string) { const token = await auth.get_access_token() if (!token) return undefined const response = await fetch("https://api.sparebank1.no/personal/banking/transactions?" + new URLSearchParams({ "accountKey": accountKey }), { headers: { Authorization: `Bearer ${token}`, }, }); const json = await response.json() console.log(accountKey + ":" + json["transactions"]?.length) return json["transactions"] as Transaction[]; } } export default { auth, data }