fix(server): disable Apple oauth on client version < 0.22.0 (#12984)

close AF-2705



#### PR Dependency Tree


* **PR #12984** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* The Apple OAuth provider is now available only for clients version
0.22.0 or higher.
* Client version detection has been improved by extracting version
information from request headers.

* **Bug Fixes**
* Ensured that the Apple OAuth provider is hidden for clients below
version 0.22.0.

* **Tests**
* Added comprehensive end-to-end and utility tests for OAuth provider
selection and client version extraction.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
fengmk2
2025-07-02 16:07:34 +08:00
committed by GitHub
parent bcd6a70b59
commit facf6ee28b
7 changed files with 255 additions and 5 deletions

View File

@@ -1,7 +1,7 @@
import assert from 'node:assert';
import { gqlFetcherFactory } from '@affine/graphql';
import { INestApplication } from '@nestjs/common';
import { INestApplication, ModuleMetadata } from '@nestjs/common';
import { NestApplication } from '@nestjs/core';
import { Test, TestingModuleBuilder } from '@nestjs/testing';
import { PrismaClient } from '@prisma/client';
@@ -35,6 +35,7 @@ import { parseCookies, TEST_LOG_LEVEL } from '../utils';
interface TestingAppMetadata {
tapModule?(m: TestingModuleBuilder): void;
tapApp?(app: INestApplication): void;
imports?: ModuleMetadata['imports'];
}
export class TestingApp extends NestApplication {
@@ -203,7 +204,7 @@ export async function createApp(
const { tapModule, tapApp } = metadata;
const builder = Test.createTestingModule({
imports: [buildAppModule(globalThis.env)],
imports: [buildAppModule(globalThis.env), ...(metadata.imports ?? [])],
});
builder.overrideProvider(Mailer).useValue(new MockMailer());

View File

@@ -0,0 +1,76 @@
# Snapshot report for `src/__tests__/e2e/oauth/resolver.spec.ts`
The actual snapshot is saved in `resolver.spec.ts.snap`.
Generated by [AVA](https://avajs.dev).
## should return apple oauth provider in version >= 0.22.0
> Snapshot 1
{
serverConfig: {
oauthProviders: [
'Google',
'Apple',
],
},
}
> Snapshot 2
{
serverConfig: {
oauthProviders: [
'Google',
'Apple',
],
},
}
> Snapshot 3
{
serverConfig: {
oauthProviders: [
'Google',
'Apple',
],
},
}
## should not return apple oauth provider when client version is not specified
> Snapshot 1
{
serverConfig: {
oauthProviders: [
'Google',
],
},
}
## should not return apple oauth provider in version < 0.22.0
> Snapshot 1
{
serverConfig: {
oauthProviders: [
'Google',
],
},
}
## should not return apple oauth provider when client version format is not correct
> Snapshot 1
{
serverConfig: {
oauthProviders: [
'Google',
],
},
}

View File

@@ -0,0 +1,111 @@
import { oauthProvidersQuery } from '@affine/graphql';
import { ConfigModule } from '../../../base/config';
import { createApp, e2e, TestingApp } from '../test';
let app: TestingApp;
e2e.before(async () => {
app = await createApp({
imports: [
ConfigModule.override({
oauth: {
providers: {
apple: {
clientId: 'test',
clientSecret: 'test',
args: {
redirectUri: 'test',
},
},
google: {
clientId: 'test',
clientSecret: 'test',
args: {
redirectUri: 'test',
},
},
},
},
}),
],
});
});
e2e.after.always(async () => {
await app.close();
});
e2e('should return apple oauth provider in version >= 0.22.0', async t => {
const res = await app.gql({
query: oauthProvidersQuery,
context: {
headers: {
'x-affine-version': '0.22.0',
},
},
});
t.snapshot(res);
const res2 = await app.gql({
query: oauthProvidersQuery,
context: {
headers: {
'x-affine-version': '0.23.0-beta.1',
},
},
});
t.snapshot(res2);
const res3 = await app.gql({
query: oauthProvidersQuery,
context: {
headers: {
'x-affine-version': '2025.6.29-canary.93',
},
},
});
t.snapshot(res3);
});
e2e(
'should not return apple oauth provider when client version is not specified',
async t => {
const res = await app.gql({
query: oauthProvidersQuery,
});
t.snapshot(res);
}
);
e2e('should not return apple oauth provider in version < 0.22.0', async t => {
const res = await app.gql({
query: oauthProvidersQuery,
context: {
headers: {
'x-affine-version': '0.21.0',
},
},
});
t.snapshot(res);
});
e2e(
'should not return apple oauth provider when client version format is not correct',
async t => {
const res = await app.gql({
query: oauthProvidersQuery,
context: {
headers: {
'x-affine-version': 'mock-invalid-version',
},
},
});
t.snapshot(res);
}
);

View File

@@ -0,0 +1,33 @@
import test from 'ava';
import { Request } from 'express';
import { getClientVersionFromRequest } from '../request';
test('should get client version from x-affine-version header', t => {
const req = {
headers: {
'x-affine-version': '0.22.2',
},
} as unknown as Request;
t.is(getClientVersionFromRequest(req), '0.22.2');
const req2 = {
headers: {
'x-affine-version': ['0.22.2', '0.23.0-beta.2'],
},
} as unknown as Request;
t.is(getClientVersionFromRequest(req2), '0.22.2');
});
test('should not get client version from x-affine-version header', t => {
const req = {
headers: {
'user-agent':
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.6998.205 Safari/537.36',
},
} as unknown as Request;
t.is(getClientVersionFromRequest(req), undefined);
});

View File

@@ -118,3 +118,11 @@ export function getRequestIdFromHost(host: ArgumentsHost) {
const req = getRequestFromHost(host);
return getRequestIdFromRequest(req, type);
}
export function getClientVersionFromRequest(req: Request) {
let version = req.headers['x-affine-version'];
if (Array.isArray(version)) {
version = version[0];
}
return version;
}

View File

@@ -1,17 +1,38 @@
import { registerEnumType, ResolveField, Resolver } from '@nestjs/graphql';
import {
Context,
registerEnumType,
ResolveField,
Resolver,
} from '@nestjs/graphql';
import type { Request } from 'express';
import semver from 'semver';
import { getClientVersionFromRequest } from '../../base';
import { ServerConfigType } from '../../core/config/types';
import { OAuthProviderName } from './config';
import { OAuthProviderFactory } from './factory';
registerEnumType(OAuthProviderName, { name: 'OAuthProviderType' });
const APPLE_OAUTH_PROVIDER_MIN_VERSION = new semver.Range('>=0.22.0', {
includePrerelease: true,
});
@Resolver(() => ServerConfigType)
export class OAuthResolver {
constructor(private readonly factory: OAuthProviderFactory) {}
@ResolveField(() => [OAuthProviderName])
oauthProviders() {
return this.factory.providers;
oauthProviders(@Context() ctx: { req: Request }) {
// Apple oauth provider is not supported in client version < 0.22.0
const providers = this.factory.providers;
if (providers.includes(OAuthProviderName.Apple)) {
const version = getClientVersionFromRequest(ctx.req);
if (!version || !APPLE_OAUTH_PROVIDER_MIN_VERSION.test(version)) {
return providers.filter(p => p !== OAuthProviderName.Apple);
}
}
return providers;
}
}