🔑 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:
parent
3bf354b4bf
commit
4a773e4b43
@ -11,13 +11,12 @@ GET {{oauthBaseUrl}}/authorize?client_id={{sparebank1OauthClientId}}&
|
||||
### OAuth2 Access Token Request
|
||||
# Refresh token is valid for 365 days
|
||||
# Access token is valid for 10 minutes
|
||||
@authenticationCode=<insert code here>
|
||||
POST {{oauthBaseUrl}}/token
|
||||
Content-Type: application/x-www-form-urlencoded
|
||||
|
||||
client_id = {{sparebank1OauthClientId}} &
|
||||
client_secret = {{sparebank1OauthClientSecret}} &
|
||||
code = {{authenticationCode}} &
|
||||
code = {{sparebank1OauthAuthCode}} &
|
||||
grant_type = authorization_code &
|
||||
state = {{sparebank1OauthState}} &
|
||||
redirect_uri = {{sparebank1OauthRedirectUri}}
|
||||
@ -43,7 +42,6 @@ grant_type = refresh_token
|
||||
%}
|
||||
|
||||
### Hello World from Sparebank1
|
||||
|
||||
GET https://api.sparebank1.no/common/helloworld
|
||||
Authorization: Bearer {{ACCESS_TOKEN}}
|
||||
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
|
||||
Authorization: Bearer {{ACCESS_TOKEN}}
|
||||
|
||||
### Fetch all transactions of the previous day
|
||||
# TODO date search not working?
|
||||
GET {{bankingBaseUrl}}/transactions?accountKey={{brukskontoAccountKey}}&fromDate=2024-11-14&
|
||||
toDate=2024-11-15
|
||||
### Fetch all transactions of specific days (inclusive)
|
||||
GET {{bankingBaseUrl}}/transactions?accountKey={{brukskontoAccountKey}}&fromDate=2025-01-20&toDate=2025-01-22
|
||||
Authorization: Bearer {{ACCESS_TOKEN}}
|
||||
|
||||
Accept: application/vnd.sparebank1.v1+json; charset=utf-8
|
||||
|
15
package.json
15
package.json
@ -12,25 +12,26 @@
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@actual-app/api": "^24.12.0",
|
||||
"@dotenvx/dotenvx": "^1.31.3",
|
||||
"better-sqlite3": "^11.7.0",
|
||||
"cron": "^3.3.1",
|
||||
"@actual-app/api": "^25.1.0",
|
||||
"@dotenvx/dotenvx": "^1.33.0",
|
||||
"better-sqlite3": "^11.8.1",
|
||||
"cron": "^3.5.0",
|
||||
"dayjs": "^1.11.13",
|
||||
"dotenv": "^16.4.7",
|
||||
"pino": "^9.5.0",
|
||||
"pino": "^9.6.0",
|
||||
"prettier": "^3.4.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@jest/globals": "^29.7.0",
|
||||
"@types/better-sqlite3": "^7.6.12",
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/node": "^22.10.2",
|
||||
"@types/node": "^22.10.7",
|
||||
"jest": "^29.7.0",
|
||||
"pino-pretty": "^13.0.0",
|
||||
"ts-jest": "^29.2.5",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.7.2"
|
||||
"typescript": "^5.7.3"
|
||||
},
|
||||
"prettier": {
|
||||
"semi": false,
|
||||
|
3970
pnpm-lock.yaml
generated
3970
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -1,19 +1,73 @@
|
||||
import { type Database } from "better-sqlite3"
|
||||
import { type OAuthTokenResponse } from "@/bank/sparebank1.ts"
|
||||
import Database from "better-sqlite3"
|
||||
|
||||
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(
|
||||
db: Database,
|
||||
db: Database.Database,
|
||||
oAuthToken: OAuthTokenResponse,
|
||||
): void {
|
||||
db.prepare("INSERT INTO tokens VALUES (?, ?)").run(tokenKey, oAuthToken)
|
||||
}
|
||||
|
||||
export function fetchTokens(db: Database): OAuthTokenResponse | null {
|
||||
return (
|
||||
(db
|
||||
.prepare("SELECT data FROM tokens WHERE key = ?")
|
||||
.get(tokenKey) as OAuthTokenResponse) ?? null
|
||||
insertAccessToken(db, oAuthToken.access_token, oAuthToken.expires_in)
|
||||
insertRefreshToken(
|
||||
db,
|
||||
oAuthToken.refresh_token,
|
||||
oAuthToken.refresh_token_absolute_expires_in,
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
)
|
||||
}
|
||||
|
@ -3,7 +3,7 @@ import { BANK_INITIAL_REFRESH_TOKEN } from "@/../config.ts"
|
||||
import logger from "@/logger.ts"
|
||||
import dayjs from "dayjs"
|
||||
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"
|
||||
|
||||
export interface OAuthTokenResponse {
|
||||
@ -15,16 +15,6 @@ export interface OAuthTokenResponse {
|
||||
refresh_token: string
|
||||
}
|
||||
|
||||
interface AccessToken {
|
||||
access_token: string
|
||||
expires_in: number
|
||||
}
|
||||
|
||||
interface RefreshToken {
|
||||
refresh_token: string
|
||||
expires_in: number
|
||||
}
|
||||
|
||||
export interface Transaction {
|
||||
id: string
|
||||
date: string
|
||||
@ -46,47 +36,39 @@ export interface Sparebank1 {
|
||||
|
||||
export class Sparebank1Impl implements Sparebank1 {
|
||||
private static baseUrl = "https://api.sparebank1.no"
|
||||
private _accessToken: AccessToken | undefined
|
||||
private _refreshToken: RefreshToken | undefined
|
||||
private readonly db: Database
|
||||
|
||||
constructor(db: Database) {
|
||||
this.db = db
|
||||
}
|
||||
|
||||
private set accessToken(accessToken: AccessToken) {
|
||||
this._accessToken = accessToken
|
||||
}
|
||||
|
||||
private set refreshToken(refreshToken: RefreshToken) {
|
||||
this._refreshToken = refreshToken
|
||||
}
|
||||
|
||||
private async getAccessToken(): Promise<string> {
|
||||
const accessToken = this._accessToken
|
||||
if (!accessToken) {
|
||||
const response = await this.fetchNewRefreshToken()
|
||||
return response.access_token
|
||||
const accessToken = fetchToken(this.db, "access-token")
|
||||
|
||||
if (accessToken && this.isValidToken(accessToken)) {
|
||||
return accessToken.token
|
||||
}
|
||||
return accessToken.access_token
|
||||
const response = await this.fetchNewTokens()
|
||||
return response.access_token
|
||||
}
|
||||
|
||||
private isValidToken(tokenResponse: TokenResponse): boolean {
|
||||
return dayjs() < tokenResponse.expires_at
|
||||
}
|
||||
|
||||
private async getRefreshToken(): Promise<string> {
|
||||
const refreshToken = this._refreshToken
|
||||
// TODO check if valid, use jsonwebtoken npm library?
|
||||
const isValid = true
|
||||
if (!refreshToken) {
|
||||
const tokenResponse = fetchToken(this.db, "refresh-token")
|
||||
if (!tokenResponse) {
|
||||
return BANK_INITIAL_REFRESH_TOKEN
|
||||
} else if (isValid) {
|
||||
return refreshToken.refresh_token
|
||||
} else {
|
||||
const response = await this.fetchNewRefreshToken()
|
||||
return response.refresh_token
|
||||
} else if (this.isValidToken(tokenResponse)) {
|
||||
return tokenResponse.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> {
|
||||
const refreshToken: string = await this.getRefreshToken()
|
||||
async fetchNewTokens(): Promise<OAuthTokenResponse> {
|
||||
const refreshToken = await this.getRefreshToken()
|
||||
const result = await Api.refreshToken(refreshToken)
|
||||
|
||||
if (result.status === "failure") {
|
||||
@ -95,15 +77,6 @@ export class Sparebank1Impl implements Sparebank1 {
|
||||
const oAuthToken = result.data
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
|
25
src/main.ts
25
src/main.ts
@ -6,10 +6,15 @@ import {
|
||||
type Transaction,
|
||||
} from "@/bank/sparebank1.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 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 create .cache if missing
|
||||
@ -46,15 +51,21 @@ async function fetchTransactionsFromPastDay(
|
||||
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> {
|
||||
logger.info("Starting application")
|
||||
|
||||
createCacheDirIfMissing()
|
||||
|
||||
const actual = await ActualImpl.init()
|
||||
const databaseFileName = "default.sqlite"
|
||||
const db = new Database(databaseFileName)
|
||||
db.pragma("journal_mode = WAL")
|
||||
db.exec(
|
||||
"CREATE TABLE IF NOT EXISTS tokens (key VARCHAR PRIMARY KEY, data JSON)",
|
||||
)
|
||||
const db = createDb(databaseFileName)
|
||||
logger.info(`Started SQLlite database with filename="${databaseFileName}"`)
|
||||
|
||||
logger.info("Waiting for CRON job to start")
|
||||
|
Loading…
x
Reference in New Issue
Block a user