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

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## 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.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------
This commit is contained in:
Yuzhong Zhang
2026-08-26 14:58:00 +07:00
committed by GitHub
parent bd095495da
commit 88585a2024
2 changed files with 104 additions and 2 deletions
@@ -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<string, string>;
allowedHeaders?: string[];
};
const test = ava.serial as TestFn<{
requests: NativeSafeFetchRequest[];
}>;
function headerValue(
headers: Record<string, string> | 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'
)
);
});
@@ -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)
);