Skip to content

fix(oauth2): RFC-compliant token lifecycle#11

Merged
Theosakamg merged 2 commits into
mainfrom
fix/oauth2-token-lifecycle
Jun 4, 2026
Merged

fix(oauth2): RFC-compliant token lifecycle#11
Theosakamg merged 2 commits into
mainfrom
fix/oauth2-token-lifecycle

Conversation

@Theosakamg

Copy link
Copy Markdown
Collaborator

Summary

Fixes 4 OAuth2 bugs identified in the SDK, bringing the token lifecycle into full compliance with RFC 6749 and RFC 6750.

Changes

P1 — 401 retry middleware bypasses refresh_token (critical)

createAuthRetryMiddleware() was calling exchangeCodeForToken() directly on a 401, always forcing a grant_type=api_token exchange and skipping any available refresh_token.

Fix: replaced with forceRefresh() — a new public method on OAuth2Client that clears cached token state and delegates to ensureValidToken(), which prefers refresh_token grant before falling back to api_token (RFC 6749 Fig. 2, steps F→G→H).

P2 — refreshAccessToken() missing Authorization: Basic header

exchangeCodeForToken() correctly sent Authorization: Basic platform-api-user: but refreshAccessToken() did not, causing silent failures on auth servers that require it for confidential clients (RFC 6749 §3.2.1).

Fix: added the same Authorization: Basic header to refreshAccessToken().

P3 — Proactive buffer too short (60s → 120s)

Clock skew between client and server could cause the server to consider a token expired before the client's 60s buffer triggered a refresh.

Fix: increased buffer to 120 * 1000 ms.

P4 — refresh_endpoint config field unused

UpsunConfig.refresh_endpoint was defined but never passed to OAuth2Client, so both grant types always hit the same endpoint.

Fix: added optional 4th parameter refreshEndpoint to OAuth2Client constructor (defaults to tokenEndpoint), wired up from UpsunClient.

Bonus — Thundering-herd protection

Multiple concurrent requests receiving a 401 simultaneously would each call forceRefresh() independently, issuing N auth requests.

Fix: ensureValidToken() uses a shared pendingToken: Promise<void> | null — the first caller creates the promise, all concurrent callers await the same one.

Tests

12 unit tests in tests/unit/core/oauth2-client.test.ts (was 4), covering:

  • Authorization: Basic header on both grant types (P2)
  • grant_type=refresh_token body assertion on expiry (P1)
  • Fallback to api_token when refresh fails
  • forceRefresh() re-acquires a server-revoked "valid" token
  • forceRefresh() prefers refresh_token grant when available
  • Concurrent forceRefresh() deduplication — 1 auth call for 5 concurrent callers
  • Separate refreshEndpoint constructor param (P4)

- P1: middleware calls forceRefresh() instead of exchangeCodeForToken()
  so 401 retries prefer refresh_token grant before falling back to api_token
- P2: refreshAccessToken() now sends Authorization: Basic header (RFC 6749 §3.2.1)
- P3: proactive expiry buffer raised 60s → 120s to absorb clock skew
- P4: separate refreshEndpoint param passed to OAuth2Client constructor
- Add thundering-herd deduplication via shared pendingToken promise
- Update unit tests: 12 tests covering all fixed behaviours
@github-actions

github-actions Bot commented Jun 3, 2026

Copy link
Copy Markdown

Upsun SDK checker report

Display raw output

════════════════════════════════════════════════════════════════════════════════
  SDK Signature Comparison Report
════════════════════════════════════════════════════════════════════════════════


📦 Class: DomainsTask
   Languages: node, php
────────────────────────────────────────────────────────────────────────────────

  ⚠️  Signature Differences:

     Method: add()
       node: (projectId: string, domain: string)
       php: (projectId: string, domainCreateInput: DomainCreateInput)

📦 Class: EnvironmentsTask
   Languages: node, php
────────────────────────────────────────────────────────────────────────────────

  ⚠️  Signature Differences:

     Method: init()
       node: (projectId: string, environmentId: string, profile: string, repository: string, files: FilesInner[])
       php: (projectId: string, environmentId: string, profile: string, repository: string, fileMode: string, filePath: string, fileContents: string)

     Method: addDomain()
       node: (projectId: string, environmentId: string, domainName: string)
       php: (projectId: string, domainCreateInput: DomainCreateInput)

📦 Class: IntegrationsTask
   Languages: node, php
────────────────────────────────────────────────────────────────────────────────

  ⚠️  Signature Differences:

     Method: createIntegration()
       node: (projectId: string, type: string, params: IntegrationCreateData)
       php: (projectId: string, integrationCreateInput: IntegrationCreateCreateInput)

     Method: updateIntegration()
       node: (projectId: string, integrationId: string, type: string, params: IntegrationCreateData)
       php: (projectId: string, integrationId: string, integrationUpdateInput: IntegrationPatch)

📦 Class: MetricsTask
   Languages: node, php
────────────────────────────────────────────────────────────────────────────────

  ⚠️  Missing Methods:
     php:
       - fetchMetrics()

📦 Class: ProjectsTask
   Languages: node, php
────────────────────────────────────────────────────────────────────────────────

  ⚠️  Signature Differences:

     Method: updateSettings()
       node: (projectId: string, settings: ProjectSettings)
       php: (projectId: string)

════════════════════════════════════════════════════════════════════════════════
  Summary
────────────────────────────────────────────────────────────────────────────────
  Total classes analyzed: 26
  ⚠️  Found 7 issue(s)
════════════════════════════════════════════════════════════════════════════════


Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR updates the SDK’s OAuth2 token acquisition/refresh behavior to align more closely with RFC 6749/6750, including correct 401 retry behavior, refresh-token preference, a larger expiry buffer, optional separate refresh endpoints, and concurrency deduplication to avoid thundering-herd refreshes.

Changes:

  • Added OAuth2Client.forceRefresh() and updated the 401 retry middleware to use it, ensuring refresh-token grants are preferred before falling back to api_token.
  • Updated refresh behavior to include Authorization: Basic ... and increased the proactive refresh buffer (60s → 120s), plus deduped concurrent acquisitions via a shared in-flight promise.
  • Expanded unit test coverage for the new lifecycle behaviors, including refresh endpoint selection and concurrency deduplication.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.

File Description
src/core/oauth-provider.ts Implements RFC-aligned token lifecycle logic (force refresh, refresh preference, header parity, buffer increase, refresh endpoint, dedup).
src/upsun.ts Wires refresh_endpoint into OAuth2Client and updates 401 retry middleware to call forceRefresh().
tests/unit/core/oauth2-client.test.ts Adds unit tests for the updated token lifecycle and concurrency behavior.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 140 to 142
const data: TokenResponse = await response.json();
this.storeTokenData(data);
}
refresh_token: string;
expires_in: number; // en secondes
refresh_token?: string;
expires_in: number; // in secondes
@Theosakamg Theosakamg merged commit 48e8ceb into main Jun 4, 2026
9 checks passed
@Theosakamg Theosakamg deleted the fix/oauth2-token-lifecycle branch June 4, 2026 08:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants