import type { MaybePromise } from '@fuman/utils' import * as fsp from 'node:fs/promises' import { z } from 'zod' export interface OauthStorage { write: (value: string) => MaybePromise read: () => MaybePromise } export class LocalOauthStorage implements OauthStorage { constructor(private filename: string) {} async write(value: string) { await fsp.writeFile(this.filename, value) } async read() { try { return await fsp.readFile(this.filename, 'utf8') } catch (e) { return null } } } const OauthState = z.object({ accessToken: z.string(), refreshToken: z.string().optional(), expiresAt: z.number(), }) type OauthState = z.infer export class OauthHandler { constructor(private params: { storage: OauthStorage refreshToken: (refreshToken: string) => MaybePromise<{ accessToken: string refreshToken: string expiresIn: number }> /** number of milliseconds to subtract from token expiration time */ jitter?: number }) { this.params.jitter = this.params.jitter ?? 5000 } #cache: OauthState | null = null async readOauthState() { if (this.#cache) return this.#cache const value = await this.params.storage.read() if (!value) return null return OauthState.parse(JSON.parse(value)) } async writeOauthState(value: OauthState) { this.#cache = value await this.params.storage.write(JSON.stringify(value)) } async getAccessToken() { const state = await this.readOauthState() if (!state) return null if (state.expiresAt < Date.now() + this.params.jitter!) { if (!state.refreshToken) return null const { accessToken, refreshToken, expiresIn } = await this.params.refreshToken(state.refreshToken) await this.writeOauthState({ accessToken, refreshToken, expiresAt: Date.now() + expiresIn * 1000, }) return accessToken } return state.accessToken } }