diff --git a/packages/backend/server/src/__tests__/e2e/create-app.ts b/packages/backend/server/src/__tests__/e2e/create-app.ts index 78bf5f467..29ab9b3e8 100644 --- a/packages/backend/server/src/__tests__/e2e/create-app.ts +++ b/packages/backend/server/src/__tests__/e2e/create-app.ts @@ -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()); diff --git a/packages/backend/server/src/__tests__/e2e/oauth/__snapshots__/resolver.spec.ts.md b/packages/backend/server/src/__tests__/e2e/oauth/__snapshots__/resolver.spec.ts.md new file mode 100644 index 000000000..11a55a434 --- /dev/null +++ b/packages/backend/server/src/__tests__/e2e/oauth/__snapshots__/resolver.spec.ts.md @@ -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', + ], + }, + } diff --git a/packages/backend/server/src/__tests__/e2e/oauth/__snapshots__/resolver.spec.ts.snap b/packages/backend/server/src/__tests__/e2e/oauth/__snapshots__/resolver.spec.ts.snap new file mode 100644 index 000000000..13e939c92 Binary files /dev/null and b/packages/backend/server/src/__tests__/e2e/oauth/__snapshots__/resolver.spec.ts.snap differ diff --git a/packages/backend/server/src/__tests__/e2e/oauth/resolver.spec.ts b/packages/backend/server/src/__tests__/e2e/oauth/resolver.spec.ts new file mode 100644 index 000000000..5fc05b75e --- /dev/null +++ b/packages/backend/server/src/__tests__/e2e/oauth/resolver.spec.ts @@ -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); + } +); diff --git a/packages/backend/server/src/base/utils/__tests__/request.spec.ts b/packages/backend/server/src/base/utils/__tests__/request.spec.ts new file mode 100644 index 000000000..6bd1a522e --- /dev/null +++ b/packages/backend/server/src/base/utils/__tests__/request.spec.ts @@ -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); +}); diff --git a/packages/backend/server/src/base/utils/request.ts b/packages/backend/server/src/base/utils/request.ts index af71b7830..a4da6a565 100644 --- a/packages/backend/server/src/base/utils/request.ts +++ b/packages/backend/server/src/base/utils/request.ts @@ -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; +} diff --git a/packages/backend/server/src/plugins/oauth/resolver.ts b/packages/backend/server/src/plugins/oauth/resolver.ts index b69d8f6e1..f0bab4161 100644 --- a/packages/backend/server/src/plugins/oauth/resolver.ts +++ b/packages/backend/server/src/plugins/oauth/resolver.ts @@ -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; } }