mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-05-08 22:07:32 +08:00
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:
@@ -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;
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user