Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ node_modules
.env.*
!.env.example
*.pem
/tmp
2 changes: 1 addition & 1 deletion eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
38 changes: 19 additions & 19 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

27 changes: 24 additions & 3 deletions src/ihs.spec.ts
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -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
Expand All @@ -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',
Expand Down
132 changes: 111 additions & 21 deletions src/ihs.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -110,7 +112,7 @@ function buildUrl(base: string, path: string) {

export default class IHS {
private config: Readonly<IHSConfig> | undefined;
private currentAuthStore: AuthStore = new DefaultAuthStore();
private currentAuthStore: AuthStore = new InMemoryAuthStore();

constructor(private readonly userConfig?: UserConfig) {}

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -239,40 +241,128 @@ 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;
}
if (isAuthTokenExpired(this.authDetail, this.ANTICIPATION)) {
return undefined;
}
return this.authDetail;
}

// expiration time in milliseconds
const expirationTime = issuedAt + expiresIn * 1000;
set(detail: AuthDetail): void {
this.authDetail = detail;
}
}

// time when the token is considered about to expire
const aboutToExpireTime = expirationTime - this.ANTICIPATION * 1000;
/**
* 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;

// compare with the current time, if expired set detail to undefined
if (aboutToExpireTime <= Date.now()) {
this.authDetail = undefined;
if (isAuthTokenExpired(parsed, this.ANTICIPATION)) return undefined;

return parsed;
} catch (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;
}
return this.authDetail;
}

set(detail: AuthDetail): void {
this.authDetail = detail;
async set(detail: AuthDetail) {
const dir = path.dirname(this.filePath);
await fs.mkdir(dir, { recursive: true });

const tempPath = this.filePath + '.tmp';
const data = JSON.stringify(detail, null, 2);

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();

// 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;
}
}
}

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;
Expand Down