From 7905b580c0d693424e24773027752633bc91415b Mon Sep 17 00:00:00 2001 From: mustofa-id <25121822+mustofa-id@users.noreply.github.com> Date: Tue, 31 Mar 2026 08:00:48 +0700 Subject: [PATCH 1/2] init --- .gitignore | 1 + eslint.config.js | 2 +- package.json | 2 +- pnpm-lock.yaml | 38 +++++++++---------- src/ihs.spec.ts | 27 ++++++++++++-- src/ihs.ts | 96 +++++++++++++++++++++++++++++++++++++----------- 6 files changed, 120 insertions(+), 46 deletions(-) diff --git a/.gitignore b/.gitignore index 603cf13..64c10bd 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ node_modules .env.* !.env.example *.pem +/tmp diff --git a/eslint.config.js b/eslint.config.js index 6993dc2..09d4d5e 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -15,7 +15,7 @@ export default defineConfig( prettier, { languageOptions: { - globals: { ...globals.browser, ...globals.node } + globals: { ...globals.node } }, rules: { // typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects. diff --git a/package.json b/package.json index eac07ac..dc530f2 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,7 @@ "@eslint/compat": "^1.4.0", "@eslint/js": "^9.39.2", "@rollup/plugin-typescript": "^11.1.6", - "@types/node": "^20.19.27", + "@types/node": "^25.5.0", "dts-buddy": "^0.5.5", "eslint": "^9.39.2", "eslint-config-prettier": "^10.1.8", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9a70adc..55c3195 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,7 +12,7 @@ dependencies: devDependencies: '@changesets/cli': specifier: ^2.29.8 - version: 2.29.8(@types/node@20.19.27) + version: 2.29.8(@types/node@25.5.0) '@eslint/compat': specifier: ^1.4.0 version: 1.4.1(eslint@9.39.2) @@ -23,8 +23,8 @@ devDependencies: specifier: ^11.1.6 version: 11.1.6(rollup@4.53.5)(tslib@2.8.1)(typescript@5.7.3) '@types/node': - specifier: ^20.19.27 - version: 20.19.27 + specifier: ^25.5.0 + version: 25.5.0 dts-buddy: specifier: ^0.5.5 version: 0.5.5(typescript@5.7.3) @@ -54,7 +54,7 @@ devDependencies: version: 8.50.0(eslint@9.39.2)(typescript@5.7.3) vitest: specifier: ^4.0.15 - version: 4.0.16(@types/node@20.19.27) + version: 4.0.16(@types/node@25.5.0) packages: @@ -105,7 +105,7 @@ packages: '@changesets/types': 6.1.0 dev: true - /@changesets/cli@2.29.8(@types/node@20.19.27): + /@changesets/cli@2.29.8(@types/node@25.5.0): resolution: {integrity: sha512-1weuGZpP63YWUYjay/E84qqwcnt5yJMM0tep10Up7Q5cS/DGe2IZ0Uj3HNMxGhCINZuR7aO9WBMdKnPit5ZDPA==} hasBin: true dependencies: @@ -123,7 +123,7 @@ packages: '@changesets/should-skip-package': 0.1.2 '@changesets/types': 6.1.0 '@changesets/write': 0.4.0 - '@inquirer/external-editor': 1.0.3(@types/node@20.19.27) + '@inquirer/external-editor': 1.0.3(@types/node@25.5.0) '@manypkg/get-packages': 1.1.3 ansi-colors: 4.1.3 ci-info: 3.9.0 @@ -596,7 +596,7 @@ packages: engines: {node: '>=18.18'} dev: true - /@inquirer/external-editor@1.0.3(@types/node@20.19.27): + /@inquirer/external-editor@1.0.3(@types/node@25.5.0): resolution: {integrity: sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==} engines: {node: '>=18'} peerDependencies: @@ -605,7 +605,7 @@ packages: '@types/node': optional: true dependencies: - '@types/node': 20.19.27 + '@types/node': 25.5.0 chardet: 2.1.1 iconv-lite: 0.7.1 dev: true @@ -938,10 +938,10 @@ packages: resolution: {integrity: sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==} dev: true - /@types/node@20.19.27: - resolution: {integrity: sha512-N2clP5pJhB2YnZJ3PIHFk5RkygRX5WO/5f0WC08tp0wd+sv0rsJk3MqWn3CbNmT2J505a5336jaQj4ph1AdMug==} + /@types/node@25.5.0: + resolution: {integrity: sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==} dependencies: - undici-types: 6.21.0 + undici-types: 7.18.2 dev: true /@typescript-eslint/eslint-plugin@8.50.0(@typescript-eslint/parser@8.50.0)(eslint@9.39.2)(typescript@5.7.3): @@ -1109,7 +1109,7 @@ packages: '@vitest/spy': 4.0.16 estree-walker: 3.0.3 magic-string: 0.30.21 - vite: 7.3.0(@types/node@20.19.27) + vite: 7.3.0(@types/node@25.5.0) dev: true /@vitest/pretty-format@4.0.16: @@ -2332,8 +2332,8 @@ packages: hasBin: true dev: true - /undici-types@6.21.0: - resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + /undici-types@7.18.2: + resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} dev: true /universalify@0.1.2: @@ -2347,7 +2347,7 @@ packages: punycode: 2.3.1 dev: true - /vite@7.3.0(@types/node@20.19.27): + /vite@7.3.0(@types/node@25.5.0): resolution: {integrity: sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true @@ -2387,7 +2387,7 @@ packages: yaml: optional: true dependencies: - '@types/node': 20.19.27 + '@types/node': 25.5.0 esbuild: 0.27.2 fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 @@ -2398,7 +2398,7 @@ packages: fsevents: 2.3.3 dev: true - /vitest@4.0.16(@types/node@20.19.27): + /vitest@4.0.16(@types/node@25.5.0): resolution: {integrity: sha512-E4t7DJ9pESL6E3I8nFjPa4xGUd3PmiWDLsDztS2qXSJWfHtbQnwAWylaBvSNY48I3vr8PTqIZlyK8TE3V3CA4Q==} engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} hasBin: true @@ -2432,7 +2432,7 @@ packages: jsdom: optional: true dependencies: - '@types/node': 20.19.27 + '@types/node': 25.5.0 '@vitest/expect': 4.0.16 '@vitest/mocker': 4.0.16(vite@7.3.0) '@vitest/pretty-format': 4.0.16 @@ -2451,7 +2451,7 @@ packages: tinyexec: 1.0.2 tinyglobby: 0.2.15 tinyrainbow: 3.0.3 - vite: 7.3.0(@types/node@20.19.27) + vite: 7.3.0(@types/node@25.5.0) why-is-node-running: 2.3.0 transitivePeerDependencies: - jiti diff --git a/src/ihs.spec.ts b/src/ihs.spec.ts index f2c29b0..6e19cb7 100644 --- a/src/ihs.spec.ts +++ b/src/ihs.spec.ts @@ -1,5 +1,5 @@ import { beforeAll, describe, expect, it } from 'vitest'; -import IHS, { AuthDetail, DefaultAuthStore, IHSConfig } from './ihs.js'; +import IHS, { AuthDetail, FileAuthStore, IHSConfig, InMemoryAuthStore } from './ihs.js'; let ihs: IHS; @@ -56,8 +56,8 @@ describe('ihs', () => { expect(prodConfig.kycPemFile).toBe('publickey.pem'); }); - it('the DefaultAuthStore should store and handle expiration correctly', async () => { - const store = new DefaultAuthStore(); + it('the InMemoryAuthStore should store and handle expiration correctly', async () => { + const store = new InMemoryAuthStore(); expect(store.get()).toBeFalsy(); const delay = 1; // seconds @@ -76,6 +76,27 @@ describe('ihs', () => { expect(store.get()).toBeUndefined(); }); + it('the FileAuthStore should store and handle expiration correctly', async () => { + const store = new FileAuthStore('./tmp/auth.json'); + expect(await store.get()).toBeFalsy(); + + const delay = 1; // seconds + const expiresIn = 3599; // IHS expiration in seconds as this test written + const issuedAt = Date.now() - (expiresIn - (store.ANTICIPATION + delay)) * 1000; + const authDetail = { + issued_at: String(issuedAt), + expires_in: String(expiresIn), + access_token: 'xyz' + } as AuthDetail; + + await store.set(authDetail); + expect(await store.get()).toBeDefined(); + expect(await store.get()).toEqual(authDetail); + + await new Promise((resolve) => setTimeout(resolve, delay * 1000)); + expect(await store.get()).toBeUndefined(); + }); + it(`request with base type should be return 200`, async () => { const response = await ihs.request({ type: 'base', diff --git a/src/ihs.ts b/src/ihs.ts index a7ae82b..01c1733 100644 --- a/src/ihs.ts +++ b/src/ihs.ts @@ -1,3 +1,5 @@ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; import { getConsentSingleton } from './consent.js'; import { getKFASingleton } from './kfa.js'; import { getKycSingleton } from './kyc.js'; @@ -110,7 +112,7 @@ function buildUrl(base: string, path: string) { export default class IHS { private config: Readonly | undefined; - private currentAuthStore: AuthStore = new DefaultAuthStore(); + private currentAuthStore: AuthStore = new InMemoryAuthStore(); constructor(private readonly userConfig?: UserConfig) {} @@ -141,7 +143,7 @@ export default class IHS { /** * Atur custom auth store untuk menyimpan atau men-cache auth detail. - * Secara default menggunakan {@link DefaultAuthStore}. + * Secara default menggunakan {@link InMemoryAuthStore}. */ set authStore(store: AuthStore) { this.currentAuthStore = store; @@ -239,31 +241,14 @@ export default class IHS { * Implementasi default dari {@link AuthStore} yang menyimpan auth * detail di-cache di cluster/process memory */ -export class DefaultAuthStore implements AuthStore { +export class InMemoryAuthStore implements AuthStore { readonly ANTICIPATION = 300; // seconds private authDetail: AuthDetail | undefined; get(): AuthDetail | undefined { - if (this.authDetail) { - const { issued_at, expires_in } = this.authDetail; - const issuedAt = parseInt(issued_at, 10); - const expiresIn = parseInt(expires_in, 10); - if (!issuedAt || !expiresIn) { - this.authDetail = undefined; - return; - } - - // expiration time in milliseconds - const expirationTime = issuedAt + expiresIn * 1000; - - // time when the token is considered about to expire - const aboutToExpireTime = expirationTime - this.ANTICIPATION * 1000; - - // compare with the current time, if expired set detail to undefined - if (aboutToExpireTime <= Date.now()) { - this.authDetail = undefined; - } + if (isAuthTokenExpired(this.authDetail, this.ANTICIPATION)) { + return undefined; } return this.authDetail; } @@ -273,6 +258,73 @@ export class DefaultAuthStore implements AuthStore { } } +/** + * Implementasi default dari {@link AuthStore} yang menyimpan auth + * detail di sebuah file. Perlu diketahui bahwa file berupa file + * dengan format json yang tidak di-enkripsi + */ +export class FileAuthStore implements AuthStore { + readonly ANTICIPATION = 300; // seconds + private filePath: string; + + constructor(filePath?: string) { + this.filePath = filePath ?? path.join(process.cwd(), 'auth.json'); + } + + async get() { + try { + const content = await fs.readFile(this.filePath, 'utf-8'); + if (!content) return undefined; + + const parsed = JSON.parse(content) as AuthDetail; + if (!parsed.access_token) return undefined; + + if (isAuthTokenExpired(parsed, this.ANTICIPATION)) return undefined; + + return parsed; + } catch (err) { + // file corrupt / JSON invalid / ENOENT + console.warn('[FileAuthStore] Failed to read file:', err); + return undefined; + } + } + + async set(detail: AuthDetail) { + const dir = path.dirname(this.filePath); + + // pastikan folder ada + await fs.mkdir(dir, { recursive: true }); + + const tempPath = this.filePath + '.tmp'; + const data = JSON.stringify(detail, null, 2); + + // write ke file sementara (atomic write pattern) + await fs.writeFile(tempPath, data, 'utf-8'); + + // lalu rename (atomic di sebagian besar OS) + await fs.rename(tempPath, this.filePath); + } +} + +export function isAuthTokenExpired(detail?: AuthDetail, anticipation = 300): boolean { + if (!detail) return true; + + const issuedAt = parseInt(detail.issued_at, 10); + const expiresIn = parseInt(detail.expires_in, 10); + if (!issuedAt || !expiresIn) return true; + + // expiration time in milliseconds + const expirationTime = issuedAt + expiresIn * 1000; + + // time when the token is considered about to expire + const aboutToExpireTime = expirationTime - anticipation * 1000; + + // compare with the current time, if expired set detail to undefined + if (aboutToExpireTime <= Date.now()) return true; + + return false; +} + export interface AuthDetail { refresh_token_expires_in: string; api_product_list: string; From fc3d87621c4efc4bebfedad82ccecd6e319a497a Mon Sep 17 00:00:00 2001 From: mustofa-id <25121822+mustofa-id@users.noreply.github.com> Date: Tue, 31 Mar 2026 09:36:15 +0700 Subject: [PATCH 2/2] secure atomic write --- src/ihs.ts | 54 ++++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 46 insertions(+), 8 deletions(-) diff --git a/src/ihs.ts b/src/ihs.ts index 01c1733..d15de41 100644 --- a/src/ihs.ts +++ b/src/ihs.ts @@ -283,26 +283,64 @@ export class FileAuthStore implements AuthStore { return parsed; } catch (err) { - // file corrupt / JSON invalid / ENOENT - console.warn('[FileAuthStore] Failed to read file:', err); + // file corrupt / JSON invalid + // @ts-expect-error `.code` is actually exists but not in ErrorConstructor + if (err?.code != 'ENOENT') { + console.warn('[FileAuthStore] Failed to read file:', err); + } return undefined; } } async set(detail: AuthDetail) { const dir = path.dirname(this.filePath); - - // pastikan folder ada await fs.mkdir(dir, { recursive: true }); const tempPath = this.filePath + '.tmp'; const data = JSON.stringify(detail, null, 2); - // write ke file sementara (atomic write pattern) - await fs.writeFile(tempPath, data, 'utf-8'); + let fd: fs.FileHandle | undefined; + + try { + // open temp file with secure permissions + fd = await fs.open(tempPath, 'w', 0o600); + + // write data + await fd.writeFile(data, 'utf-8'); + + // flush file contents to disk + await fd.sync(); - // lalu rename (atomic di sebagian besar OS) - await fs.rename(tempPath, this.filePath); + // close before rename (important on Windows) + await fd.close(); + fd = undefined; + + // atomic rename + await fs.rename(tempPath, this.filePath); + + // fsync directory (ensure rename is durable) + const dirFd = await fs.open(dir, 'r'); + try { + await dirFd.sync(); + } finally { + await dirFd.close(); + } + } catch (err) { + // cleanup temp file if anything fails + try { + if (fd) await fd.close(); + } catch { + /** do nothing */ + } + + try { + await fs.unlink(tempPath); + } catch { + /** do nothing */ + } + + throw err; + } } }