mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-02 02:00:49 +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);
|
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 => {
|
test('should reject sign out when csrf token mismatched', async t => {
|
||||||
const { app } = t.context;
|
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()
|
@Public()
|
||||||
@Post('/sign-out')
|
@Post('/sign-out')
|
||||||
async signOut(
|
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
|
redirectUrl?: string // url to redirect to after signed-in
|
||||||
) {
|
) {
|
||||||
track.$.$.auth.signIn({ method: 'magic-link' });
|
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 {
|
try {
|
||||||
const scheme = this.urlService.getClientScheme();
|
const scheme = this.urlService.getClientScheme();
|
||||||
const magicLinkUrlParams = new URLSearchParams();
|
const magicLinkUrlParams = new URLSearchParams();
|
||||||
@@ -80,7 +84,7 @@ export class AuthService extends Service {
|
|||||||
// we call it [callbackUrl] instead of [redirect_uri]
|
// 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
|
// 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()}`,
|
callbackUrl: `/magic-link?${magicLinkUrlParams.toString()}`,
|
||||||
client_nonce: this.store.getClientNonce(),
|
client_nonce: magicLinkClientNonce,
|
||||||
}),
|
}),
|
||||||
headers: {
|
headers: {
|
||||||
'content-type': 'application/json',
|
'content-type': 'application/json',
|
||||||
@@ -117,7 +121,8 @@ export class AuthService extends Service {
|
|||||||
client: string,
|
client: string,
|
||||||
/** @deprecated*/ redirectUrl?: string
|
/** @deprecated*/ redirectUrl?: string
|
||||||
): Promise<Record<string, string>> {
|
): Promise<Record<string, string>> {
|
||||||
this.setClientNonce();
|
// OAuth callback requires `client_nonce` for all clients (including web).
|
||||||
|
const clientNonce = this.setClientNonce();
|
||||||
try {
|
try {
|
||||||
const res = await this.fetchService.fetch('/api/oauth/preflight', {
|
const res = await this.fetchService.fetch('/api/oauth/preflight', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -125,7 +130,7 @@ export class AuthService extends Service {
|
|||||||
provider,
|
provider,
|
||||||
client,
|
client,
|
||||||
redirect_uri: redirectUrl,
|
redirect_uri: redirectUrl,
|
||||||
client_nonce: this.store.getClientNonce(),
|
client_nonce: clientNonce,
|
||||||
}),
|
}),
|
||||||
headers: {
|
headers: {
|
||||||
'content-type': 'application/json',
|
'content-type': 'application/json',
|
||||||
@@ -241,10 +246,9 @@ export class AuthService extends Service {
|
|||||||
return headers;
|
return headers;
|
||||||
}
|
}
|
||||||
|
|
||||||
private setClientNonce() {
|
private setClientNonce(): string {
|
||||||
if (BUILD_CONFIG.isNative) {
|
const nonce = nanoid();
|
||||||
// send random client nonce on native app
|
this.store.setClientNonce(nonce);
|
||||||
this.store.setClientNonce(nanoid());
|
return nonce;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user