🔑 Store tokens in Sqllite, moved queries, other fixes

- Create cache dir if missing
- Moved Sqlite queries to queries.ts
- Updated dependencies
- Added pino-pretty to dev-dependencies
- Changed Sqlite to store tokens as separate rows
- Removed in-memory storage of tokens
- isValidToken function
- Throw Exception if refresh token is present but invalid
- Fixed fetch query in smn http file
This commit is contained in:
Martin Berg Alstad 2025-01-22 21:00:04 +01:00
parent 3bf354b4bf
commit 4a773e4b43
Signed by: martials
GPG Key ID: A3824877B269F2E2
6 changed files with 2326 additions and 1839 deletions

View File

@ -11,13 +11,12 @@ GET {{oauthBaseUrl}}/authorize?client_id={{sparebank1OauthClientId}}&
### OAuth2 Access Token Request ### OAuth2 Access Token Request
# Refresh token is valid for 365 days # Refresh token is valid for 365 days
# Access token is valid for 10 minutes # Access token is valid for 10 minutes
@authenticationCode=<insert code here>
POST {{oauthBaseUrl}}/token POST {{oauthBaseUrl}}/token
Content-Type: application/x-www-form-urlencoded Content-Type: application/x-www-form-urlencoded
client_id = {{sparebank1OauthClientId}} & client_id = {{sparebank1OauthClientId}} &
client_secret = {{sparebank1OauthClientSecret}} & client_secret = {{sparebank1OauthClientSecret}} &
code = {{authenticationCode}} & code = {{sparebank1OauthAuthCode}} &
grant_type = authorization_code & grant_type = authorization_code &
state = {{sparebank1OauthState}} & state = {{sparebank1OauthState}} &
redirect_uri = {{sparebank1OauthRedirectUri}} redirect_uri = {{sparebank1OauthRedirectUri}}
@ -43,7 +42,6 @@ grant_type = refresh_token
%} %}
### Hello World from Sparebank1 ### Hello World from Sparebank1
GET https://api.sparebank1.no/common/helloworld GET https://api.sparebank1.no/common/helloworld
Authorization: Bearer {{ACCESS_TOKEN}} Authorization: Bearer {{ACCESS_TOKEN}}
Accept: application/vnd.sparebank1.v1+json; charset=utf-8 Accept: application/vnd.sparebank1.v1+json; charset=utf-8
@ -52,9 +50,7 @@ Accept: application/vnd.sparebank1.v1+json; charset=utf-8
GET {{bankingBaseUrl}}/accounts GET {{bankingBaseUrl}}/accounts
Authorization: Bearer {{ACCESS_TOKEN}} Authorization: Bearer {{ACCESS_TOKEN}}
### Fetch all transactions of the previous day ### Fetch all transactions of specific days (inclusive)
# TODO date search not working? GET {{bankingBaseUrl}}/transactions?accountKey={{brukskontoAccountKey}}&fromDate=2025-01-20&toDate=2025-01-22
GET {{bankingBaseUrl}}/transactions?accountKey={{brukskontoAccountKey}}&fromDate=2024-11-14&
toDate=2024-11-15
Authorization: Bearer {{ACCESS_TOKEN}} Authorization: Bearer {{ACCESS_TOKEN}}
Accept: application/vnd.sparebank1.v1+json; charset=utf-8

View File

@ -12,25 +12,26 @@
"author": "", "author": "",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@actual-app/api": "^24.12.0", "@actual-app/api": "^25.1.0",
"@dotenvx/dotenvx": "^1.31.3", "@dotenvx/dotenvx": "^1.33.0",
"better-sqlite3": "^11.7.0", "better-sqlite3": "^11.8.1",
"cron": "^3.3.1", "cron": "^3.5.0",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"dotenv": "^16.4.7", "dotenv": "^16.4.7",
"pino": "^9.5.0", "pino": "^9.6.0",
"prettier": "^3.4.2" "prettier": "^3.4.2"
}, },
"devDependencies": { "devDependencies": {
"@jest/globals": "^29.7.0", "@jest/globals": "^29.7.0",
"@types/better-sqlite3": "^7.6.12", "@types/better-sqlite3": "^7.6.12",
"@types/jest": "^29.5.14", "@types/jest": "^29.5.14",
"@types/node": "^22.10.2", "@types/node": "^22.10.7",
"jest": "^29.7.0", "jest": "^29.7.0",
"pino-pretty": "^13.0.0",
"ts-jest": "^29.2.5", "ts-jest": "^29.2.5",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"tsx": "^4.19.2", "tsx": "^4.19.2",
"typescript": "^5.7.2" "typescript": "^5.7.3"
}, },
"prettier": { "prettier": {
"semi": false, "semi": false,

3904
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -1,19 +1,73 @@
import { type Database } from "better-sqlite3" import Database from "better-sqlite3"
import { type OAuthTokenResponse } from "@/bank/sparebank1.ts"
const tokenKey = "sparebank1" import { type OAuthTokenResponse } from "@/bank/sparebank1.ts"
import dayjs, { type Dayjs } from "dayjs"
export type TokenResponse = {
key: TokenKey
token: string
expires_at: Dayjs
}
export type TokenKey = "access-token" | "refresh-token"
export function createDb(filename: string) {
const db = new Database(filename)
db.pragma("journal_mode = WAL")
db.exec(
"CREATE TABLE IF NOT EXISTS tokens ('key' VARCHAR PRIMARY KEY, token VARCHAR NOT NULL, expires_at DATETIME NOT NULL)",
)
return db
}
export function insertTokens( export function insertTokens(
db: Database, db: Database.Database,
oAuthToken: OAuthTokenResponse, oAuthToken: OAuthTokenResponse,
): void { ): void {
db.prepare("INSERT INTO tokens VALUES (?, ?)").run(tokenKey, oAuthToken) insertAccessToken(db, oAuthToken.access_token, oAuthToken.expires_in)
} insertRefreshToken(
db,
export function fetchTokens(db: Database): OAuthTokenResponse | null { oAuthToken.refresh_token,
return ( oAuthToken.refresh_token_absolute_expires_in,
(db )
.prepare("SELECT data FROM tokens WHERE key = ?") }
.get(tokenKey) as OAuthTokenResponse) ?? null
function insertAccessToken(
db: Database.Database,
accessToken: string,
expiresIn: number,
) {
insert(db, "access-token", accessToken, expiresIn)
}
function insertRefreshToken(
db: Database.Database,
refreshToken: string,
expiresIn: number,
) {
insert(db, "refresh-token", refreshToken, expiresIn)
}
function insert(
db: Database.Database,
key: TokenKey,
token: string,
expiresIn: number,
) {
db.prepare("INSERT OR REPLACE INTO tokens VALUES (?, ?, ?)").run(
key,
token,
dayjs().add(expiresIn, "seconds"),
)
}
export function fetchToken(
db: Database.Database,
tokenKey: TokenKey,
): TokenResponse | null {
return (
(db
.prepare("SELECT * FROM tokens WHERE 'key' = ?")
.get(tokenKey) as TokenResponse) ?? null
) )
} }

View File

@ -3,7 +3,7 @@ import { BANK_INITIAL_REFRESH_TOKEN } from "@/../config.ts"
import logger from "@/logger.ts" import logger from "@/logger.ts"
import dayjs from "dayjs" import dayjs from "dayjs"
import { Database } from "better-sqlite3" import { Database } from "better-sqlite3"
import { insertTokens } from "@/bank/db/queries.ts" import { fetchToken, insertTokens, TokenResponse } from "@/bank/db/queries.ts"
import * as Api from "./sparebank1Api.ts" import * as Api from "./sparebank1Api.ts"
export interface OAuthTokenResponse { export interface OAuthTokenResponse {
@ -15,16 +15,6 @@ export interface OAuthTokenResponse {
refresh_token: string refresh_token: string
} }
interface AccessToken {
access_token: string
expires_in: number
}
interface RefreshToken {
refresh_token: string
expires_in: number
}
export interface Transaction { export interface Transaction {
id: string id: string
date: string date: string
@ -46,47 +36,39 @@ export interface Sparebank1 {
export class Sparebank1Impl implements Sparebank1 { export class Sparebank1Impl implements Sparebank1 {
private static baseUrl = "https://api.sparebank1.no" private static baseUrl = "https://api.sparebank1.no"
private _accessToken: AccessToken | undefined
private _refreshToken: RefreshToken | undefined
private readonly db: Database private readonly db: Database
constructor(db: Database) { constructor(db: Database) {
this.db = db this.db = db
} }
private set accessToken(accessToken: AccessToken) {
this._accessToken = accessToken
}
private set refreshToken(refreshToken: RefreshToken) {
this._refreshToken = refreshToken
}
private async getAccessToken(): Promise<string> { private async getAccessToken(): Promise<string> {
const accessToken = this._accessToken const accessToken = fetchToken(this.db, "access-token")
if (!accessToken) {
const response = await this.fetchNewRefreshToken() if (accessToken && this.isValidToken(accessToken)) {
return accessToken.token
}
const response = await this.fetchNewTokens()
return response.access_token return response.access_token
} }
return accessToken.access_token
private isValidToken(tokenResponse: TokenResponse): boolean {
return dayjs() < tokenResponse.expires_at
} }
private async getRefreshToken(): Promise<string> { private async getRefreshToken(): Promise<string> {
const refreshToken = this._refreshToken const tokenResponse = fetchToken(this.db, "refresh-token")
// TODO check if valid, use jsonwebtoken npm library? if (!tokenResponse) {
const isValid = true
if (!refreshToken) {
return BANK_INITIAL_REFRESH_TOKEN return BANK_INITIAL_REFRESH_TOKEN
} else if (isValid) { } else if (this.isValidToken(tokenResponse)) {
return refreshToken.refresh_token return tokenResponse.token
} else {
const response = await this.fetchNewRefreshToken()
return response.refresh_token
} }
// TODO clear database, if refresh token is invalid, will cause Exceptions on each call
throw new Error("Refresh token is expired. Create a new one")
} }
async fetchNewRefreshToken(): Promise<OAuthTokenResponse> { async fetchNewTokens(): Promise<OAuthTokenResponse> {
const refreshToken: string = await this.getRefreshToken() const refreshToken = await this.getRefreshToken()
const result = await Api.refreshToken(refreshToken) const result = await Api.refreshToken(refreshToken)
if (result.status === "failure") { if (result.status === "failure") {
@ -95,15 +77,6 @@ export class Sparebank1Impl implements Sparebank1 {
const oAuthToken = result.data const oAuthToken = result.data
insertTokens(this.db, oAuthToken) insertTokens(this.db, oAuthToken)
this.accessToken = {
access_token: oAuthToken.access_token,
expires_in: oAuthToken.expires_in,
}
this.refreshToken = {
refresh_token: oAuthToken.refresh_token,
expires_in: oAuthToken.refresh_token_expires_in,
}
return oAuthToken return oAuthToken
} }

View File

@ -6,10 +6,15 @@ import {
type Transaction, type Transaction,
} from "@/bank/sparebank1.ts" } from "@/bank/sparebank1.ts"
import { bankTransactionIntoActualTransaction } from "@/mappings.ts" import { bankTransactionIntoActualTransaction } from "@/mappings.ts"
import { ACTUAL_ACCOUNT_IDS, BANK_ACCOUNT_IDS } from "../config.ts" import {
ACTUAL_ACCOUNT_IDS,
ACTUAL_DATA_DIR,
BANK_ACCOUNT_IDS,
} from "../config.ts"
import logger from "@/logger.ts" import logger from "@/logger.ts"
import type { UUID } from "node:crypto" import type { UUID } from "node:crypto"
import Database from "better-sqlite3" import { createDb } from "@/bank/db/queries.ts"
import * as fs from "node:fs"
// TODO Transports api for pino https://github.com/pinojs/pino/blob/HEAD/docs/transports.md // TODO Transports api for pino https://github.com/pinojs/pino/blob/HEAD/docs/transports.md
// TODO create .cache if missing // TODO create .cache if missing
@ -46,15 +51,21 @@ async function fetchTransactionsFromPastDay(
return bank.transactionsPastDay(BANK_ACCOUNT_IDS) return bank.transactionsPastDay(BANK_ACCOUNT_IDS)
} }
function createCacheDirIfMissing(): void {
if (!fs.existsSync(ACTUAL_DATA_DIR)) {
logger.info(`Missing '${ACTUAL_DATA_DIR}', creating...`)
fs.mkdirSync(ACTUAL_DATA_DIR)
}
}
async function main(): Promise<void> { async function main(): Promise<void> {
logger.info("Starting application") logger.info("Starting application")
createCacheDirIfMissing()
const actual = await ActualImpl.init() const actual = await ActualImpl.init()
const databaseFileName = "default.sqlite" const databaseFileName = "default.sqlite"
const db = new Database(databaseFileName) const db = createDb(databaseFileName)
db.pragma("journal_mode = WAL")
db.exec(
"CREATE TABLE IF NOT EXISTS tokens (key VARCHAR PRIMARY KEY, data JSON)",
)
logger.info(`Started SQLlite database with filename="${databaseFileName}"`) logger.info(`Started SQLlite database with filename="${databaseFileName}"`)
logger.info("Waiting for CRON job to start") logger.info("Waiting for CRON job to start")