mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-19 11:06:25 +08:00
feat(server): passkey pre-refactor (#15060)
#### PR Dependency Tree * **PR #15060** 👈 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** * OpenApp native sign-in and native session exchange (JWT) for mobile & desktop. * Centralized short-lived auth challenge store for one-time tokens. * Encrypted per-endpoint token storage and native token handlers (Android, iOS, Electron). * **Improvements** * Richer auth-method reporting (password, magic link, OAuth, passkey) and improved sign-in flows. * Hardened magic-link, OAuth, and session issuance; JWT-backed sessions and websocket JWT support. * UX tweaks: form-based password submit, OTP autocomplete, adjusted captcha flow. * **Bug Fixes** * Expanded tests and auth-state resets to avoid cross-test leakage. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -2,6 +2,7 @@ import { randomBytes } from 'node:crypto';
|
||||
|
||||
import type { TestFn } from 'ava';
|
||||
import ava from 'ava';
|
||||
import supertest from 'supertest';
|
||||
|
||||
import {
|
||||
changeEmail,
|
||||
@@ -33,6 +34,10 @@ test('change email', async t => {
|
||||
const u2Email = 'u2@affine.pro';
|
||||
|
||||
const user = await app.signupV1(u1Email);
|
||||
const signedIn = await currentUser(app);
|
||||
const jwt = signedIn?.token.token;
|
||||
t.truthy(jwt);
|
||||
|
||||
await sendChangeEmail(app, u1Email, '/email-change');
|
||||
|
||||
const changeMail = app.mails.last('ChangeEmail');
|
||||
@@ -77,7 +82,16 @@ test('change email', async t => {
|
||||
t.is(changedMail.to, u2Email);
|
||||
t.is(changedMail.props.to, u2Email);
|
||||
|
||||
await app.logout();
|
||||
const revokedCookieSession = await currentUser(app);
|
||||
t.is(revokedCookieSession, null);
|
||||
|
||||
const revokedJwtSession = await supertest(app.getHttpServer())
|
||||
.get('/api/auth/session')
|
||||
.set('Authorization', `Bearer ${jwt}`)
|
||||
.expect(200);
|
||||
t.falsy(revokedJwtSession.body.user);
|
||||
|
||||
app.clearAuth();
|
||||
await app.login({
|
||||
...user,
|
||||
email: u2Email,
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import ava, { TestFn } from 'ava';
|
||||
import Sinon from 'sinon';
|
||||
|
||||
import { SessionCache } from '../../base';
|
||||
import { AuthChallengeStore, AuthModule } from '../../core/auth';
|
||||
import { createTestingApp, TestingApp } from '../utils';
|
||||
|
||||
const test = ava as TestFn<{
|
||||
app: TestingApp;
|
||||
challenges: AuthChallengeStore;
|
||||
}>;
|
||||
|
||||
test.before(async t => {
|
||||
const app = await createTestingApp({
|
||||
imports: [AuthModule],
|
||||
});
|
||||
|
||||
t.context.app = app;
|
||||
t.context.challenges = app.get(AuthChallengeStore);
|
||||
});
|
||||
|
||||
test.beforeEach(() => {
|
||||
Sinon.restore();
|
||||
});
|
||||
|
||||
test.after.always(async t => {
|
||||
await t.context.app.close();
|
||||
});
|
||||
|
||||
test('should create and get challenge payload without consuming it', async t => {
|
||||
const token = await t.context.challenges.create(
|
||||
'oauth_state',
|
||||
{ provider: 'Google' },
|
||||
30_000
|
||||
);
|
||||
|
||||
t.deepEqual(await t.context.challenges.get('oauth_state', token), {
|
||||
provider: 'Google',
|
||||
});
|
||||
t.deepEqual(await t.context.challenges.get('oauth_state', token), {
|
||||
provider: 'Google',
|
||||
});
|
||||
});
|
||||
|
||||
test('should consume challenge payload once', async t => {
|
||||
const token = await t.context.challenges.create(
|
||||
'open_app_sign_in',
|
||||
{ userId: 'u1' },
|
||||
30_000
|
||||
);
|
||||
|
||||
t.deepEqual(await t.context.challenges.consume('open_app_sign_in', token), {
|
||||
userId: 'u1',
|
||||
});
|
||||
t.is(await t.context.challenges.consume('open_app_sign_in', token), null);
|
||||
});
|
||||
|
||||
test('should isolate challenges by purpose', async t => {
|
||||
const token = await t.context.challenges.create(
|
||||
'open_app_sign_in',
|
||||
{ userId: 'u1' },
|
||||
30_000
|
||||
);
|
||||
|
||||
t.is(await t.context.challenges.get('oauth_state', token), null);
|
||||
t.is(await t.context.challenges.consume('oauth_state', token), null);
|
||||
t.deepEqual(await t.context.challenges.consume('open_app_sign_in', token), {
|
||||
userId: 'u1',
|
||||
});
|
||||
});
|
||||
|
||||
test('should return null for expired challenge', async t => {
|
||||
const token = await t.context.challenges.create(
|
||||
'open_app_sign_in',
|
||||
{ userId: 'u1' },
|
||||
1
|
||||
);
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
|
||||
t.is(await t.context.challenges.get('open_app_sign_in', token), null);
|
||||
t.is(await t.context.challenges.consume('open_app_sign_in', token), null);
|
||||
});
|
||||
|
||||
test('should reject invalid challenge ttl', async t => {
|
||||
await t.throwsAsync(
|
||||
t.context.challenges.create('open_app_sign_in', { userId: 'u1' }, 0),
|
||||
{ message: /Invalid auth state/ }
|
||||
);
|
||||
});
|
||||
|
||||
test('should reject challenge creation when cache write fails', async t => {
|
||||
Sinon.stub(t.context.app.get(SessionCache), 'set').resolves(false);
|
||||
|
||||
await t.throwsAsync(
|
||||
t.context.challenges.create('open_app_sign_in', { userId: 'u1' }, 30_000),
|
||||
{ message: /Invalid auth state/ }
|
||||
);
|
||||
});
|
||||
|
||||
test('should atomically allow one concurrent consume', async t => {
|
||||
const token = await t.context.challenges.create(
|
||||
'open_app_sign_in',
|
||||
{ userId: 'u1' },
|
||||
30_000
|
||||
);
|
||||
|
||||
const results = await Promise.all(
|
||||
Array.from({ length: 8 }, () =>
|
||||
t.context.challenges.consume('open_app_sign_in', token)
|
||||
)
|
||||
);
|
||||
|
||||
t.is(results.filter(Boolean).length, 1);
|
||||
t.deepEqual(results.find(Boolean), { userId: 'u1' });
|
||||
});
|
||||
@@ -3,11 +3,17 @@ import { IncomingMessage } from 'node:http';
|
||||
|
||||
import { HttpStatus } from '@nestjs/common';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import ava, { TestFn } from 'ava';
|
||||
import ava, { ExecutionContext, TestFn } from 'ava';
|
||||
import Sinon from 'sinon';
|
||||
import supertest from 'supertest';
|
||||
|
||||
import { parseCookies as safeParseCookies } from '../../base/utils/request';
|
||||
import { ConfigFactory } from '../../base';
|
||||
import {
|
||||
getRequestCookie,
|
||||
getRequestHeader,
|
||||
parseCookies as safeParseCookies,
|
||||
} from '../../base/utils/request';
|
||||
import { MagicLinkAuthService } from '../../core/auth/magic-link';
|
||||
import { AuthService } from '../../core/auth/service';
|
||||
import {
|
||||
createTestingApp,
|
||||
@@ -18,7 +24,9 @@ import {
|
||||
|
||||
const test = ava as TestFn<{
|
||||
auth: AuthService;
|
||||
magicLink: MagicLinkAuthService;
|
||||
db: PrismaClient;
|
||||
config: ConfigFactory;
|
||||
app: TestingApp;
|
||||
}>;
|
||||
|
||||
@@ -26,13 +34,18 @@ test.before(async t => {
|
||||
const app = await createTestingApp();
|
||||
|
||||
t.context.auth = app.get(AuthService);
|
||||
t.context.magicLink = app.get(MagicLinkAuthService);
|
||||
t.context.db = app.get(PrismaClient);
|
||||
t.context.config = app.get(ConfigFactory);
|
||||
t.context.app = app;
|
||||
});
|
||||
|
||||
test.beforeEach(async t => {
|
||||
Sinon.reset();
|
||||
await t.context.app.initTestingDB();
|
||||
t.context.config.override({
|
||||
auth: { allowSignup: true, requireEmailDomainVerification: false },
|
||||
});
|
||||
});
|
||||
|
||||
test.after.always(async t => {
|
||||
@@ -44,15 +57,102 @@ test('should be able to sign in with credential', async t => {
|
||||
|
||||
const u1 = await app.createUser('u1@affine.pro');
|
||||
|
||||
await app
|
||||
const res = await app
|
||||
.POST('/api/auth/sign-in')
|
||||
.send({ email: u1.email, password: u1.password })
|
||||
.expect(200);
|
||||
|
||||
t.is(res.body.id, u1.id);
|
||||
t.falsy(res.body.token);
|
||||
t.falsy(res.body.expiresAt);
|
||||
|
||||
const session = await currentUser(app);
|
||||
t.is(session?.id, u1.id);
|
||||
});
|
||||
|
||||
async function exchangeSession(app: TestingApp, code: string) {
|
||||
return await supertest(app.getHttpServer())
|
||||
.post('/api/auth/native/exchange')
|
||||
.set('x-affine-client-kind', 'native')
|
||||
.send({ code })
|
||||
.expect(201);
|
||||
}
|
||||
|
||||
function assertClearsNativeAuthCookies(
|
||||
t: ExecutionContext,
|
||||
res: supertest.Response
|
||||
) {
|
||||
const setCookies = res.get('Set-Cookie') ?? [];
|
||||
for (const name of [
|
||||
AuthService.sessionCookieName,
|
||||
AuthService.userCookieName,
|
||||
AuthService.csrfCookieName,
|
||||
]) {
|
||||
t.true(
|
||||
setCookies.some(
|
||||
cookie =>
|
||||
cookie.startsWith(`${name}=;`) &&
|
||||
/Expires=Thu, 01 Jan 1970/i.test(cookie)
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
test('should issue exchange code only for native credential sign in', async t => {
|
||||
const { app } = t.context;
|
||||
|
||||
const u1 = await app.createUser('native@affine.pro');
|
||||
|
||||
const res = await app
|
||||
.POST('/api/auth/sign-in')
|
||||
.set('x-affine-client-kind', 'native')
|
||||
.send({ email: u1.email, password: u1.password })
|
||||
.expect(200);
|
||||
|
||||
t.is(res.body.id, u1.id);
|
||||
t.truthy(res.body.exchangeCode);
|
||||
assertClearsNativeAuthCookies(t, res);
|
||||
|
||||
const exchangeRes = await exchangeSession(app, res.body.exchangeCode);
|
||||
t.truthy(exchangeRes.body.token);
|
||||
t.truthy(exchangeRes.body.expiresAt);
|
||||
});
|
||||
|
||||
test('should not issue jwt for browser-origin credential sign in', async t => {
|
||||
const { app } = t.context;
|
||||
|
||||
const u1 = await app.createUser('browser@affine.pro');
|
||||
|
||||
const res = await app
|
||||
.POST('/api/auth/sign-in')
|
||||
.set('origin', 'https://app.affine.pro')
|
||||
.set('x-affine-client-kind', 'native')
|
||||
.send({ email: u1.email, password: u1.password })
|
||||
.expect(200);
|
||||
|
||||
t.is(res.body.id, u1.id);
|
||||
t.falsy(res.body.token);
|
||||
t.falsy(res.body.expiresAt);
|
||||
t.falsy(res.body.exchangeCode);
|
||||
});
|
||||
|
||||
test('should write legacy auth cookies when signing in with credential', async t => {
|
||||
const { app } = t.context;
|
||||
|
||||
const u1 = await app.createUser('u1@affine.pro');
|
||||
|
||||
const res = await app
|
||||
.POST('/api/auth/sign-in')
|
||||
.send({ email: u1.email, password: u1.password })
|
||||
.expect(200);
|
||||
|
||||
const cookies = parseCookies(res);
|
||||
|
||||
t.truthy(cookies[AuthService.sessionCookieName]);
|
||||
t.truthy(cookies[AuthService.userCookieName]);
|
||||
t.truthy(cookies[AuthService.csrfCookieName]);
|
||||
});
|
||||
|
||||
test('should record sign in client version when header is provided', async t => {
|
||||
const { app, db } = t.context;
|
||||
|
||||
@@ -81,6 +181,126 @@ test('should record sign in client version when header is provided', async t =>
|
||||
t.is(userSession2?.signInClientVersion, '0.25.1');
|
||||
});
|
||||
|
||||
test('should return method-oriented preflight for registered password users', async t => {
|
||||
const { app } = t.context;
|
||||
|
||||
const u1 = await app.createUser('u1@affine.pro');
|
||||
|
||||
const res = await app
|
||||
.POST('/api/auth/preflight')
|
||||
.send({ email: u1.email })
|
||||
.expect(201);
|
||||
|
||||
t.true(res.body.registered);
|
||||
t.deepEqual(res.body.methods.password, { available: true });
|
||||
t.deepEqual(res.body.methods.magicLink, { available: true });
|
||||
t.deepEqual(res.body.methods.passkey, {
|
||||
available: false,
|
||||
discoverable: false,
|
||||
});
|
||||
t.false('hasPassword' in res.body);
|
||||
});
|
||||
|
||||
test('should return method-oriented preflight for unknown users', async t => {
|
||||
const { app } = t.context;
|
||||
|
||||
const res = await app
|
||||
.POST('/api/auth/preflight')
|
||||
.send({ email: 'unknown@affine.pro' })
|
||||
.expect(201);
|
||||
|
||||
t.false(res.body.registered);
|
||||
t.deepEqual(res.body.methods.password, { available: false });
|
||||
t.deepEqual(res.body.methods.magicLink, { available: true });
|
||||
t.deepEqual(res.body.methods.passkey, {
|
||||
available: false,
|
||||
discoverable: false,
|
||||
});
|
||||
t.false('hasPassword' in res.body);
|
||||
});
|
||||
|
||||
test('should return password unavailable for registered users without password', async t => {
|
||||
const { app } = t.context;
|
||||
|
||||
const u1 = await app.createUser('passwordless@affine.pro', {
|
||||
password: null,
|
||||
});
|
||||
|
||||
const res = await app
|
||||
.POST('/api/auth/preflight')
|
||||
.send({ email: u1.email })
|
||||
.expect(201);
|
||||
|
||||
t.true(res.body.registered);
|
||||
t.deepEqual(res.body.methods.password, { available: false });
|
||||
t.false('hasPassword' in res.body);
|
||||
});
|
||||
|
||||
test('should return methods unavailable for disabled users', async t => {
|
||||
const { app } = t.context;
|
||||
|
||||
const u1 = await app.createUser('disabled@affine.pro', {
|
||||
disabled: true,
|
||||
});
|
||||
|
||||
const res = await app
|
||||
.POST('/api/auth/preflight')
|
||||
.send({ email: u1.email })
|
||||
.expect(201);
|
||||
|
||||
t.false(res.body.registered);
|
||||
t.deepEqual(res.body.methods.password, { available: false });
|
||||
t.deepEqual(res.body.methods.magicLink, { available: false });
|
||||
});
|
||||
|
||||
test('should return magic link unavailable for unknown users when signup is disabled', async t => {
|
||||
const { app, config } = t.context;
|
||||
|
||||
config.override({
|
||||
auth: {
|
||||
allowSignup: false,
|
||||
},
|
||||
});
|
||||
|
||||
const res = await app
|
||||
.POST('/api/auth/preflight')
|
||||
.send({ email: 'unknown@affine.pro' })
|
||||
.expect(201);
|
||||
|
||||
t.false(res.body.registered);
|
||||
t.deepEqual(res.body.methods.magicLink, { available: false });
|
||||
});
|
||||
|
||||
test('should return magic link unavailable when domain verification rejects signup email', async t => {
|
||||
const { app, config } = t.context;
|
||||
|
||||
config.override({
|
||||
auth: {
|
||||
requireEmailDomainVerification: true,
|
||||
},
|
||||
});
|
||||
|
||||
const res = await app
|
||||
.POST('/api/auth/preflight')
|
||||
.send({ email: 'unknown+alias@affine.pro' })
|
||||
.expect(201);
|
||||
|
||||
t.false(res.body.registered);
|
||||
t.deepEqual(res.body.methods.magicLink, { available: false });
|
||||
});
|
||||
|
||||
test('should return bound auth methods for current account', async t => {
|
||||
const { app } = t.context;
|
||||
|
||||
await app.signupV1('bound-methods@affine.pro');
|
||||
|
||||
const res = await app.GET('/api/auth/methods').expect(200);
|
||||
|
||||
t.deepEqual(res.body.password, { bound: true });
|
||||
t.deepEqual(res.body.oauth, { bound: false, providers: [] });
|
||||
t.deepEqual(res.body.passkey, { bound: false, count: 0 });
|
||||
});
|
||||
|
||||
test('should be able to sign in with email', async t => {
|
||||
const { app } = t.context;
|
||||
|
||||
@@ -100,7 +320,19 @@ test('should be able to sign in with email', async t => {
|
||||
const email = url.searchParams.get('email');
|
||||
const token = url.searchParams.get('token');
|
||||
|
||||
await app.POST('/api/auth/magic-link').send({ email, token }).expect(201);
|
||||
const signInRes = await app
|
||||
.POST('/api/auth/magic-link')
|
||||
.send({ email, token })
|
||||
.expect(201);
|
||||
|
||||
t.is(signInRes.body.id, u1.id);
|
||||
t.falsy(signInRes.body.token);
|
||||
t.falsy(signInRes.body.expiresAt);
|
||||
|
||||
const cookies = parseCookies(signInRes);
|
||||
t.truthy(cookies[AuthService.sessionCookieName]);
|
||||
t.truthy(cookies[AuthService.userCookieName]);
|
||||
t.truthy(cookies[AuthService.csrfCookieName]);
|
||||
|
||||
const session = await currentUser(app);
|
||||
t.is(session?.id, u1.id);
|
||||
@@ -140,6 +372,17 @@ test('should not be able to sign in if email is invalid', async t => {
|
||||
t.is(res.body.message, 'An invalid email provided: ');
|
||||
});
|
||||
|
||||
test('should not create magic-link state if email is invalid', async t => {
|
||||
const { app, magicLink } = t.context;
|
||||
|
||||
await t.throwsAsync(magicLink.send('invalid-email'), {
|
||||
message: 'An invalid email provided: invalid-email',
|
||||
});
|
||||
|
||||
t.is(app.mails.count('SignIn'), 0);
|
||||
t.is(app.mails.count('SignUp'), 0);
|
||||
});
|
||||
|
||||
test('should not be able to sign in if forbidden', async t => {
|
||||
const { app, auth } = t.context;
|
||||
|
||||
@@ -202,7 +445,7 @@ test('should be able to sign out', async t => {
|
||||
t.falsy(session);
|
||||
});
|
||||
|
||||
test('should be able to sign out when csrf header is missing (compat)', async t => {
|
||||
test('should reject cookie sign out when csrf header is missing', async t => {
|
||||
const { app } = t.context;
|
||||
|
||||
const u1 = await app.createUser('u1@affine.pro');
|
||||
@@ -220,16 +463,134 @@ test('should be able to sign out when csrf header is missing (compat)', async t
|
||||
await supertest(app.getHttpServer())
|
||||
.post('/api/auth/sign-out')
|
||||
.set('Cookie', cookieHeader)
|
||||
.expect(200);
|
||||
.expect(HttpStatus.FORBIDDEN);
|
||||
|
||||
const sessionRes = await supertest(app.getHttpServer())
|
||||
.get('/api/auth/session')
|
||||
.set('Cookie', cookieHeader)
|
||||
.expect(200);
|
||||
|
||||
t.is(sessionRes.body.user.id, u1.id);
|
||||
});
|
||||
|
||||
test('should be able to sign out with jwt without csrf', async t => {
|
||||
const { app } = t.context;
|
||||
|
||||
const u1 = await app.createUser('u1@affine.pro');
|
||||
|
||||
const signInRes = await supertest(app.getHttpServer())
|
||||
.post('/api/auth/sign-in')
|
||||
.set('x-affine-client-kind', 'native')
|
||||
.send({ email: u1.email, password: u1.password })
|
||||
.expect(200);
|
||||
const token = (await exchangeSession(app, signInRes.body.exchangeCode)).body
|
||||
.token;
|
||||
|
||||
await supertest(app.getHttpServer())
|
||||
.post('/api/auth/sign-out')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.expect(200);
|
||||
|
||||
const sessionRes = await supertest(app.getHttpServer())
|
||||
.get('/api/auth/session')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.expect(200);
|
||||
|
||||
t.falsy(sessionRes.body.user);
|
||||
});
|
||||
|
||||
test('should ignore user_id query when signing out with jwt', async t => {
|
||||
const { app } = t.context;
|
||||
|
||||
const u1 = await app.createUser('u1@affine.pro');
|
||||
const u2 = await app.createUser('u2@affine.pro');
|
||||
|
||||
const u1SignIn = await app
|
||||
.POST('/api/auth/sign-in')
|
||||
.set('x-affine-client-kind', 'native')
|
||||
.send({ email: u1.email, password: u1.password })
|
||||
.expect(200);
|
||||
const u1Token = (await exchangeSession(app, u1SignIn.body.exchangeCode)).body
|
||||
.token;
|
||||
await app
|
||||
.POST('/api/auth/sign-in')
|
||||
.send({ email: u2.email, password: u2.password })
|
||||
.expect(200);
|
||||
|
||||
await supertest(app.getHttpServer())
|
||||
.post(`/api/auth/sign-out?user_id=${u2.id}`)
|
||||
.set('Authorization', `Bearer ${u1Token}`)
|
||||
.expect(200);
|
||||
|
||||
const u1Session = await supertest(app.getHttpServer())
|
||||
.get('/api/auth/session')
|
||||
.set('Authorization', `Bearer ${u1Token}`)
|
||||
.expect(200);
|
||||
t.falsy(u1Session.body.user);
|
||||
|
||||
const cookieSession = await app.GET('/api/auth/session').expect(200);
|
||||
t.is(cookieSession.body.user.id, u2.id);
|
||||
});
|
||||
|
||||
test('should reuse jwt session when signing in another account without cookies', async t => {
|
||||
const { app } = t.context;
|
||||
|
||||
const u1 = await app.createUser('u1@affine.pro');
|
||||
const u2 = await app.createUser('u2@affine.pro');
|
||||
|
||||
const u1SignIn = await supertest(app.getHttpServer())
|
||||
.post('/api/auth/sign-in')
|
||||
.set('x-affine-client-kind', 'native')
|
||||
.send({ email: u1.email, password: u1.password })
|
||||
.expect(200);
|
||||
const u1Token = (await exchangeSession(app, u1SignIn.body.exchangeCode)).body
|
||||
.token;
|
||||
|
||||
const u2SignIn = await supertest(app.getHttpServer())
|
||||
.post('/api/auth/sign-in')
|
||||
.set('Authorization', `Bearer ${u1Token}`)
|
||||
.send({ email: u2.email, password: u2.password })
|
||||
.expect(200);
|
||||
|
||||
const u1Session = await t.context.db.userSession.findFirstOrThrow({
|
||||
where: { userId: u1.id },
|
||||
});
|
||||
const u2Session = await t.context.db.userSession.findFirstOrThrow({
|
||||
where: { userId: u2.id },
|
||||
});
|
||||
|
||||
t.is(u2SignIn.body.id, u2.id);
|
||||
t.is(u2Session.sessionId, u1Session.sessionId);
|
||||
});
|
||||
|
||||
test('should not reuse legacy bearer session id when signing in another account without cookies', async t => {
|
||||
const { app } = t.context;
|
||||
|
||||
const u1 = await app.createUser('u1@affine.pro');
|
||||
const u2 = await app.createUser('u2@affine.pro');
|
||||
|
||||
await supertest(app.getHttpServer())
|
||||
.post('/api/auth/sign-in')
|
||||
.send({ email: u1.email, password: u1.password })
|
||||
.expect(200);
|
||||
|
||||
const u1Session = await t.context.db.userSession.findFirstOrThrow({
|
||||
where: { userId: u1.id },
|
||||
});
|
||||
|
||||
await supertest(app.getHttpServer())
|
||||
.post('/api/auth/sign-in')
|
||||
.set('Authorization', `Bearer ${u1Session.sessionId}`)
|
||||
.send({ email: u2.email, password: u2.password })
|
||||
.expect(200);
|
||||
|
||||
const u2Session = await t.context.db.userSession.findFirstOrThrow({
|
||||
where: { userId: u2.id },
|
||||
});
|
||||
|
||||
t.not(u2Session.sessionId, u1Session.sessionId);
|
||||
});
|
||||
|
||||
test('should be able to sign out when duplicated csrf cookies exist', async t => {
|
||||
const { app } = t.context;
|
||||
|
||||
@@ -264,23 +625,6 @@ 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;
|
||||
|
||||
@@ -317,20 +661,20 @@ test('should sign in desktop app via one-time open-app code', async t => {
|
||||
|
||||
const exchangeRes = await supertest(app.getHttpServer())
|
||||
.post('/api/auth/open-app/sign-in')
|
||||
.set('x-affine-client-kind', 'native')
|
||||
.send({ code })
|
||||
.expect(201);
|
||||
|
||||
const exchangedCookies = exchangeRes.get('Set-Cookie') ?? [];
|
||||
t.true(
|
||||
exchangedCookies.some(c =>
|
||||
c.startsWith(`${AuthService.sessionCookieName}=`)
|
||||
)
|
||||
);
|
||||
t.is(exchangeRes.body.id, u1.id);
|
||||
t.truthy(exchangeRes.body.exchangeCode);
|
||||
assertClearsNativeAuthCookies(t, exchangeRes);
|
||||
const tokenRes = await exchangeSession(app, exchangeRes.body.exchangeCode);
|
||||
t.truthy(tokenRes.body.token);
|
||||
t.truthy(tokenRes.body.expiresAt);
|
||||
|
||||
const cookieHeader = exchangedCookies.map(c => c.split(';')[0]).join('; ');
|
||||
const sessionRes = await supertest(app.getHttpServer())
|
||||
.get('/api/auth/session')
|
||||
.set('Cookie', cookieHeader)
|
||||
.set('Authorization', `Bearer ${tokenRes.body.token}`)
|
||||
.expect(200);
|
||||
|
||||
t.is(sessionRes.body.user?.id, u1.id);
|
||||
@@ -379,6 +723,35 @@ test('should not throw on parse of a bad cookie', async t => {
|
||||
t.is(req.cookies?.[badCookieKey], badCookieVal);
|
||||
});
|
||||
|
||||
test('should only read string request cookies', t => {
|
||||
const req = {
|
||||
headers: {},
|
||||
cookies: {
|
||||
empty: '',
|
||||
list: ['session'],
|
||||
object: { value: 'session' },
|
||||
session: 'valid_session',
|
||||
},
|
||||
} as unknown as IncomingMessage & { cookies?: Record<string, unknown> };
|
||||
|
||||
t.is(getRequestCookie(req, 'session'), 'valid_session');
|
||||
t.is(getRequestCookie(req, 'empty'), undefined);
|
||||
t.is(getRequestCookie(req, 'list'), undefined);
|
||||
t.is(getRequestCookie(req, 'object'), undefined);
|
||||
});
|
||||
|
||||
test('should only read string request headers', t => {
|
||||
const req = {
|
||||
headers: {
|
||||
'x-list': ['value'],
|
||||
'x-string': 'value',
|
||||
},
|
||||
} as unknown as IncomingMessage;
|
||||
|
||||
t.is(getRequestHeader(req, 'x-string'), 'value');
|
||||
t.is(getRequestHeader(req, 'x-list'), undefined);
|
||||
});
|
||||
|
||||
// multiple accounts session tests
|
||||
test('should be able to sign in another account in one session', async t => {
|
||||
const { app } = t.context;
|
||||
@@ -400,15 +773,6 @@ test('should be able to sign in another account in one session', async t => {
|
||||
.send({ email: u2.email, password: u2.password })
|
||||
.expect(200);
|
||||
|
||||
// list [u1, u2]
|
||||
const sessions = await app.GET('/api/auth/sessions').expect(200);
|
||||
|
||||
t.is(sessions.body.users.length, 2);
|
||||
t.like(
|
||||
sessions.body.users.map((u: any) => u.id),
|
||||
[u1.id, u2.id]
|
||||
);
|
||||
|
||||
// default to latest signed in user: u2
|
||||
let session = await app.GET('/api/auth/session').expect(200);
|
||||
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import ava from 'ava';
|
||||
|
||||
import { verifyEmailDomainRecords } from '../../core/auth/email-domain';
|
||||
|
||||
const test = ava;
|
||||
|
||||
test('should verify email domain records', async t => {
|
||||
const ok = await verifyEmailDomainRecords(
|
||||
'user@example.com',
|
||||
{
|
||||
resolveMx: async () => [{ exchange: 'mx.example.com', priority: 10 }],
|
||||
resolveTxt: async domain =>
|
||||
domain === '_dmarc.example.com'
|
||||
? [['v=DMARC1; p=none']]
|
||||
: [['v=spf1 include:_spf.example.com ~all']],
|
||||
},
|
||||
100
|
||||
);
|
||||
|
||||
t.true(ok);
|
||||
});
|
||||
|
||||
test('should verify split txt record chunks', async t => {
|
||||
const ok = await verifyEmailDomainRecords(
|
||||
'user@example.com',
|
||||
{
|
||||
resolveMx: async () => [{ exchange: 'mx.example.com', priority: 10 }],
|
||||
resolveTxt: async domain =>
|
||||
domain === '_dmarc.example.com'
|
||||
? [['v=DM', 'ARC1; p=none']]
|
||||
: [['v=spf', '1 include:_spf.example.com ~all']],
|
||||
},
|
||||
100
|
||||
);
|
||||
|
||||
t.true(ok);
|
||||
});
|
||||
|
||||
test('should fail closed when email domain lookup times out', async t => {
|
||||
const ok = await verifyEmailDomainRecords(
|
||||
'user@example.com',
|
||||
{
|
||||
resolveMx: async () =>
|
||||
new Promise(resolve =>
|
||||
setTimeout(
|
||||
() => resolve([{ exchange: 'mx.example.com', priority: 10 }]),
|
||||
50
|
||||
)
|
||||
),
|
||||
resolveTxt: async () => [['v=spf1 include:_spf.example.com ~all']],
|
||||
},
|
||||
1
|
||||
);
|
||||
|
||||
t.false(ok);
|
||||
});
|
||||
@@ -5,7 +5,13 @@ import Sinon from 'sinon';
|
||||
import request from 'supertest';
|
||||
|
||||
import { CANARY_CLIENT_VERSION_MAX_AGE_DAYS, ConfigFactory } from '../../base';
|
||||
import { AuthModule, CurrentUser, Public, Session } from '../../core/auth';
|
||||
import {
|
||||
AuthModule,
|
||||
CurrentUser,
|
||||
JwtSessionService,
|
||||
Public,
|
||||
Session,
|
||||
} from '../../core/auth';
|
||||
import { AuthService } from '../../core/auth/service';
|
||||
import { Models } from '../../models';
|
||||
import { createTestingApp, TestingApp } from '../utils';
|
||||
@@ -37,6 +43,7 @@ const test = ava as TestFn<{
|
||||
app: TestingApp;
|
||||
server: any;
|
||||
auth: AuthService;
|
||||
jwtSession: JwtSessionService;
|
||||
models: Models;
|
||||
db: PrismaClient;
|
||||
config: ConfigFactory;
|
||||
@@ -53,6 +60,7 @@ test.before(async t => {
|
||||
t.context.app = app;
|
||||
t.context.server = app.getHttpServer();
|
||||
t.context.auth = app.get(AuthService);
|
||||
t.context.jwtSession = app.get(JwtSessionService);
|
||||
t.context.models = app.get(Models);
|
||||
t.context.db = app.get(PrismaClient);
|
||||
t.context.config = app.get(ConfigFactory);
|
||||
@@ -110,7 +118,7 @@ test('should not be able to visit private api if not signed in', async t => {
|
||||
t.assert(true);
|
||||
});
|
||||
|
||||
test('should be able to visit private api if signed in', async t => {
|
||||
test('should be able to visit private api with cookie session', async t => {
|
||||
const res = await request(t.context.server)
|
||||
.get('/private')
|
||||
.set('Cookie', `${AuthService.sessionCookieName}=${t.context.sessionId}`)
|
||||
@@ -119,16 +127,115 @@ test('should be able to visit private api if signed in', async t => {
|
||||
t.is(res.body.user.id, t.context.u1.id);
|
||||
});
|
||||
|
||||
test('should be able to visit private api with access token', async t => {
|
||||
const models = t.context.app.get(Models);
|
||||
const token = await models.accessToken.create({
|
||||
test('should be able to visit private api with legacy bearer session id', async t => {
|
||||
const res = await request(t.context.server)
|
||||
.get('/private')
|
||||
.set('Authorization', `Bearer ${t.context.sessionId}`)
|
||||
.expect(HttpStatus.OK);
|
||||
|
||||
t.is(res.body.user.id, t.context.u1.id);
|
||||
});
|
||||
|
||||
test('should be able to visit private api with personal access token', async t => {
|
||||
const accessToken = await t.context.models.accessToken.create({
|
||||
userId: t.context.u1.id,
|
||||
name: 'test',
|
||||
});
|
||||
|
||||
const res = await request(t.context.server)
|
||||
.get('/private')
|
||||
.set('Authorization', `Bearer ${token.token}`)
|
||||
.set('Authorization', `Bearer ${accessToken.token}`)
|
||||
.expect(HttpStatus.OK);
|
||||
|
||||
t.is(res.body.user.id, t.context.u1.id);
|
||||
});
|
||||
|
||||
test('should be able to visit private api with jwt session', async t => {
|
||||
const jwt = t.context.jwtSession.sign(t.context.u1.id, t.context.sessionId);
|
||||
|
||||
const res = await request(t.context.server)
|
||||
.get('/private')
|
||||
.set('Authorization', `Bearer ${jwt.token}`)
|
||||
.expect(HttpStatus.OK);
|
||||
|
||||
t.is(res.body.user.id, t.context.u1.id);
|
||||
});
|
||||
|
||||
test('should prefer bearer jwt over cookie session', async t => {
|
||||
const u2 = await t.context.auth.signUp('u2@affine.pro', '1');
|
||||
const u2Session = await t.context.auth.createUserSession(u2.id);
|
||||
const jwt = t.context.jwtSession.sign(u2.id, u2Session.sessionId);
|
||||
|
||||
const res = await request(t.context.server)
|
||||
.get('/private')
|
||||
.set('Cookie', `${AuthService.sessionCookieName}=${t.context.sessionId}`)
|
||||
.set('Authorization', `Bearer ${jwt.token}`)
|
||||
.expect(HttpStatus.OK);
|
||||
|
||||
t.is(res.body.user.id, u2.id);
|
||||
});
|
||||
|
||||
test('should reject jwt after its user session is deleted', async t => {
|
||||
const jwt = t.context.jwtSession.sign(t.context.u1.id, t.context.sessionId);
|
||||
|
||||
await t.context.auth.signOut(t.context.sessionId, t.context.u1.id);
|
||||
|
||||
await request(t.context.server)
|
||||
.get('/private')
|
||||
.set('Authorization', `Bearer ${jwt.token}`)
|
||||
.expect(HttpStatus.UNAUTHORIZED);
|
||||
|
||||
t.pass();
|
||||
});
|
||||
|
||||
test('should enforce client version for jwt and bearer session id auth', async t => {
|
||||
t.context.config.override({
|
||||
client: {
|
||||
versionControl: {
|
||||
enabled: true,
|
||||
requiredVersion: '>=0.25.0',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const cases = [
|
||||
{
|
||||
name: 'jwt',
|
||||
token: async () => {
|
||||
const session = await t.context.auth.createUserSession(t.context.u1.id);
|
||||
return t.context.jwtSession.sign(t.context.u1.id, session.sessionId)
|
||||
.token;
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'bearer session id',
|
||||
token: async () => {
|
||||
const session = await t.context.auth.createUserSession(t.context.u1.id);
|
||||
return session.sessionId;
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
for (const testCase of cases) {
|
||||
const res = await request(t.context.server)
|
||||
.get('/private')
|
||||
.set('Authorization', `Bearer ${await testCase.token()}`)
|
||||
.set('x-affine-version', '0.24.0')
|
||||
.expect(HttpStatus.FORBIDDEN);
|
||||
|
||||
t.is(
|
||||
res.body.message,
|
||||
'Unsupported client with version [0.24.0], required version is [>=0.25.0].',
|
||||
testCase.name
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('should fall back to cookie session on public api when jwt is invalid', async t => {
|
||||
const res = await request(t.context.server)
|
||||
.get('/public')
|
||||
.set('Cookie', `${AuthService.sessionCookieName}=${t.context.sessionId}`)
|
||||
.set('Authorization', 'Bearer invalid.jwt.token')
|
||||
.expect(HttpStatus.OK);
|
||||
|
||||
t.is(res.body.user.id, t.context.u1.id);
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import ava, { TestFn } from 'ava';
|
||||
import jwt from 'jsonwebtoken';
|
||||
|
||||
import { CryptoHelper } from '../../base/helpers';
|
||||
import {
|
||||
AuthModule,
|
||||
AuthService,
|
||||
type CurrentUser,
|
||||
JwtSessionService,
|
||||
} from '../../core/auth';
|
||||
import { Models } from '../../models';
|
||||
import { createTestingApp, TestingApp } from '../utils';
|
||||
|
||||
const test = ava as TestFn<{
|
||||
app: TestingApp;
|
||||
auth: AuthService;
|
||||
jwtSession: JwtSessionService;
|
||||
crypto: CryptoHelper;
|
||||
models: Models;
|
||||
db: PrismaClient;
|
||||
user: CurrentUser;
|
||||
sessionId: string;
|
||||
}>;
|
||||
|
||||
test.before(async t => {
|
||||
const app = await createTestingApp({
|
||||
imports: [AuthModule],
|
||||
});
|
||||
|
||||
t.context.app = app;
|
||||
t.context.auth = app.get(AuthService);
|
||||
t.context.jwtSession = app.get(JwtSessionService);
|
||||
t.context.crypto = app.get(CryptoHelper);
|
||||
t.context.models = app.get(Models);
|
||||
t.context.db = app.get(PrismaClient);
|
||||
});
|
||||
|
||||
test.beforeEach(async t => {
|
||||
await t.context.app.initTestingDB();
|
||||
|
||||
t.context.user = await t.context.auth.signUp('u1@affine.pro', '1');
|
||||
const session = await t.context.auth.createUserSession(t.context.user.id);
|
||||
t.context.sessionId = session.sessionId;
|
||||
});
|
||||
|
||||
test.after.always(async t => {
|
||||
await t.context.app.close();
|
||||
});
|
||||
|
||||
function currentJwtKey(crypto: CryptoHelper) {
|
||||
return Buffer.concat([
|
||||
Buffer.from('affine:user-session-jwt:v1:'),
|
||||
crypto.keyPair.sha256.privateKey,
|
||||
]);
|
||||
}
|
||||
|
||||
test('should sign and verify a user session jwt', async t => {
|
||||
const signed = t.context.jwtSession.sign(
|
||||
t.context.user.id,
|
||||
t.context.sessionId
|
||||
);
|
||||
|
||||
const session = await t.context.jwtSession.verify(signed.token);
|
||||
|
||||
t.is(session.user.id, t.context.user.id);
|
||||
t.is(session.sessionId, t.context.sessionId);
|
||||
t.true(signed.expiresAt.getTime() > Date.now());
|
||||
});
|
||||
|
||||
test('should reject invalid jwt cases', async t => {
|
||||
const cases: Array<{ name: string; token: string }> = [
|
||||
{
|
||||
name: 'expired token',
|
||||
token: jwt.sign(
|
||||
{ sid: t.context.sessionId, typ: 'user_session' },
|
||||
currentJwtKey(t.context.crypto),
|
||||
{
|
||||
algorithm: 'HS256',
|
||||
audience: 'affine-client',
|
||||
expiresIn: -1,
|
||||
issuer: 'affine',
|
||||
subject: t.context.user.id,
|
||||
}
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'wrong signature',
|
||||
token: jwt.sign(
|
||||
{ sid: t.context.sessionId, typ: 'user_session' },
|
||||
'wrong-key',
|
||||
{
|
||||
algorithm: 'HS256',
|
||||
audience: 'affine-client',
|
||||
expiresIn: 60,
|
||||
issuer: 'affine',
|
||||
subject: t.context.user.id,
|
||||
}
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'wrong issuer',
|
||||
token: jwt.sign(
|
||||
{ sid: t.context.sessionId, typ: 'user_session' },
|
||||
currentJwtKey(t.context.crypto),
|
||||
{
|
||||
algorithm: 'HS256',
|
||||
audience: 'affine-client',
|
||||
expiresIn: 60,
|
||||
issuer: 'other-issuer',
|
||||
subject: t.context.user.id,
|
||||
}
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'wrong audience',
|
||||
token: jwt.sign(
|
||||
{ sid: t.context.sessionId, typ: 'user_session' },
|
||||
currentJwtKey(t.context.crypto),
|
||||
{
|
||||
algorithm: 'HS256',
|
||||
audience: 'other-audience',
|
||||
expiresIn: 60,
|
||||
issuer: 'affine',
|
||||
subject: t.context.user.id,
|
||||
}
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'wrong type',
|
||||
token: jwt.sign(
|
||||
{ sid: t.context.sessionId, typ: 'personal_access_token' },
|
||||
currentJwtKey(t.context.crypto),
|
||||
{
|
||||
algorithm: 'HS256',
|
||||
audience: 'affine-client',
|
||||
expiresIn: 60,
|
||||
issuer: 'affine',
|
||||
subject: t.context.user.id,
|
||||
}
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
for (const testCase of cases) {
|
||||
await t.throwsAsync(() => t.context.jwtSession.verify(testCase.token), {
|
||||
message: 'You must sign in first to access this resource.',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('should reject jwt when its user session is missing or expired', async t => {
|
||||
const signed = t.context.jwtSession.sign(
|
||||
t.context.user.id,
|
||||
t.context.sessionId
|
||||
);
|
||||
|
||||
await t.context.auth.signOut(t.context.sessionId, t.context.user.id);
|
||||
|
||||
await t.throwsAsync(() => t.context.jwtSession.verify(signed.token), {
|
||||
message: 'You must sign in first to access this resource.',
|
||||
});
|
||||
|
||||
const refreshed = await t.context.auth.createUserSession(t.context.user.id);
|
||||
const expired = t.context.jwtSession.sign(
|
||||
t.context.user.id,
|
||||
refreshed.sessionId
|
||||
);
|
||||
await t.context.db.userSession.updateMany({
|
||||
where: {
|
||||
userId: t.context.user.id,
|
||||
sessionId: refreshed.sessionId,
|
||||
},
|
||||
data: {
|
||||
expiresAt: new Date(Date.now() - 1000),
|
||||
},
|
||||
});
|
||||
|
||||
await t.throwsAsync(() => t.context.jwtSession.verify(expired.token), {
|
||||
message: 'You must sign in first to access this resource.',
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import ava, { TestFn } from 'ava';
|
||||
|
||||
import { AuthMethodsService, AuthModule } from '../../core/auth';
|
||||
import { Models } from '../../models';
|
||||
import { createTestingApp, TestingApp } from '../utils';
|
||||
|
||||
const test = ava as TestFn<{
|
||||
app: TestingApp;
|
||||
authMethods: AuthMethodsService;
|
||||
models: Models;
|
||||
}>;
|
||||
|
||||
test.before(async t => {
|
||||
const app = await createTestingApp({
|
||||
imports: [AuthModule],
|
||||
});
|
||||
|
||||
t.context.app = app;
|
||||
t.context.authMethods = app.get(AuthMethodsService);
|
||||
t.context.models = app.get(Models);
|
||||
});
|
||||
|
||||
test.beforeEach(async t => {
|
||||
await t.context.app.initTestingDB();
|
||||
});
|
||||
|
||||
test.after.always(async t => {
|
||||
await t.context.app.close();
|
||||
});
|
||||
|
||||
test('should return login preflight methods without top-level has fields', async t => {
|
||||
const user = await t.context.app.createUser('methods@affine.pro');
|
||||
|
||||
const preflight = await t.context.authMethods.loginPreflight(user.email);
|
||||
|
||||
t.true(preflight.registered);
|
||||
t.deepEqual(preflight.methods.password, { available: true });
|
||||
t.deepEqual(preflight.methods.magicLink, { available: true });
|
||||
t.deepEqual(preflight.methods.passkey, {
|
||||
available: false,
|
||||
discoverable: false,
|
||||
});
|
||||
t.false('hasPassword' in preflight);
|
||||
});
|
||||
|
||||
test('should return bound account methods for settings', async t => {
|
||||
const user = await t.context.app.createUser('bound-methods@affine.pro');
|
||||
|
||||
await t.context.models.user.createConnectedAccount({
|
||||
userId: user.id,
|
||||
provider: 'Google',
|
||||
providerAccountId: 'google-account',
|
||||
accessToken: 'access-token',
|
||||
});
|
||||
|
||||
const methods = await t.context.authMethods.boundMethods(user.id);
|
||||
|
||||
t.deepEqual(methods.password, { bound: true });
|
||||
t.deepEqual(methods.oauth, {
|
||||
bound: true,
|
||||
providers: ['Google'],
|
||||
});
|
||||
t.deepEqual(methods.passkey, { bound: false, count: 0 });
|
||||
});
|
||||
@@ -50,6 +50,13 @@ test('should be able to set cache with ttl', async t => {
|
||||
t.true(ttl > 0);
|
||||
});
|
||||
|
||||
test('should reject invalid ttl options', async t => {
|
||||
t.false(await cache.set(key('test-invalid-ttl'), 1, { ttl: 0 }));
|
||||
t.is(await cache.get(key('test-invalid-ttl')), undefined);
|
||||
t.false(await cache.setnx(key('test-invalid-ttl-nx'), 1, { ttl: 0 }));
|
||||
t.is(await cache.get(key('test-invalid-ttl-nx')), undefined);
|
||||
});
|
||||
|
||||
test('should be able to incr/decr number cache', async t => {
|
||||
t.true(await cache.set(key('test-incr'), 1));
|
||||
t.is(await cache.increase(key('test-incr')), 2);
|
||||
|
||||
@@ -63,6 +63,14 @@ export class TestingApp extends NestApplication {
|
||||
await this.close();
|
||||
}
|
||||
|
||||
clearAuth() {
|
||||
this.resetRateLimit();
|
||||
this.sessionCookie = null;
|
||||
this.currentUserCookie = null;
|
||||
this.csrfCookie = null;
|
||||
this.userCookies.clear();
|
||||
}
|
||||
|
||||
request(
|
||||
method: 'options' | 'get' | 'post' | 'put' | 'delete' | 'patch',
|
||||
path: string
|
||||
|
||||
@@ -7,7 +7,7 @@ import { app, e2e, Mockers } from '../test';
|
||||
e2e('user(email) should return null without auth', async t => {
|
||||
const user = await app.create(Mockers.User);
|
||||
|
||||
await app.logout();
|
||||
app.clearAuth();
|
||||
|
||||
const res = await app.gql({
|
||||
query: getUserQuery,
|
||||
@@ -18,7 +18,7 @@ e2e('user(email) should return null without auth', async t => {
|
||||
});
|
||||
|
||||
e2e('user(email) should return null outside workspace scope', async t => {
|
||||
await app.logout();
|
||||
app.clearAuth();
|
||||
const me = await app.signup();
|
||||
const other = await app.create(Mockers.User);
|
||||
|
||||
@@ -43,7 +43,7 @@ e2e('user(email) should return null outside workspace scope', async t => {
|
||||
});
|
||||
|
||||
e2e('user(email) should return user within workspace scope', async t => {
|
||||
await app.logout();
|
||||
app.clearAuth();
|
||||
const me = await app.signup();
|
||||
const other = await app.create(Mockers.User);
|
||||
const ws = await app.create(Mockers.Workspace, { owner: me });
|
||||
@@ -67,7 +67,7 @@ e2e('user(email) should return user within workspace scope', async t => {
|
||||
});
|
||||
|
||||
e2e('user(email) should be rate limited', async t => {
|
||||
await app.logout();
|
||||
app.clearAuth();
|
||||
const me = await app.signup();
|
||||
|
||||
const stub = Sinon.stub(app.get(ThrottlerStorage), 'increment').resolves({
|
||||
|
||||
@@ -7,6 +7,7 @@ import Sinon from 'sinon';
|
||||
|
||||
import { AppModule } from '../../app.module';
|
||||
import { ConfigFactory, InvalidOauthResponse, URLHelper } from '../../base';
|
||||
import { SessionCache } from '../../base/cache';
|
||||
import { ConfigModule } from '../../base/config';
|
||||
import { CurrentUser } from '../../core/auth';
|
||||
import { AuthService } from '../../core/auth/service';
|
||||
@@ -23,6 +24,7 @@ import { createTestingApp, currentUser, TestingApp } from '../utils';
|
||||
const test = ava as TestFn<{
|
||||
auth: AuthService;
|
||||
oauth: OAuthService;
|
||||
cache: SessionCache;
|
||||
models: Models;
|
||||
u1: CurrentUser;
|
||||
db: PrismaClient;
|
||||
@@ -62,6 +64,7 @@ test.before(async t => {
|
||||
|
||||
t.context.auth = app.get(AuthService);
|
||||
t.context.oauth = app.get(OAuthService);
|
||||
t.context.cache = app.get(SessionCache);
|
||||
t.context.models = app.get(Models);
|
||||
t.context.db = app.get(PrismaClient);
|
||||
t.context.app = app;
|
||||
@@ -244,6 +247,7 @@ test('should forbid preflight with untrusted redirect_uri', async t => {
|
||||
|
||||
test('should throw if client_nonce is missing in preflight', async t => {
|
||||
const { app } = t.context;
|
||||
app.clearAuth();
|
||||
|
||||
await app
|
||||
.POST('/api/oauth/preflight')
|
||||
@@ -293,6 +297,19 @@ test('should be able to save oauth state', async t => {
|
||||
t.is(state!.provider, OAuthProviderName.Google);
|
||||
});
|
||||
|
||||
test('should save oauth state with three hour ttl', async t => {
|
||||
const { cache, oauth } = t.context;
|
||||
|
||||
const id = await oauth.saveOAuthState({
|
||||
provider: OAuthProviderName.Google,
|
||||
});
|
||||
|
||||
const ttl = await cache.ttl(`auth_challenge:oauth_state:${id}`);
|
||||
|
||||
t.true(ttl > 2 * 3600);
|
||||
t.true(ttl <= 3 * 3600);
|
||||
});
|
||||
|
||||
test('should be able to get registered oauth providers', async t => {
|
||||
const { oauth } = t.context;
|
||||
|
||||
@@ -550,12 +567,40 @@ test('should be able to sign up with oauth', async t => {
|
||||
|
||||
const clientNonce = mockOAuthProvider(app, 'u2@affine.pro');
|
||||
|
||||
await app
|
||||
const res = await app
|
||||
.POST('/api/oauth/callback')
|
||||
.set('x-affine-client-kind', 'native')
|
||||
.send({ code: '1', state: '1', client_nonce: clientNonce })
|
||||
.expect(HttpStatus.OK);
|
||||
|
||||
const sessionUser = await currentUser(app);
|
||||
t.truthy(res.body.exchangeCode);
|
||||
const tokenRes = await app
|
||||
.POST('/api/auth/native/exchange')
|
||||
.set('x-affine-client-kind', 'native')
|
||||
.send({ code: res.body.exchangeCode })
|
||||
.expect(201);
|
||||
t.truthy(tokenRes.body.token);
|
||||
t.truthy(tokenRes.body.expiresAt);
|
||||
const setCookies = res.get('Set-Cookie') ?? [];
|
||||
for (const name of [
|
||||
AuthService.sessionCookieName,
|
||||
AuthService.userCookieName,
|
||||
AuthService.csrfCookieName,
|
||||
]) {
|
||||
t.true(
|
||||
setCookies.some(
|
||||
cookie =>
|
||||
cookie.startsWith(`${name}=;`) &&
|
||||
/Expires=Thu, 01 Jan 1970/i.test(cookie)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const sessionUserRes = await app
|
||||
.GET('/api/auth/session')
|
||||
.set('Authorization', `Bearer ${tokenRes.body.token}`)
|
||||
.expect(200);
|
||||
const sessionUser = sessionUserRes.body.user;
|
||||
|
||||
t.truthy(sessionUser);
|
||||
t.is(sessionUser!.email, 'u2@affine.pro');
|
||||
|
||||
@@ -60,14 +60,17 @@ async function withTimeout<T>(
|
||||
}
|
||||
}
|
||||
|
||||
function createClient(url: string, cookie: string): SocketIOClient {
|
||||
function createClient(
|
||||
url: string,
|
||||
cookie?: string,
|
||||
auth?: Record<string, unknown>
|
||||
): SocketIOClient {
|
||||
return io(url, {
|
||||
transports: ['websocket'],
|
||||
reconnection: false,
|
||||
forceNew: true,
|
||||
extraHeaders: {
|
||||
cookie,
|
||||
},
|
||||
...(cookie ? { extraHeaders: { cookie } } : {}),
|
||||
...(auth ? { auth } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -146,14 +149,24 @@ function expectNoEvent(
|
||||
|
||||
async function login(app: TestingApp) {
|
||||
const user = await app.createUser();
|
||||
const res = await app
|
||||
const cookieRes = await app
|
||||
.POST('/api/auth/sign-in')
|
||||
.send({ email: user.email, password: user.password })
|
||||
.expect(200);
|
||||
const nativeRes = await app
|
||||
.POST('/api/auth/sign-in')
|
||||
.set('x-affine-client-kind', 'native')
|
||||
.send({ email: user.email, password: user.password })
|
||||
.expect(200);
|
||||
const tokenRes = await app
|
||||
.POST('/api/auth/native/exchange')
|
||||
.set('x-affine-client-kind', 'native')
|
||||
.send({ code: nativeRes.body.exchangeCode })
|
||||
.expect(201);
|
||||
|
||||
const cookies = res.get('Set-Cookie') ?? [];
|
||||
const cookies = cookieRes.get('Set-Cookie') ?? [];
|
||||
const cookieHeader = cookies.map(c => c.split(';')[0]).join('; ');
|
||||
return { user, cookieHeader };
|
||||
return { user, cookieHeader, token: tokenRes.body.token as string };
|
||||
}
|
||||
|
||||
function createYjsUpdateBase64() {
|
||||
@@ -217,6 +230,52 @@ test.after.always(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
test('should reject websocket legacy session token auth', async t => {
|
||||
const { cookieHeader } = await login(app);
|
||||
const sessionCookie = cookieHeader
|
||||
.split('; ')
|
||||
.find(cookie => cookie.startsWith('affine_session='));
|
||||
const token = sessionCookie?.split('=')[1];
|
||||
t.truthy(token);
|
||||
|
||||
const socket = createClient(url, undefined, { token });
|
||||
|
||||
try {
|
||||
await t.throwsAsync(() => waitForConnect(socket));
|
||||
} finally {
|
||||
socket.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
test('should connect websocket with jwt auth', async t => {
|
||||
const { token } = await login(app);
|
||||
const socket = createClient(url, undefined, { token, tokenType: 'jwt' });
|
||||
|
||||
try {
|
||||
await waitForConnect(socket);
|
||||
t.true(socket.connected);
|
||||
} finally {
|
||||
socket.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
test('should reject websocket jwt auth after session deletion', async t => {
|
||||
const { token } = await login(app);
|
||||
|
||||
await app
|
||||
.POST('/api/auth/sign-out')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.expect(200);
|
||||
|
||||
const socket = createClient(url, undefined, { token, tokenType: 'jwt' });
|
||||
|
||||
try {
|
||||
await t.throwsAsync(() => waitForConnect(socket));
|
||||
} finally {
|
||||
socket.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
test('clientVersion=0.25.0 should only receive space:broadcast-doc-update', async t => {
|
||||
const { user, cookieHeader } = await login(app);
|
||||
const spaceId = user.id;
|
||||
|
||||
@@ -110,6 +110,10 @@ export class TestingApp extends ApplyType<INestApplication>() {
|
||||
|
||||
async initTestingDB() {
|
||||
await initTestingDB(this);
|
||||
this.clearAuth();
|
||||
}
|
||||
|
||||
clearAuth() {
|
||||
this.sessionCookie = null;
|
||||
this.currentUserCookie = null;
|
||||
this.csrfCookie = null;
|
||||
|
||||
Reference in New Issue
Block a user