fix: web login (#14378)

#### PR Dependency Tree


* **PR #14378** 👈

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**
* Added a deprecated GET sign-out endpoint for backward compatibility
with legacy clients.

* **Improvements**
* Updated magic-link and OAuth flows to always generate and manage
client nonces; native clients use a nonce, web preserves cross-device
behavior.

* **Tests**
* Added tests covering the deprecated sign-out flow and OAuth preflight
client_nonce handling.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
DarkSky
2026-02-06 03:05:50 +08:00
committed by GitHub
parent 5a51d447fb
commit 8c15df489b
4 changed files with 114 additions and 9 deletions

View File

@@ -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;

View File

@@ -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(

View File

@@ -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<string, unknown>;
expect(body.client_nonce).toBeTypeOf('string');
expect((body.client_nonce as string).length).toBeGreaterThan(0);
});
});

View File

@@ -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<Record<string, string>> {
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;
}
}