From 88585a202401b3d7e22a08a8c893837774d2ec1f Mon Sep 17 00:00:00 2001 From: Yuzhong Zhang <141388234+BetterAndBetterII@users.noreply.github.com> Date: Wed, 26 Aug 2026 14:58:00 +0700 Subject: [PATCH] fix(server): send User-Agent on GitHub OAuth fetches (#15524) ## Description Self-hosted GitHub OAuth login fails at token exchange because the outbound request to `https://github.com/login/oauth/access_token` has no `User-Agent` header. GitHub then returns 403 ("Request forbidden by administrative rules"), which is surfaced as `INVALID_OAUTH_CALLBACK_CODE`. OAuth `safeFetch` only forwarded `authorization`, `content-type`, and `accept`, so even a User-Agent on the request would be stripped. This change: - allows `user-agent` in OAuth `fetchOptions()` - always sends `User-Agent: AFFiNE-Server` from `fetchJson()` (covers token exchange and `api.github.com` user/email fetches) Fixes #15521 ## Checklist - [x] The PR targets the `canary` branch and its title follows Conventional Commits - [x] Tests are added or updated where it makes sense ## Summary by CodeRabbit * **Bug Fixes** * Improved OAuth request compatibility by including a standard `User-Agent` header. * Ensured the header is permitted consistently during OAuth token exchanges. * **Tests** * Added coverage to verify case-insensitive handling of the `User-Agent` header in GitHub OAuth requests. --------- --- .../__tests__/oauth/github-user-agent.spec.ts | 95 +++++++++++++++++++ .../server/src/plugins/oauth/providers/def.ts | 11 ++- 2 files changed, 104 insertions(+), 2 deletions(-) create mode 100644 packages/backend/server/src/__tests__/oauth/github-user-agent.spec.ts diff --git a/packages/backend/server/src/__tests__/oauth/github-user-agent.spec.ts b/packages/backend/server/src/__tests__/oauth/github-user-agent.spec.ts new file mode 100644 index 0000000000..5c623fcd62 --- /dev/null +++ b/packages/backend/server/src/__tests__/oauth/github-user-agent.spec.ts @@ -0,0 +1,95 @@ +import serverNativeModule from '@affine/server-native'; +import ava, { TestFn } from 'ava'; +import Sinon from 'sinon'; + +type NativeSafeFetchRequest = { + url: string; + headers?: Record; + allowedHeaders?: string[]; +}; + +const test = ava.serial as TestFn<{ + requests: NativeSafeFetchRequest[]; +}>; + +function headerValue( + headers: Record | undefined, + name: string +) { + const target = name.toLowerCase(); + for (const [key, value] of Object.entries(headers ?? {})) { + if (key.toLowerCase() === target) { + return value; + } + } + return undefined; +} + +test.before(t => { + const requests: NativeSafeFetchRequest[] = []; + + Sinon.stub(serverNativeModule, 'safeFetch').callsFake(async request => { + const nativeRequest = request as NativeSafeFetchRequest; + requests.push(nativeRequest); + return { + status: 200, + finalUrl: nativeRequest.url, + headers: { 'content-type': 'application/json' }, + body: Buffer.from( + JSON.stringify({ + access_token: 'github-access-token', + scope: 'read:user user:email', + token_type: 'bearer', + }) + ), + }; + }); + + t.context.requests = requests; +}); + +test.after.always(() => { + Sinon.restore(); +}); + +test('github oauth token exchange should send a User-Agent header', async t => { + const { OAuthProviderName } = await import('../../plugins/oauth/config'); + const { OAuthProvider } = await import('../../plugins/oauth/providers/def'); + + class Probe extends OAuthProvider { + override provider = OAuthProviderName.GitHub; + + getAuthUrl() { + return ''; + } + + async getToken() { + return { accessToken: 'token' }; + } + + async getUser() { + return { id: 'id', email: 'user@example.com' }; + } + + exchange() { + return this.postFormJson( + 'https://github.com/login/oauth/access_token', + 'code=oauth-code' + ); + } + } + + const { requests } = t.context; + requests.length = 0; + + await new Probe().exchange(); + + t.is(requests.length, 1); + t.is(requests[0].url, 'https://github.com/login/oauth/access_token'); + t.truthy(headerValue(requests[0].headers, 'user-agent')); + t.true( + (requests[0].allowedHeaders ?? []).some( + header => header.toLowerCase() === 'user-agent' + ) + ); +}); diff --git a/packages/backend/server/src/plugins/oauth/providers/def.ts b/packages/backend/server/src/plugins/oauth/providers/def.ts index c90f7a3879..51cc392cd0 100644 --- a/packages/backend/server/src/plugins/oauth/providers/def.ts +++ b/packages/backend/server/src/plugins/oauth/providers/def.ts @@ -85,7 +85,7 @@ export abstract class OAuthProvider { timeoutMs: 10_000, maxRedirects: 3, maxBytes: 1024 * 1024, - allowedHeaders: ['authorization', 'content-type', 'accept'], + allowedHeaders: ['authorization', 'content-type', 'accept', 'user-agent'], }; } @@ -96,7 +96,14 @@ export abstract class OAuthProvider { ) { const response = await safeFetch( url, - { ...init, headers: { ...init?.headers, Accept: 'application/json' } }, + { + ...init, + headers: { + ...init?.headers, + Accept: 'application/json', + 'User-Agent': 'AFFiNE-Server', + }, + }, this.fetchOptions(url) );