diff --git a/src/core/oauth-provider.ts b/src/core/oauth-provider.ts index 4f8c6c5..0973e02 100644 --- a/src/core/oauth-provider.ts +++ b/src/core/oauth-provider.ts @@ -1,7 +1,7 @@ type TokenResponse = { access_token: string; - refresh_token: string; - expires_in: number; // en secondes + refresh_token?: string; + expires_in: number; // in secondes token_type: string; }; @@ -10,6 +10,12 @@ type TokenResponse = { * This class provides methods for exchanging an API token for an access token, * refreshing the access token, and ensuring that the access token is valid. * It also provides a method to get the authorization header for API requests. + * + * Token lifecycle follows RFC 6749 §6 and RFC 6750 §3.1: + * - Proactive refresh when token is within the expiry buffer (P3: 120s) + * - On 401 from the resource server, forceRefresh() re-acquires unconditionally + * preferring refresh_token grant over api_token grant (RFC 6749 Fig.2 steps F→G→H) + * - Concurrent callers share a single in-flight acquisition promise (thundering-herd protection) */ export class OAuth2Client { private typeToken: string | null = null; @@ -17,17 +23,23 @@ export class OAuth2Client { private refreshToken: string | null = null; private tokenExpiry: number = 0; // timestamp en ms + /** Deduplicates concurrent token acquisition calls (thundering-herd protection). */ + private pendingToken: Promise | null = null; + /** * Constructor for the OAuth2Client class. * * @param tokenEndpoint - The endpoint for exchanging the API token for an access token. * @param clientId - The client ID for OAuth2 authentication. * @param clientSecret - The client secret for OAuth2 authentication. + * @param refreshEndpoint - Optional separate endpoint for refresh_token grant. + * Defaults to tokenEndpoint when not provided (P4). */ constructor( private readonly tokenEndpoint: string, private readonly clientId: string, private readonly clientSecret: string, + private readonly refreshEndpoint: string = tokenEndpoint, ) {} /** @@ -60,6 +72,35 @@ export class OAuth2Client { return true; } + /** + * Forces an unconditional token re-acquisition, bypassing the expiry check. + * + * Called by the retry middleware on 401 responses from the resource server + * (RFC 6750 §3.1: "The client MAY request a new access token and retry"). + * Clears any cached token state so ensureValidToken() always re-acquires. + * Prefers refresh_token grant over api_token grant (RFC 6749 Fig.2 F→G→H). + */ + async forceRefresh(): Promise { + this.accessToken = null; + this.tokenExpiry = 0; + // Do NOT clear pendingToken here — if multiple concurrent callers invoke + // forceRefresh() simultaneously, the first one creates a new pendingToken + // and all subsequent ones join it, preventing a thundering-herd. + await this.ensureValidToken(); + } + + /** + * Gets the authorization header for API requests. + * + * @returns {Promise} - Returns a promise that resolves to the authorization header. + */ + async getAuthorization(): Promise { + await this.ensureValidToken(); + + //TODO use typeToken. + return `${this.accessToken}`; + } + /** * Stores the token data in the class properties. * @@ -68,10 +109,14 @@ export class OAuth2Client { private storeTokenData(data: TokenResponse): void { this.typeToken = data.token_type; this.accessToken = data.access_token; - this.refreshToken = data.refresh_token; + this.refreshToken = data.refresh_token ?? null; this.tokenExpiry = Date.now() + data.expires_in * 1000; } + /** + * Refreshes the access token using the refresh_token grant (RFC 6749 §6). + * Sends the Authorization: Basic header as required for confidential clients (RFC 6749 §3.2.1). + */ private async refreshAccessToken(): Promise { if (!this.refreshToken) throw new Error('No refresh token available'); @@ -81,9 +126,10 @@ export class OAuth2Client { client_id: this.clientId, }); - const response = await fetch(this.tokenEndpoint, { + const response = await fetch(this.refreshEndpoint, { method: 'POST', headers: { + Authorization: 'Basic ' + btoa('platform-api-user:'), 'Content-Type': 'application/x-www-form-urlencoded', }, body: params.toString(), @@ -96,35 +142,40 @@ export class OAuth2Client { } /** - * Ensures that the access token is valid and refreshes it if necessary. - * - * @returns {Promise} - Returns a promise that resolves when the token is valid. + * Acquires a new token, preferring the refresh_token grant over api_token. + * This is the internal implementation used by ensureValidToken(). */ - private async ensureValidToken(): Promise { - const buffer = 60 * 1000; - if (!this.accessToken || Date.now() > this.tokenExpiry - buffer) { - if (this.refreshToken) { - try { - await this.refreshAccessToken(); - return; - } catch { - // fall back to exchanging a new token when refresh fails - this.accessToken = null; - } + private async doAcquireToken(): Promise { + if (this.refreshToken) { + try { + await this.refreshAccessToken(); + return; + } catch { + // Refresh token is invalid/expired — fall back to full re-exchange + this.accessToken = null; + this.refreshToken = null; } - await this.exchangeCodeForToken(); } + await this.exchangeCodeForToken(); } /** - * Gets the authorization header for API requests. + * Ensures that the access token is valid and refreshes it if necessary. + * Uses a shared in-flight promise to deduplicate concurrent calls (thundering-herd protection). * - * @returns {Promise} - Returns a promise that resolves to the authorization header. + * Buffer is 120s (P3) to account for clock skew between client and server. + * + * @returns {Promise} - Returns a promise that resolves when the token is valid. */ - async getAuthorization(): Promise { - await this.ensureValidToken(); - - //TODO use typeToken. - return `${this.accessToken}`; + private async ensureValidToken(): Promise { + const buffer = 120 * 1000; + if (!this.accessToken || Date.now() > this.tokenExpiry - buffer) { + if (!this.pendingToken) { + this.pendingToken = this.doAcquireToken().finally(() => { + this.pendingToken = null; + }); + } + await this.pendingToken; + } } } diff --git a/src/upsun.ts b/src/upsun.ts index abadbcc..3fff761 100644 --- a/src/upsun.ts +++ b/src/upsun.ts @@ -173,6 +173,7 @@ export class UpsunClient { `${this.upsunConfig.auth_url}/${this.upsunConfig.token_endpoint}`, this.upsunConfig.clientId, this.upsunConfig.apiKey, + `${this.upsunConfig.auth_url}/${this.upsunConfig.refresh_endpoint}`, ); } @@ -318,7 +319,10 @@ export class UpsunClient { if (!this.auth) { return response; } - await this.auth.exchangeCodeForToken(); + // RFC 6750 §3.1: on invalid_token (401), re-acquire unconditionally. + // forceRefresh() prefers refresh_token grant, falls back to api_token + // (RFC 6749 Fig.2 steps F→G→H). Concurrent retries share one request. + await this.auth.forceRefresh(); const token = await this.getToken(); retryInit.headers = { ...this.cloneHeaders(init.headers), diff --git a/tests/unit/core/oauth2-client.test.ts b/tests/unit/core/oauth2-client.test.ts index ce9ab79..34c4866 100644 --- a/tests/unit/core/oauth2-client.test.ts +++ b/tests/unit/core/oauth2-client.test.ts @@ -9,6 +9,11 @@ describe('OAuth2Client', () => { beforeEach(() => { oauth2Client = new OAuth2Client(tokenEndpoint, clientId, apiKey); + nock.cleanAll(); + }); + + afterEach(() => { + nock.cleanAll(); }); describe('exchangeCodeForToken', () => { @@ -26,6 +31,19 @@ describe('OAuth2Client', () => { expect(typeof result).toBe('boolean'); }); + it('should send Authorization: Basic header', async () => { + let capturedAuth: string | undefined; + nock('https://auth.upsun.com') + .post('/oauth2/token') + .reply(function () { + capturedAuth = this.req.headers['authorization'] as string; + return [200, { access_token: 'tok', token_type: 'Bearer', expires_in: 3600 }]; + }); + + await oauth2Client.exchangeCodeForToken(); + expect(capturedAuth).toMatch(/^Basic /); + }); + it('should handle token exchange errors', async () => { nock('https://auth.upsun.com').post('/oauth2/token').reply(400, { error: 'invalid_grant', @@ -44,7 +62,6 @@ describe('OAuth2Client', () => { describe('getAuthorization', () => { it('should return cached access token if valid', async () => { - // First exchange to set token nock('https://auth.upsun.com').post('/oauth2/token').reply(200, { access_token: 'cached-token-123', token_type: 'Bearer', @@ -53,50 +70,186 @@ describe('OAuth2Client', () => { await oauth2Client.exchangeCodeForToken(); - // Should return cached token without new request + // Should return cached token without a new network request const token = await oauth2Client.getAuthorization(); - expect(typeof token).toBe('string'); + expect(token).toBe('cached-token-123'); + expect(nock.pendingMocks()).toHaveLength(0); // no extra AUTH call }); - it('should refresh token if expired', async () => { - // Mock initial token that expires quickly + it('should use refresh_token grant when token is expired (P1)', async () => { + // Acquire initial token with a refresh_token nock('https://auth.upsun.com').post('/oauth2/token').reply(200, { access_token: 'initial-token', token_type: 'Bearer', - expires_in: -1, // Already expired - refresh_token: 'refresh-token', + expires_in: -1, // already expired + refresh_token: 'my-refresh-token', }); + await oauth2Client.exchangeCodeForToken(); + + let capturedBody: string | undefined; + nock('https://auth.upsun.com') + .post('/oauth2/token') + .reply(function (_uri, body) { + capturedBody = body as string; + return [200, { access_token: 'refreshed-token', token_type: 'Bearer', expires_in: 3600 }]; + }); + + const token = await oauth2Client.getAuthorization(); + expect(token).toBe('refreshed-token'); + expect(capturedBody).toContain('grant_type=refresh_token'); + expect(capturedBody).toContain('refresh_token=my-refresh-token'); + }); + + it('should send Authorization: Basic header on refresh_token grant (P2)', async () => { + nock('https://auth.upsun.com').post('/oauth2/token').reply(200, { + access_token: 'initial-token', + token_type: 'Bearer', + expires_in: -1, + refresh_token: 'my-refresh-token', + }); + await oauth2Client.exchangeCodeForToken(); + + let capturedAuth: string | undefined; + nock('https://auth.upsun.com') + .post('/oauth2/token') + .reply(function () { + capturedAuth = this.req.headers['authorization'] as string; + return [200, { access_token: 'refreshed-token', token_type: 'Bearer', expires_in: 3600 }]; + }); + + await oauth2Client.getAuthorization(); + expect(capturedAuth).toMatch(/^Basic /); + }); + + it('should fall back to api_token grant when refresh fails', async () => { + nock('https://auth.upsun.com').post('/oauth2/token').reply(200, { + access_token: 'initial-token', + token_type: 'Bearer', + expires_in: -1, + refresh_token: 'invalid-refresh-token', + }); + await oauth2Client.exchangeCodeForToken(); + + // First call: failed refresh + // Second call: successful api_token exchange + let callCount = 0; + nock('https://auth.upsun.com') + .post('/oauth2/token') + .twice() + .reply(function (_uri, body) { + callCount++; + const b = body as string; + if (b.includes('refresh_token')) return [400, { error: 'invalid_grant' }]; + return [200, { access_token: 'new-token', token_type: 'Bearer', expires_in: 3600 }]; + }); + + const token = await oauth2Client.getAuthorization(); + expect(token).toBe('new-token'); + expect(callCount).toBe(2); + }); + }); + describe('forceRefresh (P1 — RFC 6749 Fig.2 F→G→H)', () => { + it('should re-acquire token even when cached token appears valid', async () => { + // Seed a "valid" token (expires in 1h) + nock('https://auth.upsun.com').post('/oauth2/token').reply(200, { + access_token: 'valid-but-server-revoked', + token_type: 'Bearer', + expires_in: 3600, + }); await oauth2Client.exchangeCodeForToken(); - // Mock refresh token request + // forceRefresh must issue a new AUTH call despite the token appearing valid nock('https://auth.upsun.com').post('/oauth2/token').reply(200, { - access_token: 'refreshed-token', + access_token: 'fresh-token', token_type: 'Bearer', expires_in: 3600, }); + await oauth2Client.forceRefresh(); const token = await oauth2Client.getAuthorization(); - expect(typeof token).toBe('string'); + expect(token).toBe('fresh-token'); }); - it('should handle refresh token errors', async () => { - // Mock expired token + it('should prefer refresh_token grant on forceRefresh when available', async () => { nock('https://auth.upsun.com').post('/oauth2/token').reply(200, { access_token: 'initial-token', token_type: 'Bearer', - expires_in: -1, - refresh_token: 'invalid-refresh-token', + expires_in: 3600, + refresh_token: 'my-refresh-token', }); + await oauth2Client.exchangeCodeForToken(); + + let capturedBody: string | undefined; + nock('https://auth.upsun.com') + .post('/oauth2/token') + .reply(function (_uri, body) { + capturedBody = body as string; + return [200, { access_token: 'force-refreshed', token_type: 'Bearer', expires_in: 3600 }]; + }); + await oauth2Client.forceRefresh(); + expect(capturedBody).toContain('grant_type=refresh_token'); + }); + + it('should deduplicate concurrent forceRefresh calls (thundering-herd)', async () => { + // Seed a valid token to avoid initial exchange + nock('https://auth.upsun.com').post('/oauth2/token').reply(200, { + access_token: 'initial', + token_type: 'Bearer', + expires_in: 3600, + }); await oauth2Client.exchangeCodeForToken(); - // Mock failed refresh - nock('https://auth.upsun.com').post('/oauth2/token').reply(400, { - error: 'invalid_grant', + let authCallCount = 0; + nock('https://auth.upsun.com') + .post('/oauth2/token') + .reply(200, () => { + authCallCount++; + return { access_token: 'deduped-token', token_type: 'Bearer', expires_in: 3600 }; + }); + + // Fire 5 concurrent forceRefresh calls + await Promise.all([ + oauth2Client.forceRefresh(), + oauth2Client.forceRefresh(), + oauth2Client.forceRefresh(), + oauth2Client.forceRefresh(), + oauth2Client.forceRefresh(), + ]); + + expect(authCallCount).toBe(1); + }); + }); + + describe('P4 — separate refresh endpoint', () => { + it('should use refreshEndpoint for refresh_token grant when provided', async () => { + const refreshEndpoint = 'https://auth.upsun.com/oauth2/refresh'; + const clientWithSeparateRefresh = new OAuth2Client( + tokenEndpoint, + clientId, + apiKey, + refreshEndpoint, + ); + + // Initial exchange on tokenEndpoint + nock('https://auth.upsun.com').post('/oauth2/token').reply(200, { + access_token: 'initial-token', + token_type: 'Bearer', + expires_in: -1, + refresh_token: 'my-refresh-token', + }); + await clientWithSeparateRefresh.exchangeCodeForToken(); + + // Refresh on refreshEndpoint (different path) + nock('https://auth.upsun.com').post('/oauth2/refresh').reply(200, { + access_token: 'refreshed-via-separate-endpoint', + token_type: 'Bearer', + expires_in: 3600, }); - await expect(oauth2Client.getAuthorization()).rejects.toThrow(); + const token = await clientWithSeparateRefresh.getAuthorization(); + expect(token).toBe('refreshed-via-separate-endpoint'); }); }); });