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:
DarkSky
2026-06-01 17:11:15 +08:00
committed by GitHub
parent 5b9d51b41b
commit ce9841df9d
74 changed files with 3719 additions and 939 deletions
@@ -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;