Skip to content

OAuth Callbacks

Permission required: oauth

Register a callback handler to receive OAuth authorization redirects from external services (Spotify, Google, Discord, etc.). This enables extensions to implement full OAuth flows using PKCE without requiring a separate backend server.

spindle.oauth.getCallbackUrl()

Returns the relative callback URL path for this extension: /api/spindle-oauth/{identifier}/callback

Use this to construct the redirect_uri for your OAuth authorization request.

const callbackUrl = spindle.oauth.getCallbackUrl()
// => "/api/spindle-oauth/my_extension/callback"

spindle.oauth.onCallback(handler)

Register a handler invoked when the OAuth callback URL is hit. The handler receives all query parameters from the redirect. Return { html } to customize the response page shown to the user, or return nothing for a default "Authorization Complete" page.

spindle.oauth.onCallback(async (params) => {
  // params contains all query parameters from the OAuth redirect
  // e.g. { code: "abc123", state: "xyz" }

  const { code, state } = params

  // Exchange the authorization code for tokens
  const tokens = await spindle.cors('https://accounts.example.com/token', {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: `grant_type=authorization_code&code=${code}&redirect_uri=...`,
  })

  await spindle.storage.setJson('tokens.json', tokens)

  return { html: '<html><body>Connected! You can close this window.</body></html>' }
})

Full OAuth PKCE Example

declare const spindle: import('lumiverse-spindle-types').SpindleAPI

let pendingVerifier: string | null = null

// Handle the callback
spindle.oauth.onCallback(async (params) => {
  if (!pendingVerifier) return { html: 'No pending authorization.' }

  const verifier = pendingVerifier
  pendingVerifier = null

  const res = await spindle.cors('https://accounts.example.com/api/token', {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      grant_type: 'authorization_code',
      code: params.code,
      redirect_uri: serverBaseUrl + spindle.oauth.getCallbackUrl(),
      code_verifier: verifier,
      client_id: 'YOUR_CLIENT_ID',
    }).toString(),
  })

  await spindle.storage.setJson('tokens.json', JSON.parse(res.body))
  return { html: '<html><body><h1>Connected!</h1><script>setTimeout(()=>window.close(),2000)</script></body></html>' }
})

// Start the OAuth flow (triggered by frontend message)
spindle.onFrontendMessage(async (msg: any, userId) => {
  if (msg.type === 'connect') {
    const verifier = generateCodeVerifier()
    const challenge = await generateCodeChallenge(verifier)
    pendingVerifier = verifier

    const redirectUri = msg.serverBaseUrl + spindle.oauth.getCallbackUrl()
    const authUrl = `https://accounts.example.com/authorize?response_type=code&client_id=${msg.clientId}&redirect_uri=${encodeURIComponent(redirectUri)}&code_challenge=${challenge}&code_challenge_method=S256`

    spindle.sendToFrontend({ type: 'auth_url', url: authUrl }, userId)
  }
})

Note

The callback route is unauthenticated (outside /api/v1/*), so external OAuth servers can redirect to it without session cookies.