[GH-ISSUE #9040] Generic OAuth + Oauth 2.1 Provider usage #19889

Open
opened 2026-04-15 19:15:07 -05:00 by GiteaMirror · 1 comment
Owner

Originally created by @j-fdion on GitHub (Apr 8, 2026).
Original GitHub issue: https://github.com/better-auth/better-auth/issues/9040

OAuth 2.1 Provider: incomplete docs make it very hard to implement a real client-server setup

I summarized my thoughts using Claude, as english is not my first language. It's coherent with what I first wrote, thanks in advance for any help about this issue.

Summary

The OAuth 2.1 provider docs cover client registration but leave out the most critical part: how a client app actually obtains, refreshes, and uses an access token to call a protected server endpoint. Several workarounds were needed to reach a working state, and none of them are documented.

Environment

Two apps — an auth server (OAuth 2.1 provider) and a client app (generic OAuth consumer). Goal: secure an API endpoint so authenticated users of the client can call the server.

What works (already documented)

Client registration via the docs.


What is missing or broken

1. authorizationUrlParams / tokenUrlParams are silently ignored

Setting resource here has no effect, so the server issues an opaque token instead of a JWT:

authorizationUrlParams: { resource: process.env.GCF_SCHEDULER_URL },
tokenUrlParams:         { resource: process.env.GCF_SCHEDULER_URL },

The only workaround is to override getToken entirely and build the token request by hand — which is undocumented and should not be necessary for a standard OAuth param:

getToken: async ({ code, redirectURI, codeVerifier }) => {
  const body = new URLSearchParams({
    grant_type: 'authorization_code',
    code,
    redirect_uri: redirectURI,
    client_id: process.env.GCF_SCHEDULER_CLIENT_ID!,
    client_secret: process.env.GCF_SCHEDULER_CLIENT_SECRET!,
    resource: process.env.GCF_SCHEDULER_URL!,
    ...(codeVerifier ? { code_verifier: codeVerifier } : {}),
  })

  const res = await fetch(`${process.env.GCF_SCHEDULER_URL}/api/auth/oauth2/token`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: body.toString(),
  })

  const data = await res.json()

  return {
    accessToken: data.access_token,
    refreshToken: data.refresh_token,
    idToken: data.id_token,
    accessTokenExpiresAt: data.expires_in
      ? new Date(Date.now() + data.expires_in * 1000)
      : undefined,
    scopes: data.scope?.split(' '),
  }
},

2. getUserInfo also needs a manual override

Because the opaque token can't be verified server-side (unless client credentials are hardcoded), getUserInfo must be overridden too. The docs don't mention this is needed or how to do it:

getUserInfo: async (tokens) => {
  if (tokens.idToken) {
    const [, payload] = tokens.idToken.split('.')
    const claims = JSON.parse(Buffer.from(payload, 'base64url').toString())
    return {
      id: claims.sub,
      email: claims.email,
      name: claims.name,
      emailVerified: claims.email_verified ?? false,
      image: claims.picture ?? null,
    }
  }

  const res = await fetch(`${process.env.GCF_SCHEDULER_URL}/api/auth/oauth2/userinfo`, {
    headers: { Authorization: `Bearer ${tokens.accessToken}` },
  })
  const profile = await res.json()
  return {
    id: profile.sub,
    email: profile.email,
    name: profile.name,
    emailVerified: profile.email_verified ?? false,
    image: profile.picture ?? null,
  }
},

3. authClient.getAccessToken() can't send extra token params

There is no way to pass resource (or any extra param) through getAccessToken(). The workaround is to bypass the helper entirely — query the DB directly and manually handle token refresh:

async function getSchedulerToken(userId: string, db: any) {
  const linked = await db.query.account.findFirst({
    where: and(eq(account.userId, userId), eq(account.providerId, 'scheduler')),
  })

  if (!linked) throw new Error('Scheduler account not linked')

  const isExpired = !linked.accessTokenExpiresAt || new Date() >= linked.accessTokenExpiresAt
  if (!isExpired) return linked.accessToken!

  const body = new URLSearchParams({
    grant_type: 'refresh_token',
    refresh_token: linked.refreshToken!,
    client_id: process.env.GCF_SCHEDULER_CLIENT_ID!,
    client_secret: process.env.GCF_SCHEDULER_CLIENT_SECRET!,
    resource: process.env.GCF_SCHEDULER_URL!,
  })

  const res = await fetch(`${process.env.GCF_SCHEDULER_URL}/api/auth/oauth2/token`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: body.toString(),
  })

  if (!res.ok) throw new Error(`Token refresh failed: ${res.status} ${await res.text()}`)

  const data = await res.json()

  await db
    .update(account)
    .set({
      accessToken: data.access_token,
      refreshToken: data.refresh_token ?? linked.refreshToken,
      accessTokenExpiresAt: new Date(Date.now() + Number(data.expires_in) * 1000),
    })
    .where(and(eq(account.userId, userId), eq(account.providerId, 'scheduler')))

  return data.access_token as string
}

What does work (partially documented)

Verifying the JWT on the server side works once a proper JWT is obtained:

const payload = await serverClient.verifyAccessToken(accessToken, {
  verifyOptions: {
    issuer: process.env.AUTH_ISSUER ?? 'http://localhost:3000',
    audience: process.env.AUTH_AUDIENCE ?? 'http://localhost:3000',
  },
  jwksUrl: `${process.env.AUTH_ISSUER ?? 'http://localhost:3000'}/api/auth/jwks`,
})

What would fix this

  1. Fix authorizationUrlParams / tokenUrlParams so they are actually forwarded in requests — or document clearly that they are not, and that getToken must be overridden.
  2. Add an extraParams (or similar) option to authClient.getAccessToken() so fields like resource can be passed through without bypassing the helper entirely.
  3. Add an end-to-end guide for the two-app pattern (OAuth 2.1 provider + generic OAuth client): link accounts → get a token → call a protected endpoint → verify the token server-side.
  4. Provide a minimal working example repo, or at least a code snippet covering the full round-trip without requiring the reader to already understand RFC 8707 resource indicators.

Relevant issue I found: #6486
Relevant fix? (not sure about this one...): https://github.com/better-auth/better-auth/pull/6541

Originally created by @j-fdion on GitHub (Apr 8, 2026). Original GitHub issue: https://github.com/better-auth/better-auth/issues/9040 # OAuth 2.1 Provider: incomplete docs make it very hard to implement a real client-server setup I summarized my thoughts using Claude, as english is not my first language. It's coherent with what I first wrote, thanks in advance for any help about this issue. ## Summary The OAuth 2.1 provider docs cover client registration but leave out the most critical part: how a client app actually obtains, refreshes, and uses an access token to call a protected server endpoint. Several workarounds were needed to reach a working state, and none of them are documented. ## Environment Two apps — an auth server (OAuth 2.1 provider) and a client app (generic OAuth consumer). Goal: secure an API endpoint so authenticated users of the client can call the server. ## What works (already documented) Client registration via the docs. --- ## What is missing or broken ### 1. `authorizationUrlParams` / `tokenUrlParams` are silently ignored Setting `resource` here has no effect, so the server issues an opaque token instead of a JWT: ```ts authorizationUrlParams: { resource: process.env.GCF_SCHEDULER_URL }, tokenUrlParams: { resource: process.env.GCF_SCHEDULER_URL }, ``` The only workaround is to override `getToken` entirely and build the token request by hand — which is undocumented and should not be necessary for a standard OAuth param: ```ts getToken: async ({ code, redirectURI, codeVerifier }) => { const body = new URLSearchParams({ grant_type: 'authorization_code', code, redirect_uri: redirectURI, client_id: process.env.GCF_SCHEDULER_CLIENT_ID!, client_secret: process.env.GCF_SCHEDULER_CLIENT_SECRET!, resource: process.env.GCF_SCHEDULER_URL!, ...(codeVerifier ? { code_verifier: codeVerifier } : {}), }) const res = await fetch(`${process.env.GCF_SCHEDULER_URL}/api/auth/oauth2/token`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: body.toString(), }) const data = await res.json() return { accessToken: data.access_token, refreshToken: data.refresh_token, idToken: data.id_token, accessTokenExpiresAt: data.expires_in ? new Date(Date.now() + data.expires_in * 1000) : undefined, scopes: data.scope?.split(' '), } }, ``` ### 2. `getUserInfo` also needs a manual override Because the opaque token can't be verified server-side (unless client credentials are hardcoded), `getUserInfo` must be overridden too. The docs don't mention this is needed or how to do it: ```ts getUserInfo: async (tokens) => { if (tokens.idToken) { const [, payload] = tokens.idToken.split('.') const claims = JSON.parse(Buffer.from(payload, 'base64url').toString()) return { id: claims.sub, email: claims.email, name: claims.name, emailVerified: claims.email_verified ?? false, image: claims.picture ?? null, } } const res = await fetch(`${process.env.GCF_SCHEDULER_URL}/api/auth/oauth2/userinfo`, { headers: { Authorization: `Bearer ${tokens.accessToken}` }, }) const profile = await res.json() return { id: profile.sub, email: profile.email, name: profile.name, emailVerified: profile.email_verified ?? false, image: profile.picture ?? null, } }, ``` ### 3. `authClient.getAccessToken()` can't send extra token params There is no way to pass `resource` (or any extra param) through `getAccessToken()`. The workaround is to bypass the helper entirely — query the DB directly and manually handle token refresh: ```ts async function getSchedulerToken(userId: string, db: any) { const linked = await db.query.account.findFirst({ where: and(eq(account.userId, userId), eq(account.providerId, 'scheduler')), }) if (!linked) throw new Error('Scheduler account not linked') const isExpired = !linked.accessTokenExpiresAt || new Date() >= linked.accessTokenExpiresAt if (!isExpired) return linked.accessToken! const body = new URLSearchParams({ grant_type: 'refresh_token', refresh_token: linked.refreshToken!, client_id: process.env.GCF_SCHEDULER_CLIENT_ID!, client_secret: process.env.GCF_SCHEDULER_CLIENT_SECRET!, resource: process.env.GCF_SCHEDULER_URL!, }) const res = await fetch(`${process.env.GCF_SCHEDULER_URL}/api/auth/oauth2/token`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: body.toString(), }) if (!res.ok) throw new Error(`Token refresh failed: ${res.status} ${await res.text()}`) const data = await res.json() await db .update(account) .set({ accessToken: data.access_token, refreshToken: data.refresh_token ?? linked.refreshToken, accessTokenExpiresAt: new Date(Date.now() + Number(data.expires_in) * 1000), }) .where(and(eq(account.userId, userId), eq(account.providerId, 'scheduler'))) return data.access_token as string } ``` --- ## What does work (partially documented) Verifying the JWT on the server side works once a proper JWT is obtained: ```ts const payload = await serverClient.verifyAccessToken(accessToken, { verifyOptions: { issuer: process.env.AUTH_ISSUER ?? 'http://localhost:3000', audience: process.env.AUTH_AUDIENCE ?? 'http://localhost:3000', }, jwksUrl: `${process.env.AUTH_ISSUER ?? 'http://localhost:3000'}/api/auth/jwks`, }) ``` --- ## What would fix this 1. Fix `authorizationUrlParams` / `tokenUrlParams` so they are actually forwarded in requests — or document clearly that they are not, and that `getToken` must be overridden. 2. Add an `extraParams` (or similar) option to `authClient.getAccessToken()` so fields like `resource` can be passed through without bypassing the helper entirely. 3. Add an end-to-end guide for the two-app pattern (OAuth 2.1 provider + generic OAuth client): link accounts → get a token → call a protected endpoint → verify the token server-side. 4. Provide a minimal working example repo, or at least a code snippet covering the full round-trip without requiring the reader to already understand RFC 8707 resource indicators. Relevant issue I found: #6486 Relevant fix? (not sure about this one...): [https://github.com/better-auth/better-auth/pull/6541](https://github.com/better-auth/better-auth/pull/6541)
GiteaMirror added the enhancement label 2026-04-15 19:15:07 -05:00
Author
Owner

@dosubot[bot] commented on GitHub (Apr 8, 2026):

Hey @j-fdion — this looks like a continuation of your earlier discussion, and the issues you're raising are valid and well-documented at this point. Here's what I can confirm from the source code and existing threads:

tokenUrlParams / authorizationUrlParams — These are implemented in the code and should forward params [1], but there were confirmed bugs in versions ~1.4.5–1.5.5 where function-based params were never called [2]. If you're on a recent version, tokenUrlParams: { resource: "..." } should work for the initial token exchange — worth retesting before keeping the manual getToken override.

Token refresh is the real gap — Even if tokenUrlParams works for the initial exchange, there's no refreshTokenParams config option, so refreshing a token won't include resource and you'll get an opaque token back [3]. PR #7660 proposes adding refreshAccessTokenExtraParams but its merge status is unclear. Your manual refresh workaround is currently the only reliable path.

getAccessToken() doesn't support extra params — Confirmed. It only accepts providerId/accountId/userId and has no way to pass resource [4]. This is by design — it's a generic helper for retrieving stored tokens, not for making token requests with custom params.

Your workarounds are sound. The custom getToken approach is fine. For getUserInfo, decoding the ID token without signature verification is acceptable when the token came directly from the token endpoint over HTTPS during an auth code exchange, but for production you should verify the signature via JWKS as mentioned in the earlier thread.

For the documentation gap — your request for an end-to-end guide for the two-app pattern (OAuth 2.1 provider + generic OAuth client) is reasonable. The pieces exist across various threads but aren't consolidated in the docs yet.

To reply, just mention @dosu.


How did I do? Good | Irrelevant | Incorrect | Verbose | Hallucination | Report 🐛 | Other
Chat with Dosu Open in Cursor Join Discord Share on X

<!-- gh-comment-id:4207341865 --> @dosubot[bot] commented on GitHub (Apr 8, 2026): <!-- Answer --> Hey @j-fdion — this looks like a continuation of [your earlier discussion](https://github.com/better-auth/better-auth/discussions/8947#discussioncomment-16469725), and the issues you're raising are valid and well-documented at this point. Here's what I can confirm from the source code and existing threads: **`tokenUrlParams` / `authorizationUrlParams`** — These *are* implemented in the code and should forward params [[1]](https://github.com/better-auth/better-auth/blob/0a094412008b8056bde88e6ff300f45d6c44da03/packages/better-auth/src/plugins/generic-oauth/routes.ts#L387-L404), but there were confirmed bugs in versions ~1.4.5–1.5.5 where function-based params were never called [[2]](https://github.com/better-auth/better-auth/issues/6486#issuecomment-3605257165). If you're on a recent version, `tokenUrlParams: { resource: "..." }` should work for the initial token exchange — worth retesting before keeping the manual `getToken` override. **Token refresh is the real gap** — Even if `tokenUrlParams` works for the initial exchange, there's **no `refreshTokenParams` config option**, so refreshing a token won't include `resource` and you'll get an opaque token back [[3]](https://github.com/better-auth/better-auth/issues/7554). [PR #7660](https://github.com/better-auth/better-auth/pull/7660) proposes adding `refreshAccessTokenExtraParams` but its merge status is unclear. Your manual refresh workaround is currently the only reliable path. **`getAccessToken()` doesn't support extra params** — Confirmed. It only accepts `providerId`/`accountId`/`userId` and has no way to pass `resource` [[4]](https://github.com/better-auth/better-auth/discussions/8947#discussioncomment-16469725). This is by design — it's a generic helper for retrieving stored tokens, not for making token requests with custom params. **Your workarounds are sound.** The custom `getToken` approach is fine. For `getUserInfo`, decoding the ID token without signature verification is acceptable when the token came directly from the token endpoint over HTTPS during an auth code exchange, but for production you should verify the signature via JWKS as mentioned in the [earlier thread](https://github.com/better-auth/better-auth/discussions/8947#discussioncomment-16469725). For the documentation gap — your request for an end-to-end guide for the two-app pattern (OAuth 2.1 provider + generic OAuth client) is reasonable. The pieces exist across various threads but aren't consolidated in the docs yet. <!-- Dosu Comment Footer --> *To reply, just mention [@dosu](https://go.dosu.dev/dosubot?utm_source=github&utm_medium=bot-comment&utm_campaign=github-assistant&utm_content=brand-link).* --- <sup>How did I do? [Good](https://app.dosu.dev/response-feedback?utm_source=github&utm_medium=bot-comment&utm_campaign=github-assistant&utm_content=feedback-great_response#message_id=c06810fc-d974-4e31-a2a5-8c8c16ce0084&feedback_type=great_response) | [Irrelevant](https://app.dosu.dev/response-feedback?utm_source=github&utm_medium=bot-comment&utm_campaign=github-assistant&utm_content=feedback-irrelevant_answer#message_id=c06810fc-d974-4e31-a2a5-8c8c16ce0084&feedback_type=irrelevant_answer) | [Incorrect](https://app.dosu.dev/response-feedback?utm_source=github&utm_medium=bot-comment&utm_campaign=github-assistant&utm_content=feedback-incorrect_sources#message_id=c06810fc-d974-4e31-a2a5-8c8c16ce0084&feedback_type=incorrect_sources) | [Verbose](https://app.dosu.dev/response-feedback?utm_source=github&utm_medium=bot-comment&utm_campaign=github-assistant&utm_content=feedback-too_verbose#message_id=c06810fc-d974-4e31-a2a5-8c8c16ce0084&feedback_type=too_verbose) | [Hallucination](https://app.dosu.dev/response-feedback?utm_source=github&utm_medium=bot-comment&utm_campaign=github-assistant&utm_content=feedback-hallucination#message_id=c06810fc-d974-4e31-a2a5-8c8c16ce0084&feedback_type=hallucination) | [Report 🐛](https://app.dosu.dev/response-feedback?utm_source=github&utm_medium=bot-comment&utm_campaign=github-assistant&utm_content=feedback-bug_report#message_id=c06810fc-d974-4e31-a2a5-8c8c16ce0084&feedback_type=bug_report) | [Other](https://app.dosu.dev/response-feedback?utm_source=github&utm_medium=bot-comment&utm_campaign=github-assistant&utm_content=feedback-other#message_id=c06810fc-d974-4e31-a2a5-8c8c16ce0084&feedback_type=other)</sup> [![Chat with Dosu](https://dosu.dev/dosu-chat-badge.svg)](https://app.dosu.dev/cdda13d9-dd27-4d31-b09a-5d8bec92de21/ask?utm_source=github&utm_medium=bot-comment&utm_campaign=github-assistant&utm_content=chat-badge)&nbsp;[![Open in Cursor](https://dosu.dev/dosu-in-cursor.svg)](https://cursor.com/link/prompt?text=Hey%20%40j-fdion%20%E2%80%94%20this%20looks%20like%20a%20continuation%20of%20%5Byour%20earlier%20discussion%5D%28https%3A//github.com/better-auth/better-auth/discussions/8947%23discussioncomment-16469725%29%2C%20and%20the%20issues%20you%27re%20raising%20are%20valid%20and%20well-documented%20at%20this%20point.%20Here%27s%20what%20I%20can%20confirm%20from%20the%20source%20code%20and%20existing%20threads%3A%0A%0A%2A%2A%60tokenUrlParams%60%20/%20%60authorizationUrlParams%60%2A%2A%20%E2%80%94%20These%20%2Aare%2A%20implemented%20in%20the%20code%20and%20should%20forward%20params%20%5B%5B1%5D%5D%28https%3A//github.com/better-auth/better-auth/blob/0a094412008b8056bde88e6ff300f45d6c44da03/packages/better-auth/src/plugins/generic-oauth/routes.ts%23L387-L404%29%2C%20but%20there%20were%20confirmed%20bugs%20in%20versions%20~1.4.5%E2%80%931.5.5%20where%20function-based%20params%20were%20never%20called%20%5B%5B2%5D%5D%28https%3A//github.com/better-auth/better-auth/issues/6486%23issuecomment-3605257165%29.%20If%20you%27re%20on%20a%20recent%20version%2C%20%60tokenUrlParams%3A%20%7B%20resource%3A%20%22...%22%20%7D%60%20should%20work%20for%20the%20initial%20token%20exchange%20%E2%80%94%20worth%20retesting%20before%20keeping%20the%20manual%20%60getToken%60%20override.%0A%0A%2A%2AToken%20refresh%20is%20the%20real%20gap%2A%2A%20%E2%80%94%20Even%20if%20%60tokenUrlParams%60%20works%20for%20the%20initial%20exchange%2C%20there%27s%20%2A%2Ano%20%60refreshTokenParams%60%20config%20option%2A%2A%2C%20so%20refreshing%20a%20token%20won%27t%20include%20%60resource%60%20and%20you%27ll%20get%20an%20opaque%20token%20back%20%5B%5B3%5D%5D%28https%3A//github.com/better-auth/better-auth/issues/7554%29.%20%5BPR%20%237660%5D%28https%3A//github.com/better-auth/better-auth/pull/7660%29%20proposes%20adding%20%60refreshAccessTokenExtraParams%60%20but%20its%20merge%20status%20is%20unclear.%20Your%20manual%20refresh%20workaround%20is%20currently%20the%20only%20reliable%20path.%0A%0A%2A%2A%60getAccessToken%28%29%60%20doesn%27t%20support%20extra%20params%2A%2A%20%E2%80%94%20Confirmed.%20It%20only%20accepts%20%60providerId%60/%60accountId%60/%60userId%60%20and%20has%20no%20way%20to%20pass%20%60resource%60%20%5B%5B4%5D%5D%28https%3A//github.com/better-auth/better-auth/discussions/8947%23discussioncomment-16469725%29.%20This%20is%20by%20design%20%E2%80%94%20it%27s%20a%20generic%20helper%20for%20retrieving%20stored%20tokens%2C%20not%20for%20making%20token%20requests%20with%20custom%20params.%0A%0A%2A%2AYour%20workarounds%20are%20sound.%2A%2A%20The%20custom%20%60getToken%60%20approach%20is%20fine.%20For%20%60getUserInfo%60%2C%20decoding%20the%20ID%20token%20without%20signature%20verification%20is%20acceptable%20when%20the%20token%20came%20directly%20from%20the%20token%20endpoint%20over%20HTTPS%20during%20an%20auth%20code%20exchange%2C%20but%20for%20production%20you%20should%20verify%20the%20signature%20via%20JWKS%20as%20mentioned%20in%20the%20%5Bearlier%20thread%5D%28https%3A//github.com/better-auth/better-auth/discussions/8947%23discussioncomment-16469725%29.%0A%0AFor%20the%20documentation%20gap%20%E2%80%94%20your%20request%20for%20an%20end-to-end%20guide%20for%20the%20two-app%20pattern%20%28OAuth%202.1%20provider%20%2B%20generic%20OAuth%20client%29%20is%20reasonable.%20The%20pieces%20exist%20across%20various%20threads%20but%20aren%27t%20consolidated%20in%20the%20docs%20yet.)&nbsp;[![Join Discord](https://img.shields.io/badge/join-5865F2?logo=discord&logoColor=white&label=)](https://go.dosu.dev/discord-bot?utm_source=github&utm_medium=bot-comment&utm_campaign=github-assistant&utm_content=join-discord)&nbsp;[![Share on X](https://img.shields.io/badge/X-share-black)](https://twitter.com/intent/tweet?text=%40dosu_ai%20helped%20me%20solve%20this%20issue!&url=https%3A//github.com/better-auth/better-auth/issues/9040)
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: github-starred/better-auth#19889