diff --git a/packages/backend/server/src/__tests__/auth/controller.spec.ts b/packages/backend/server/src/__tests__/auth/controller.spec.ts index 8366462d4..a38cbaa26 100644 --- a/packages/backend/server/src/__tests__/auth/controller.spec.ts +++ b/packages/backend/server/src/__tests__/auth/controller.spec.ts @@ -264,6 +264,23 @@ test('should be able to sign out when duplicated csrf cookies exist', async t => t.falsy(sessionRes.body.user); }); +test('should be able to sign out via GET /api/auth/sign-out (deprecated)', async t => { + const { app } = t.context; + + const u1 = await app.createUser('u1@affine.pro'); + + await app + .POST('/api/auth/sign-in') + .send({ email: u1.email, password: u1.password }) + .expect(200); + + const res = await app.GET('/api/auth/sign-out').expect(200); + t.is(res.headers.deprecation, 'true'); + + const session = await currentUser(app); + t.falsy(session); +}); + test('should reject sign out when csrf token mismatched', async t => { const { app } = t.context; diff --git a/packages/backend/server/src/core/auth/controller.ts b/packages/backend/server/src/core/auth/controller.ts index fe02cf38e..af8940d7d 100644 --- a/packages/backend/server/src/core/auth/controller.ts +++ b/packages/backend/server/src/core/auth/controller.ts @@ -228,6 +228,30 @@ export class AuthController { }); } + @Public() + /** + * @deprecated Kept for 0.25 clients that still call GET `/api/auth/sign-out`. + * Use POST `/api/auth/sign-out` instead. + */ + @Get('/sign-out') + async signOutDeprecated( + @Res() res: Response, + @Session() session: Session | undefined, + @Query('user_id') userId: string | undefined + ) { + res.setHeader('Deprecation', 'true'); + + if (!session) { + res.status(HttpStatus.OK).send({}); + return; + } + + await this.auth.signOut(session.sessionId, userId); + await this.auth.refreshCookies(res, session.sessionId); + + res.status(HttpStatus.OK).send({}); + } + @Public() @Post('/sign-out') async signOut( diff --git a/packages/frontend/core/src/__tests__/auth-client-nonce.spec.ts b/packages/frontend/core/src/__tests__/auth-client-nonce.spec.ts new file mode 100644 index 000000000..6968920e5 --- /dev/null +++ b/packages/frontend/core/src/__tests__/auth-client-nonce.spec.ts @@ -0,0 +1,60 @@ +import { AuthSession } from '@affine/core/modules/cloud/entities/session'; +import { AuthService } from '@affine/core/modules/cloud/services/auth'; +import { FetchService } from '@affine/core/modules/cloud/services/fetch'; +import { AuthStore } from '@affine/core/modules/cloud/stores/auth'; +import { GlobalDialogService } from '@affine/core/modules/dialogs/services/dialog'; +import { UrlService } from '@affine/core/modules/url/services/url'; +import { Framework } from '@toeverything/infra'; +import { of } from 'rxjs'; +import { describe, expect, test, vi } from 'vitest'; + +describe('AuthService oauthPreflight', () => { + test('should always send client_nonce on web', async () => { + let nonce: string | undefined; + + const fetch = vi.fn(async (_input: string, _init?: RequestInit) => { + return { + json: async () => ({ url: 'https://example.com' }), + } as any; + }); + + const framework = new Framework(); + + framework.entity( + AuthSession, + () => + ({ + account$: of(null), + revalidate: vi.fn(), + }) as any + ); + framework.service(FetchService, { fetch } as any); + framework.store(AuthStore, { + getClientNonce: () => nonce, + setClientNonce: (n: string) => { + nonce = n; + }, + } as any); + framework.service(UrlService, { getClientScheme: () => null } as any); + framework.service(GlobalDialogService, { open: vi.fn() } as any); + + framework.service(AuthService, [ + FetchService, + AuthStore, + UrlService, + GlobalDialogService, + ]); + + const auth = framework.provider().get(AuthService); + await auth.oauthPreflight('Google' as any, 'web'); + + expect(fetch).toHaveBeenCalledOnce(); + const [, init] = fetch.mock.calls[0] as [ + string, + (RequestInit & { body?: string })?, + ]; + const body = JSON.parse(init?.body ?? '{}') as Record; + expect(body.client_nonce).toBeTypeOf('string'); + expect((body.client_nonce as string).length).toBeGreaterThan(0); + }); +}); diff --git a/packages/frontend/core/src/modules/cloud/services/auth.ts b/packages/frontend/core/src/modules/cloud/services/auth.ts index afabd9abd..15822be2b 100644 --- a/packages/frontend/core/src/modules/cloud/services/auth.ts +++ b/packages/frontend/core/src/modules/cloud/services/auth.ts @@ -63,7 +63,11 @@ export class AuthService extends Service { redirectUrl?: string // url to redirect to after signed-in ) { track.$.$.auth.signIn({ method: 'magic-link' }); - this.setClientNonce(); + // Only native clients use `client_nonce` for magic-link/otp sign-in. + // Web needs to keep cross-device magic-link compatibility. + const magicLinkClientNonce = BUILD_CONFIG.isNative + ? this.setClientNonce() + : undefined; try { const scheme = this.urlService.getClientScheme(); const magicLinkUrlParams = new URLSearchParams(); @@ -80,7 +84,7 @@ export class AuthService extends Service { // we call it [callbackUrl] instead of [redirect_uri] // to make it clear the url is used to finish the sign-in process instead of redirect after signed-in callbackUrl: `/magic-link?${magicLinkUrlParams.toString()}`, - client_nonce: this.store.getClientNonce(), + client_nonce: magicLinkClientNonce, }), headers: { 'content-type': 'application/json', @@ -117,7 +121,8 @@ export class AuthService extends Service { client: string, /** @deprecated*/ redirectUrl?: string ): Promise> { - this.setClientNonce(); + // OAuth callback requires `client_nonce` for all clients (including web). + const clientNonce = this.setClientNonce(); try { const res = await this.fetchService.fetch('/api/oauth/preflight', { method: 'POST', @@ -125,7 +130,7 @@ export class AuthService extends Service { provider, client, redirect_uri: redirectUrl, - client_nonce: this.store.getClientNonce(), + client_nonce: clientNonce, }), headers: { 'content-type': 'application/json', @@ -241,10 +246,9 @@ export class AuthService extends Service { return headers; } - private setClientNonce() { - if (BUILD_CONFIG.isNative) { - // send random client nonce on native app - this.store.setClientNonce(nanoid()); - } + private setClientNonce(): string { + const nonce = nanoid(); + this.store.setClientNonce(nonce); + return nonce; } }