Skip to content

Add support for SDK to use OAuth2.0 PKCE flow#13804

Open
rickyrombo wants to merge 5 commits intomainfrom
mjp-oauth-net-new
Open

Add support for SDK to use OAuth2.0 PKCE flow#13804
rickyrombo wants to merge 5 commits intomainfrom
mjp-oauth-net-new

Conversation

@rickyrombo
Copy link
Contributor

Adds helpers + refresh middleware and updates OAuth service to support PKCE flow.

Makes API key only configs use the API-forward SDK type

@changeset-bot
Copy link

changeset-bot bot commented Mar 4, 2026

🦋 Changeset detected

Latest commit: 4cea1ec

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 3 packages
Name Type
@audius/sdk Minor
@audius/sdk-legacy Patch
@audius/sp-actions Patch

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

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

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 OAuthTokenStore and export them from sdk/oauth.
  • Extend OAuth to support PKCE code exchange, token refresh, and logout flows.
  • Add addTokenRefreshMiddleware to transparently refresh + retry requests after 401s, and wire it into createSdkWithoutServices.

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.

Comment on lines +212 to +218
// Delegate to async implementation
this._loginAsync({
scope,
params,
redirectUri,
display,
responseMode
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

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

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).

Suggested change
// 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)
)

Copilot uses AI. Check for mistakes.
Comment on lines +446 to +455
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
)
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

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

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).

Copilot uses AI. Check for mistakes.
Comment on lines +514 to +545
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
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.
Comment on lines +74 to +113
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)
}
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants