Add support for SDK to use OAuth2.0 PKCE flow#13804
Conversation
🦋 Changeset detectedLatest commit: 4cea1ec The changes in this PR will be included in the next version bump. This PR includes changesets to release 3 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
There was a problem hiding this comment.
Pull request overview
Adds OAuth2 PKCE support to the @audius/sdk browser OAuth flow by introducing PKCE helpers, an in-memory token store, and a middleware that auto-refreshes tokens on 401s, while also shifting API-key-only SDK construction to the API-forward (no-services) SDK path.
Changes:
- Add PKCE utility functions + an
OAuthTokenStoreand export them fromsdk/oauth. - Extend
OAuthto support PKCE code exchange, token refresh, and logout flows. - Add
addTokenRefreshMiddlewareto transparently refresh + retry requests after 401s, and wire it intocreateSdkWithoutServices.
Reviewed changes
Copilot reviewed 12 out of 12 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/sdk/src/sdk/sdk.ts | Routes API-key-only configs to createSdkWithoutServices (API-forward SDK type). |
| packages/sdk/src/sdk/oauth/tokenStore.ts | Adds an in-memory token store with a Configuration.accessToken provider. |
| packages/sdk/src/sdk/oauth/tokenStore.test.ts | Unit tests for token store behavior. |
| packages/sdk/src/sdk/oauth/pkce.ts | Adds PKCE verifier/challenge/state helpers. |
| packages/sdk/src/sdk/oauth/pkce.test.ts | Tests PKCE helpers including RFC 7636 test vector. |
| packages/sdk/src/sdk/oauth/index.ts | Re-exports PKCE + token store helpers. |
| packages/sdk/src/sdk/oauth/OAuth.ts | Implements PKCE login + code exchange, token refresh, and logout support. |
| packages/sdk/src/sdk/middleware/index.ts | Exports the new token refresh middleware. |
| packages/sdk/src/sdk/middleware/addTokenRefreshMiddleware.ts | Adds 401-handling refresh + retry middleware. |
| packages/sdk/src/sdk/middleware/addTokenRefreshMiddleware.test.ts | Tests refresh + retry and failure modes. |
| packages/sdk/src/sdk/createSdkWithoutServices.ts | Wires in token store + refresh middleware; exposes tokenStore on returned SDK. |
| .changeset/breezy-lions-jog.md | Declares a minor release for PKCE/refresh token support. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // Delegate to async implementation | ||
| this._loginAsync({ | ||
| scope, | ||
| params, | ||
| redirectUri, | ||
| display, | ||
| responseMode |
There was a problem hiding this comment.
login() delegates to the async _loginAsync() but does not await it or attach a .catch(...). If generateCodeChallenge() (or any other awaited step) rejects, this becomes an unhandled promise rejection and the error callback/logger won’t run. Consider calling it as void this._loginAsync(...).catch(e => this._surfaceError(...)) (or making login async and awaiting).
| // Delegate to async implementation | |
| this._loginAsync({ | |
| scope, | |
| params, | |
| redirectUri, | |
| display, | |
| responseMode | |
| // Delegate to async implementation and ensure errors are surfaced | |
| void this._loginAsync({ | |
| scope, | |
| params, | |
| redirectUri, | |
| display, | |
| responseMode | |
| }).catch((error) => { | |
| this._surfaceError( | |
| error instanceof Error ? error.message : String(error) | |
| ) |
| if (!tokenRes.ok) { | ||
| const err = await tokenRes.json().catch(() => ({})) | ||
| this._surfaceError(err.error_description ?? 'Token exchange failed.') | ||
| return | ||
| } | ||
| const tokens = await tokenRes.json() | ||
| this.config.tokenStore.setTokens( | ||
| tokens.access_token, | ||
| tokens.refresh_token | ||
| ) |
There was a problem hiding this comment.
In the PKCE message handler, the token exchange response is used without validating required fields. If the server returns a non-JSON body or missing/incorrect access_token / refresh_token, tokenStore.setTokens(tokens.access_token, tokens.refresh_token) will store undefined and break future auth/refresh behavior. Add a JSON parse guard and validate both tokens are non-empty strings before storing them (and surface a helpful error otherwise).
| async refreshAccessToken(): Promise<boolean> { | ||
| if (!this.config.tokenStore || !this.config.basePath) { | ||
| this._surfaceError( | ||
| 'Token store and basePath are required for token refresh.' | ||
| ) | ||
| return false | ||
| } | ||
| const refreshToken = this.config.tokenStore.refreshToken | ||
| if (!refreshToken) { | ||
| this._surfaceError('No refresh token available.') | ||
| return false | ||
| } | ||
| try { | ||
| const res = await fetch(`${this.config.basePath}/oauth/token`, { | ||
| method: 'POST', | ||
| headers: { 'Content-Type': 'application/json' }, | ||
| body: JSON.stringify({ | ||
| grant_type: 'refresh_token', | ||
| refresh_token: refreshToken, | ||
| client_id: this.apiKey | ||
| }) | ||
| }) | ||
| if (!res.ok) { | ||
| return false | ||
| } | ||
| const tokens = await res.json() | ||
| if (tokens.access_token && tokens.refresh_token) { | ||
| this.config.tokenStore.setTokens( | ||
| tokens.access_token, | ||
| tokens.refresh_token | ||
| ) | ||
| return true |
There was a problem hiding this comment.
refreshAccessToken() posts client_id: this.apiKey, but this.apiKey is typed as string | null. If OAuth was constructed without an API key, this will send null (or fail server-side) even though refresh requires a client id. Consider returning false (and surfacing an error) when this.apiKey is null, and validate access_token/refresh_token are non-empty strings before calling tokenStore.setTokens.
| post: async (context: ResponseContext): Promise<Response | void> => { | ||
| if (context.response.status !== 401) { | ||
| return context.response | ||
| } | ||
|
|
||
| // Snapshot the refresh token — it may be cleared concurrently. | ||
| const currentRefreshToken = tokenStore.refreshToken | ||
| if (!currentRefreshToken) { | ||
| return context.response | ||
| } | ||
|
|
||
| // Coalesce concurrent 401s into a single refresh call. | ||
| if (!refreshInFlight) { | ||
| refreshInFlight = exchangeRefreshToken( | ||
| currentRefreshToken, | ||
| apiKey, | ||
| basePath | ||
| ).finally(() => { | ||
| refreshInFlight = null | ||
| }) | ||
| } | ||
|
|
||
| const newTokens = await refreshInFlight | ||
| if (!newTokens) { | ||
| // Refresh failed — surface the original 401. | ||
| return context.response | ||
| } | ||
|
|
||
| tokenStore.setTokens(newTokens.access_token, newTokens.refresh_token) | ||
|
|
||
| // Retry the original request with the new access token. | ||
| const retryInit: RequestInit = { | ||
| ...context.init, | ||
| headers: { | ||
| ...((context.init.headers as Record<string, string>) ?? {}), | ||
| Authorization: `Bearer ${newTokens.access_token}` | ||
| } | ||
| } | ||
| return context.fetch(context.url, retryInit) | ||
| } |
There was a problem hiding this comment.
The retry uses context.fetch(context.url, retryInit), which will run the middleware stack again. If the retried request still returns 401 for reasons unrelated to token expiry (e.g., missing permissions), this middleware can repeatedly refresh + retry and potentially loop indefinitely. Add a per-request guard (e.g., set/check a x-audius-refresh-retried header or a symbol on RequestInit) so the refresh+retry happens at most once per original request.
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Adds helpers + refresh middleware and updates OAuth service to support PKCE flow.
Makes API key only configs use the API-forward SDK type