Skip to content

Added caching of tokens #223

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
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
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Changelog

All notable changes to this project will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [2.1.0] - 2025-08-05

### Added

- First-class support for user-supplied token cache via `tokenCache` option
- New `OidcToken` and `TokenCache` interfaces for external token persistence
- Automatic cache consultation before interactive authentication flows
- Cache population after successful token acquisition
- Token sharing between parallel Jest workers and Node.js processes

### Changed

- Enhanced `MongoDBOIDCPluginOptions` interface with optional `tokenCache` property

## [2.0.1] - Previous Release

- See git history for previous changes
53 changes: 53 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,59 @@ const client = await MongoClient.connect(
// ...
```

## Token Caching

The plugin supports external token caching to share OIDC tokens between processes, which is particularly useful for parallel Jest workers or multiple Node.js processes.

### Example: File-based Token Cache

```ts
import { promises as fs } from 'fs';
import {
createMongoDBOIDCPlugin,
TokenCache,
OidcToken,
} from '@mongodb-js/oidc-plugin';

class FileTokenCache implements TokenCache {
constructor(private filePath: string) {}

async get(): Promise<OidcToken | undefined> {
try {
const data = await fs.readFile(this.filePath, 'utf8');
return JSON.parse(data);
} catch {
return undefined;
}
}

async set(token: OidcToken): Promise<void> {
await fs.writeFile(this.filePath, JSON.stringify(token));
}
}

// Use the file-based cache
const plugin = createMongoDBOIDCPlugin({
tokenCache: new FileTokenCache('./oidc-cache.json'),
openBrowser: { command: 'open' },
});

const client = await MongoClient.connect(
'mongodb+srv://.../?authMechanism=MONGODB-OIDC',
{
...plugin.mongoClientOptions,
}
);
```

The plugin will automatically:

- Check the cache for valid tokens before starting interactive authentication
- Store fresh tokens in the cache after successful authentication
- Handle cache errors gracefully without interrupting the authentication flow

**Security Note**: Token caches contain sensitive authentication data. Ensure appropriate file permissions and storage security when implementing persistent caches.

See the TypeScript annotations for more API details.

[mongodb node.js driver]: https://github.com/mongodb/node-mongodb-native
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
"email": "[email protected]"
},
"homepage": "https://github.com/mongodb-js/oidc-plugin",
"version": "2.0.1",
"version": "2.1.0",
"repository": {
"type": "git",
"url": "https://github.com/mongodb-js/oidc-plugin.git"
Expand Down
9 changes: 9 additions & 0 deletions src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type {
MongoDBOIDCLogEventsMap,
OIDCAbortSignal,
OIDCCallbackFunction,
TokenCache,
TypedEventEmitter,
} from './types';
import type { RequestOptions } from 'https';
Expand Down Expand Up @@ -217,6 +218,14 @@ export interface MongoDBOIDCPluginOptions {
* Default is `false`.
*/
skipNonceInAuthCodeRequest?: boolean;

/**
* An optional external token cache for sharing tokens between processes.
* If provided, the plugin will check the cache for valid tokens before
* initiating interactive authentication flows, and will store fresh tokens
* after successful authentication.
*/
tokenCache?: TokenCache;
}

/** @public */
Expand Down
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ export type {
OIDCAbortSignal,
MongoDBOIDCError,
MongoDBOIDCLogEventsMap,
OidcToken,
TokenCache,
} from './types';

export { hookLoggerToMongoLogWriter, MongoLogWriter } from './log-hook';
212 changes: 212 additions & 0 deletions src/plugin.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ import type {
OIDCAbortSignal,
IdPServerInfo,
OIDCCallbackFunction,
OidcToken,
OpenBrowserOptions,
TokenCache,
} from './';
import { createMongoDBOIDCPlugin, hookLoggerToMongoLogWriter } from './';
import { once } from 'events';
Expand Down Expand Up @@ -79,6 +81,47 @@ async function delay(ms: number) {
return await new Promise((resolve) => setTimeout(resolve, ms));
}

// Mock TokenCache implementation for testing
class MockTokenCache implements TokenCache {
private cache = new Map<string, OidcToken>();
private shouldThrowOnGet = false;
private shouldThrowOnSet = false;

setShouldThrowOnGet(shouldThrow: boolean): void {
this.shouldThrowOnGet = shouldThrow;
}

setShouldThrowOnSet(shouldThrow: boolean): void {
this.shouldThrowOnSet = shouldThrow;
}

setToken(key: string, token: OidcToken): void {
this.cache.set(key, token);
}

clear(): void {
this.cache.clear();
}

async get(): Promise<OidcToken | undefined> {
if (this.shouldThrowOnGet) {
throw new Error('Mock cache get error');
}
// For simplicity, return the first cached token (tests typically use one token)
const values = Array.from(this.cache.values());
return Promise.resolve(values.length > 0 ? values[0] : undefined);
}

async set(token: OidcToken): Promise<void> {
if (this.shouldThrowOnSet) {
throw new Error('Mock cache set error');
}
// For simplicity, store with a default key (tests can use setToken for specific keys)
this.cache.set('default', token);
return Promise.resolve();
}
}

function testAuthCodeFlow(
fn: (opts: Partial<MongoDBOIDCPluginOptions>) => Mocha.Func
): void {
Expand Down Expand Up @@ -1235,6 +1278,175 @@ describe('OIDC plugin (local OIDC provider)', function () {
});
});

// eslint-disable-next-line mocha/max-top-level-suites
describe('TokenCache functionality', function () {
this.timeout(90_000);

let plugin: MongoDBOIDCPlugin;
let mockCache: MockTokenCache;
let provider: OIDCMockProvider;
let getTokenPayload: OIDCMockProviderConfig['getTokenPayload'];

const metadata: IdPServerInfo = {
issuer: 'http://localhost:0',
clientId: 'testClient',
};

beforeEach(async function () {
mockCache = new MockTokenCache();

getTokenPayload = () => ({
expires_in: 3600,
payload: {
sub: 'test-user',
aud: metadata.clientId,
exp: Math.floor(Date.now() / 1000) + 3600,
},
});

provider = await OIDCMockProvider.create({
getTokenPayload,
});
metadata.issuer = provider.issuer.toString();
});

afterEach(async function () {
await plugin?.destroy();
await provider?.close();
});

it('uses cached token when available and valid', async function () {
const validToken: OidcToken = {
accessToken: 'cached-access-token',
refreshToken: 'cached-refresh-token',
expiresAt: Date.now() + 120000, // Expires in 2 minutes
};

await mockCache.set(validToken);

plugin = createMongoDBOIDCPlugin({
openBrowser: fetchBrowser,
tokenCache: mockCache,
});

const result = await requestToken(plugin, metadata);

expect(result.accessToken).to.equal('cached-access-token');
expect(result.refreshToken).to.be.a('string');
expect(result.expiresInSeconds).to.equal(0);
});

it('skips cache for expired tokens and performs full authentication', async function () {
const expiredToken: OidcToken = {
accessToken: 'expired-access-token',
refreshToken: 'expired-refresh-token',
expiresAt: Date.now() - 1000, // Expired 1 second ago
};

await mockCache.set(expiredToken);

plugin = createMongoDBOIDCPlugin({
openBrowser: fetchBrowser,
tokenCache: mockCache,
});

const result = await requestToken(plugin, metadata);

// Should get fresh token from provider, not the expired cached one
expect(result.accessToken).to.not.equal('expired-access-token');
expect(result.accessToken).to.be.a('string');
});

it('performs full authentication when cache is empty', async function () {
plugin = createMongoDBOIDCPlugin({
openBrowser: fetchBrowser,
tokenCache: mockCache,
});

const result = await requestToken(plugin, metadata);

expect(result.accessToken).to.be.a('string');
expect(result.refreshToken).to.be.a('string');
});

it('caches tokens after successful authentication', async function () {
plugin = createMongoDBOIDCPlugin({
openBrowser: fetchBrowser,
tokenCache: mockCache,
});

await requestToken(plugin, metadata);

// Wait a bit for async cache write
await delay(100);

const cachedToken = await mockCache.get();
expect(cachedToken).to.not.be.undefined;
if (cachedToken) {
expect(cachedToken.accessToken).to.be.a('string');
}
});

it('continues authentication when cache read fails', async function () {
mockCache.setShouldThrowOnGet(true);

plugin = createMongoDBOIDCPlugin({
openBrowser: fetchBrowser,
tokenCache: mockCache,
});

const result = await requestToken(plugin, metadata);

expect(result.accessToken).to.be.a('string');
expect(result.refreshToken).to.be.a('string');
});

it('continues authentication when cache write fails', async function () {
mockCache.setShouldThrowOnSet(true);

plugin = createMongoDBOIDCPlugin({
openBrowser: fetchBrowser,
tokenCache: mockCache,
});

const result = await requestToken(plugin, metadata);

expect(result.accessToken).to.be.a('string');
expect(result.refreshToken).to.be.a('string');
});

it('maintains backward compatibility when no tokenCache provided', async function () {
plugin = createMongoDBOIDCPlugin({
openBrowser: fetchBrowser,
// No tokenCache provided
});

const result = await requestToken(plugin, metadata);

expect(result.accessToken).to.be.a('string');
expect(result.refreshToken).to.be.a('string');
});

it('uses cached token without expiresAt (assumes valid)', async function () {
const nonExpiringToken: OidcToken = {
accessToken: 'non-expiring-access-token',
refreshToken: 'non-expiring-refresh-token',
// No expiresAt - should be treated as valid
};

await mockCache.set(nonExpiringToken);

plugin = createMongoDBOIDCPlugin({
openBrowser: fetchBrowser,
tokenCache: mockCache,
});

const result = await requestToken(plugin, metadata);

expect(result.accessToken).to.equal('non-expiring-access-token');
});
});

// eslint-disable-next-line mocha/max-top-level-suites
describe('OIDC plugin (mock OIDC provider)', function () {
let provider: OIDCMockProvider;
Expand Down
Loading