diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..075361e --- /dev/null +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 4e1cfd1..7b09ef3 100644 --- a/README.md +++ b/README.md @@ -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 { + try { + const data = await fs.readFile(this.filePath, 'utf8'); + return JSON.parse(data); + } catch { + return undefined; + } + } + + async set(token: OidcToken): Promise { + 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 diff --git a/package.json b/package.json index 065a5cb..175bc92 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "email": "compass@mongodb.com" }, "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" diff --git a/src/api.ts b/src/api.ts index 1ea3f72..f0d5cf5 100644 --- a/src/api.ts +++ b/src/api.ts @@ -7,6 +7,7 @@ import type { MongoDBOIDCLogEventsMap, OIDCAbortSignal, OIDCCallbackFunction, + TokenCache, TypedEventEmitter, } from './types'; import type { RequestOptions } from 'https'; @@ -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 */ diff --git a/src/index.ts b/src/index.ts index bff50e6..9e87f51 100644 --- a/src/index.ts +++ b/src/index.ts @@ -21,6 +21,8 @@ export type { OIDCAbortSignal, MongoDBOIDCError, MongoDBOIDCLogEventsMap, + OidcToken, + TokenCache, } from './types'; export { hookLoggerToMongoLogWriter, MongoLogWriter } from './log-hook'; diff --git a/src/plugin.spec.ts b/src/plugin.spec.ts index c5a4f7c..0c8610d 100644 --- a/src/plugin.spec.ts +++ b/src/plugin.spec.ts @@ -4,7 +4,9 @@ import type { OIDCAbortSignal, IdPServerInfo, OIDCCallbackFunction, + OidcToken, OpenBrowserOptions, + TokenCache, } from './'; import { createMongoDBOIDCPlugin, hookLoggerToMongoLogWriter } from './'; import { once } from 'events'; @@ -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(); + 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 { + 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 { + 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) => Mocha.Func ): void { @@ -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; diff --git a/src/plugin.ts b/src/plugin.ts index c6187cb..c81b17d 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -4,6 +4,7 @@ import type { OIDCCallbackParams, IdPServerInfo, IdPServerResponse, + OidcToken, TypedEventEmitter, } from './types'; import { MongoDBOIDCError } from './types'; @@ -760,6 +761,29 @@ export class MongoDBOIDCPluginImpl implements MongoDBOIDCPlugin { timerDuration, tokenSetId: tokenSet.stableId(), }); + + // Write to external cache if provided + if (this.options.tokenCache) { + const writeToCache = async () => { + try { + const oidcToken = this.tokenSetToOidcToken(tokenSet); + if (this.options.tokenCache) { + await this.options.tokenCache.set(oidcToken); + } + } catch (err: unknown) { + // Log cache write errors but don't fail the authentication + this.logger.emit('mongodb-oidc-plugin:auth-failed', { + authStateId: state.id, + error: `Token cache write failed: ${errorString(err)}`, + }); + } + }; + + // Non-blocking cache write + writeToCache().catch(() => { + // Already handled above, this just prevents unhandled promise rejection + }); + } } static readonly defaultRedirectURI = 'http://localhost:27097/redirect'; @@ -960,6 +984,36 @@ export class MongoDBOIDCPluginImpl implements MongoDBOIDCPlugin { try { get_tokens: { + // Check external token cache first if provided + if (!forceRefreshOrReauth && this.options.tokenCache) { + try { + const cachedToken = await this.options.tokenCache.get(); + if (cachedToken) { + const expiresInMs = cachedToken.expiresAt + ? cachedToken.expiresAt - Date.now() + : Infinity; + const expiresInSeconds = expiresInMs / 1000; + + // Use cached token if it expires in more than 60 seconds + if (!cachedToken.expiresAt || expiresInSeconds > 60) { + const tokenSet = this.oidcTokenToTokenSet(cachedToken); + this.updateStateWithTokenSet(state, tokenSet); + this.logger.emit('mongodb-oidc-plugin:skip-auth-attempt', { + authStateId: state.id, + reason: 'cache-hit', + }); + break get_tokens; + } + } + } catch (err: unknown) { + // Log cache read errors but don't fail authentication + this.logger.emit('mongodb-oidc-plugin:auth-failed', { + authStateId: state.id, + error: `Token cache read failed: ${errorString(err)}`, + }); + } + } + if ( !forceRefreshOrReauth && tokenExpiryInSeconds( @@ -1180,6 +1234,55 @@ export class MongoDBOIDCPluginImpl implements MongoDBOIDCPlugin { } } + /** + * Convert a TokenSet to an OidcToken for external cache storage. + * @param tokenSet The internal TokenSet to convert + * @returns OidcToken for cache storage + */ + private tokenSetToOidcToken(tokenSet: TokenSet): OidcToken { + if (!tokenSet.accessToken) { + throw new MongoDBOIDCError( + 'Cannot cache token set without access token', + { + codeName: 'TokenSetMissingAccessToken', + } + ); + } + + return { + accessToken: tokenSet.accessToken, + refreshToken: tokenSet.refreshToken, + expiresAt: tokenSet.expiresAt ? tokenSet.expiresAt * 1000 : undefined, // Convert to milliseconds + }; + } + + /** + * Convert an OidcToken from external cache to a TokenSet for internal use. + * @param oidcToken The cached token to convert + * @returns TokenSet for internal use + */ + private oidcTokenToTokenSet(oidcToken: OidcToken): TokenSet { + // Create a mock TokenEndpointResponse that satisfies the openid-client types + const mockTokenResponse = { + access_token: oidcToken.accessToken, + token_type: 'bearer' as const, + refresh_token: oidcToken.refreshToken, + id_token: undefined, + }; + + // Use TokenSet.fromSerialized which is designed to handle reconstructed tokens + const serializedFormat = { + ...mockTokenResponse, + claims: undefined, + expiresAt: oidcToken.expiresAt + ? Math.floor(oidcToken.expiresAt / 1000) + : undefined, + expiresIn: undefined, + }; + + return TokenSet.fromSerialized(serializedFormat); + } + // eslint-disable-next-line @typescript-eslint/require-await public async destroy(): Promise { this.destroyed = true; diff --git a/src/types.ts b/src/types.ts index c1b6af8..9217044 100644 --- a/src/types.ts +++ b/src/types.ts @@ -237,3 +237,27 @@ export class MongoDBOIDCError extends Error { ); } } + +/** + * Represents an OIDC token for caching purposes. + * @public + */ +export interface OidcToken { + /** Bearer-token for the "MONGODB-OIDC" auth mech */ + accessToken: string; + /** Optional refresh-token so the plugin can silently renew */ + refreshToken?: string; + /** Absolute epoch-ms when accessToken expires */ + expiresAt?: number; +} + +/** + * Interface for providing external token cache implementations. + * @public + */ +export interface TokenCache { + /** Return a cached token or undefined */ + get(): Promise; + /** Persist the freshly fetched token (non-blocking errors are OK) */ + set(token: OidcToken): Promise; +}