feat(server): support refresh token (#15218)

This commit is contained in:
DarkSky
2026-07-12 02:39:42 +08:00
committed by GitHub
parent aea128f0b9
commit 02b25e05d8
80 changed files with 5866 additions and 745 deletions
@@ -0,0 +1,364 @@
import { PrismaClient } from '@prisma/client';
import ava, { ExecutionContext, TestFn } from 'ava';
import jwt from 'jsonwebtoken';
import { ConfigFactory } from '../../base';
import {
AccessTokenService,
AuthModule,
AuthService,
AuthSessionService,
AuthSigningKeyRing,
type CurrentUser,
} from '../../core/auth';
import { Models } from '../../models';
import { createTestingApp, TestingApp } from '../utils';
const test = ava as TestFn<{
app: TestingApp;
auth: AuthService;
accessTokens: AccessTokenService;
keys: AuthSigningKeyRing;
authSessions: AuthSessionService;
config: ConfigFactory;
models: Models;
db: PrismaClient;
user: CurrentUser;
sessionId: string;
userSessionId: string;
authSessionId: string;
}>;
test.before(async t => {
const app = await createTestingApp({
imports: [AuthModule],
});
t.context.app = app;
t.context.auth = app.get(AuthService);
t.context.accessTokens = app.get(AccessTokenService);
t.context.keys = app.get(AuthSigningKeyRing);
t.context.authSessions = app.get(AuthSessionService);
t.context.config = app.get(ConfigFactory);
t.context.models = app.get(Models);
t.context.db = app.get(PrismaClient);
});
test.beforeEach(async t => {
await t.context.app.initTestingDB();
resetAuthSessionConfig(t.context.config);
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;
t.context.userSessionId = session.id;
const authSession = await t.context.authSessions.create({
userSessionId: session.id,
installationId: 'installation-1',
platform: 'ios',
});
t.context.authSessionId = authSession.session.id;
});
test.after.always(async t => {
await t.context.app.close();
});
const DEFAULT_ACCESS_TOKEN_TTL = 15 * 60;
function resetAuthSessionConfig(config: ConfigFactory) {
config.override({
auth: {
session: {
ttl: 60 * 60 * 24 * 15,
ttr: 60 * 60 * 24 * 7,
},
token: {
accessTokenTtl: DEFAULT_ACCESS_TOKEN_TTL,
},
},
});
}
function assertSignedTtl(
t: ExecutionContext,
signed: { token: string; expiresAt: Date },
expectedTtl: number
) {
const ttlMs = signed.expiresAt.getTime() - Date.now();
t.true(ttlMs > (expectedTtl - 5) * 1000);
t.true(ttlMs <= (expectedTtl + 1) * 1000);
const payload = jwt.decode(signed.token);
t.truthy(payload);
t.true(typeof payload !== 'string');
if (!payload || typeof payload === 'string') return;
t.is(typeof payload.iat, 'number');
t.is(typeof payload.exp, 'number');
if (typeof payload.iat !== 'number' || typeof payload.exp !== 'number') {
return;
}
t.is(payload.exp - payload.iat, expectedTtl);
}
test.serial('should sign and verify a auth-session access jwt', async t => {
const signed = await t.context.accessTokens.sign(
t.context.user.id,
t.context.authSessionId
);
const key = await t.context.keys.active();
const nativePayload = jwt.verify(signed.token, key.secret, {
algorithms: ['HS256'],
audience: 'affine-client',
issuer: 'affine',
});
const session = await t.context.accessTokens.verify(signed.token);
t.true(typeof nativePayload !== 'string');
t.is(session.user.id, t.context.user.id);
t.is(session.sessionId, t.context.sessionId);
t.is(session.authSessionId, t.context.authSessionId);
t.true(signed.expiresAt.getTime() > Date.now());
});
test.serial('should use the auth-session access-token ttl', async t => {
const defaultSigned = await t.context.accessTokens.sign(
t.context.user.id,
t.context.authSessionId
);
assertSignedTtl(t, defaultSigned, DEFAULT_ACCESS_TOKEN_TTL);
const ttl = 120;
t.context.config.override({
auth: {
token: {
accessTokenTtl: ttl,
},
},
});
const configuredSigned = await t.context.accessTokens.sign(
t.context.user.id,
t.context.authSessionId
);
assertSignedTtl(t, configuredSigned, ttl);
});
test.serial('should reject invalid jwt cases', async t => {
const key = await t.context.keys.active();
const sign = (claims: object, overrides: jwt.SignOptions = {}) =>
jwt.sign(claims, key.secret, {
algorithm: 'HS256',
audience: 'affine-client',
expiresIn: 60,
issuer: 'affine',
keyid: key.id,
subject: t.context.user.id,
...overrides,
});
const cases: Array<{ name: string; token: string; code: string }> = [
{
name: 'expired token',
token: sign(
{ sid: t.context.authSessionId, typ: 'session_access' },
{ expiresIn: -31 }
),
code: 'ACCESS_TOKEN_EXPIRED',
},
{
name: 'wrong signature',
token: jwt.sign(
{ sid: t.context.authSessionId, typ: 'session_access' },
'wrong-key',
{
algorithm: 'HS256',
audience: 'affine-client',
expiresIn: 60,
issuer: 'affine',
keyid: key.id,
subject: t.context.user.id,
}
),
code: 'ACCESS_TOKEN_INVALID',
},
{
name: 'unknown key id',
token: jwt.sign(
{ sid: t.context.authSessionId, typ: 'session_access' },
key.secret,
{
algorithm: 'HS256',
audience: 'affine-client',
expiresIn: 60,
issuer: 'affine',
keyid: 'unknown',
subject: t.context.user.id,
}
),
code: 'ACCESS_TOKEN_INVALID',
},
{
name: 'wrong algorithm',
token: jwt.sign(
{ sid: t.context.authSessionId, typ: 'session_access' },
key.secret,
{
algorithm: 'HS384',
audience: 'affine-client',
expiresIn: 60,
issuer: 'affine',
keyid: key.id,
subject: t.context.user.id,
}
),
code: 'ACCESS_TOKEN_INVALID',
},
{
name: 'wrong issuer',
token: sign(
{ sid: t.context.authSessionId, typ: 'session_access' },
{ issuer: 'other-issuer' }
),
code: 'ACCESS_TOKEN_INVALID',
},
{
name: 'wrong audience',
token: sign(
{ sid: t.context.authSessionId, typ: 'session_access' },
{ audience: 'other-audience' }
),
code: 'ACCESS_TOKEN_INVALID',
},
{
name: 'wrong type',
token: sign({
sid: t.context.authSessionId,
typ: 'user_session',
}),
code: 'ACCESS_TOKEN_INVALID',
},
{
name: 'missing time claims',
token: jwt.sign(
{ sid: t.context.authSessionId, typ: 'session_access' },
key.secret,
{
algorithm: 'HS256',
audience: 'affine-client',
issuer: 'affine',
keyid: key.id,
subject: t.context.user.id,
noTimestamp: true,
}
),
code: 'ACCESS_TOKEN_INVALID',
},
{
name: 'wrong header type',
token: jwt.sign(
{ sid: t.context.authSessionId, typ: 'session_access' },
key.secret,
{
algorithm: 'HS256',
audience: 'affine-client',
expiresIn: 60,
issuer: 'affine',
header: { alg: 'HS256', kid: key.id, typ: 'NOT_JWT' },
subject: t.context.user.id,
}
),
code: 'ACCESS_TOKEN_INVALID',
},
{
name: 'future issued-at',
token: jwt.sign(
{
sid: t.context.authSessionId,
typ: 'session_access',
iat: Math.floor(Date.now() / 1000) + 31,
},
key.secret,
{
algorithm: 'HS256',
audience: 'affine-client',
expiresIn: 60,
issuer: 'affine',
keyid: key.id,
subject: t.context.user.id,
}
),
code: 'ACCESS_TOKEN_INVALID',
},
];
for (const testCase of cases) {
const error = await t.throwsAsync(() =>
t.context.accessTokens.verify(testCase.token)
);
t.is(error?.message, testCase.code, testCase.name);
}
});
test.serial(
'should reject jwt when its auth or user session is invalid',
async t => {
const signed = await t.context.accessTokens.sign(
t.context.user.id,
t.context.authSessionId
);
await t.context.authSessions.revoke(t.context.authSessionId, 'test');
const revoked = await t.throwsAsync(() =>
t.context.accessTokens.verify(signed.token)
);
t.is(revoked?.message, 'AUTH_SESSION_REVOKED');
const refreshed = await t.context.auth.createUserSession(t.context.user.id);
const authSession = await t.context.authSessions.create({
userSessionId: refreshed.id,
installationId: 'installation-2',
platform: 'android',
});
const expired = await t.context.accessTokens.sign(
t.context.user.id,
authSession.session.id
);
await t.context.db.userSession.updateMany({
where: {
userId: t.context.user.id,
sessionId: refreshed.sessionId,
},
data: {
expiresAt: new Date(Date.now() - 1000),
},
});
const expiredError = await t.throwsAsync(() =>
t.context.accessTokens.verify(expired.token)
);
t.is(expiredError?.message, 'AUTH_SESSION_EXPIRED');
}
);
for (const expiry of ['idleExpiresAt', 'absoluteExpiresAt'] as const) {
test.serial(
`should reject access jwt after auth-session ${expiry}`,
async t => {
const signed = await t.context.accessTokens.sign(
t.context.user.id,
t.context.authSessionId
);
await t.context.db.authSession.update({
where: { id: t.context.authSessionId },
data: { [expiry]: new Date(Date.now() - 1000) },
});
const error = await t.throwsAsync(() =>
t.context.accessTokens.verify(signed.token)
);
t.is(error?.message, 'AUTH_SESSION_EXPIRED');
}
);
}
@@ -0,0 +1,662 @@
import { createHash } from 'node:crypto';
import { Prisma, PrismaClient } from '@prisma/client';
import ava, { TestFn } from 'ava';
import { Cache, ConfigFactory, EventBus } from '../../base';
import {
AuthModule,
AuthService,
AuthSessionService,
AuthSigningKeyRing,
} from '../../core/auth';
import { Models } from '../../models';
import { createTestingApp, TestingApp } from '../utils';
const test = ava as TestFn<{
app: TestingApp;
auth: AuthService;
cache: Cache;
config: ConfigFactory;
db: PrismaClient;
event: EventBus;
authSessions: AuthSessionService;
signingKeys: AuthSigningKeyRing;
models: Models;
userId: string;
userSessionId: string;
}>;
test.before(async t => {
const app = await createTestingApp({ imports: [AuthModule] });
t.context.app = app;
t.context.auth = app.get(AuthService);
t.context.cache = app.get(Cache);
t.context.config = app.get(ConfigFactory);
t.context.db = app.get(PrismaClient);
t.context.event = app.get(EventBus);
t.context.authSessions = app.get(AuthSessionService);
t.context.signingKeys = app.get(AuthSigningKeyRing);
t.context.models = app.get(Models);
});
test.beforeEach(async t => {
await t.context.app.initTestingDB();
t.context.config.override({
auth: {
token: {
accessTokenTtl: 900,
refreshIdleTtl: 30 * 24 * 60 * 60,
refreshAbsoluteTtl: 180 * 24 * 60 * 60,
refreshGracePeriod: 30,
refreshRetention: 30 * 24 * 60 * 60,
},
},
});
const user = await t.context.auth.signUp('auth-session@affine.pro', '1');
const userSession = await t.context.auth.createUserSession(user.id);
t.context.userId = user.id;
t.context.userSessionId = userSession.id;
});
test.after.always(async t => {
await t.context.app.close();
});
test.serial('stores only a hash of the refresh token', async t => {
const issued = await t.context.authSessions.create({
userSessionId: t.context.userSessionId,
installationId: 'installation-1',
platform: 'ios',
});
const persisted = await t.context.db.authRefreshToken.findFirstOrThrow();
t.regex(issued.refreshToken, /^aff_rt_v1\.[^.]+\.[^.]+$/);
t.not(persisted.secretHash, issued.refreshToken);
t.false(issued.refreshToken.includes(persisted.secretHash));
const secret = issued.refreshToken.split('.')[2];
t.is(
persisted.secretHash,
createHash('sha256').update(Buffer.from(secret, 'base64url')).digest('hex')
);
});
test.serial('emits token-free security policy events', async t => {
const events: Events['auth.security.detected'][] = [];
const dispose = t.context.event.on('auth.security.detected', event => {
events.push(event);
});
const issued = await t.context.authSessions.create({
userSessionId: t.context.userSessionId,
installationId: 'installation-security',
platform: 'ios',
});
await t.context.authSessions.revokeUserSessions(
t.context.userId,
'security_action'
);
await new Promise(resolve => setImmediate(resolve));
dispose();
t.deepEqual(
events.map(event => event.type),
['new_device_login', 'sessions_revoked']
);
t.false(JSON.stringify(events).includes(issued.refreshToken));
});
test.serial('emits new-device policy only for a new installation', async t => {
const events: Events['auth.security.detected'][] = [];
const dispose = t.context.event.on('auth.security.detected', event => {
if (event.type === 'new_device_login') events.push(event);
});
const input = {
userSessionId: t.context.userSessionId,
installationId: 'same-installation',
platform: 'ios',
};
await t.context.authSessions.create(input);
const secondParent = await t.context.auth.createUserSession(t.context.userId);
await t.context.authSessions.create({
...input,
userSessionId: secondParent.id,
});
await new Promise(resolve => setImmediate(resolve));
dispose();
t.is(events.length, 1);
});
test.serial(
'emits one new-device event for concurrent session creation',
async t => {
const events: Events['auth.security.detected'][] = [];
const dispose = t.context.event.on('auth.security.detected', event => {
if (event.type === 'new_device_login') events.push(event);
});
const secondParent = await t.context.auth.createUserSession(
t.context.userId
);
await Promise.all([
t.context.authSessions.create({
userSessionId: t.context.userSessionId,
installationId: 'concurrent-installation',
platform: 'ios',
}),
t.context.authSessions.create({
userSessionId: secondParent.id,
installationId: 'concurrent-installation',
platform: 'ios',
}),
]);
await new Promise(resolve => setImmediate(resolve));
dispose();
t.is(events.length, 1);
}
);
test.serial(
'revokes auth sessions before user deletion or disable',
async t => {
await t.context.authSessions.create({
userSessionId: t.context.userSessionId,
installationId: 'deleted-user-device',
platform: 'android',
});
const detected = t.context.event.waitFor('auth.security.detected', 1000);
await t.context.models.user.delete(t.context.userId);
const [event] = (await detected) as [Events['auth.security.detected']];
t.is(event.type, 'sessions_revoked');
t.is(event.reason, 'user_deleted_or_disabled');
t.is(
await t.context.db.authSession.count({
where: { userSession: { userId: t.context.userId } },
}),
0
);
}
);
test.serial('rotates refresh tokens and revokes reuse after grace', async t => {
const issued = await t.context.authSessions.create({
userSessionId: t.context.userSessionId,
installationId: 'installation-1',
platform: 'android',
});
const concurrent = await Promise.all([
t.context.authSessions.refresh(issued.refreshToken, '0.27.0'),
t.context.authSessions.refresh(issued.refreshToken, '0.27.0'),
]);
t.deepEqual(
concurrent.map(result => result.status),
['rotated', 'rotated']
);
if (
concurrent[0].status !== 'rotated' ||
concurrent[1].status !== 'rotated'
) {
return;
}
t.is(concurrent[0].refreshToken, concurrent[1].refreshToken);
const next = await t.context.authSessions.refresh(
concurrent[0].refreshToken,
'0.27.0'
);
t.is(next.status, 'rotated');
const replay = await t.context.authSessions.refresh(issued.refreshToken);
t.is(replay.status, 'reused');
if (replay.status === 'reused') {
t.is(replay.code, 'REFRESH_TOKEN_REUSED');
}
const session = await t.context.db.authSession.findUniqueOrThrow({
where: { id: issued.session.id },
});
t.truthy(session.revokedAt);
t.is(session.revokeReason, 'refresh_token_reused');
});
test.serial('bounds a refresh-token concurrency storm', async t => {
const issued = await t.context.authSessions.create({
userSessionId: t.context.userSessionId,
installationId: 'installation-1',
platform: 'android',
});
const results = await Promise.all(
Array.from({ length: 100 }, () =>
t.context.authSessions.refresh(issued.refreshToken, '0.27.0')
)
);
const rotated = results.filter(result => result.status === 'rotated');
t.is(rotated.length, 2);
if (rotated.length !== 2) return;
t.is(rotated[0].refreshToken, rotated[1].refreshToken);
t.true(
results.some(
result => result.status === 'reused' || result.status === 'revoked'
)
);
const session = await t.context.db.authSession.findUniqueOrThrow({
where: { id: issued.session.id },
});
t.is(session.revokeReason, 'refresh_token_reused');
});
test.serial(
'does not return an unpersisted token when the grace cache is lost',
async t => {
const issued = await t.context.authSessions.create({
userSessionId: t.context.userSessionId,
installationId: 'installation-1',
platform: 'ios',
});
const sourceTokenId = issued.refreshToken.split('.')[1];
const rotated = await t.context.authSessions.refresh(issued.refreshToken);
t.is(rotated.status, 'rotated');
if (rotated.status !== 'rotated') return;
await t.context.cache.delete(`auth:session-refresh:${sourceTokenId}`);
const retry = await t.context.authSessions.refresh(issued.refreshToken);
t.deepEqual(retry, {
status: 'temporarily_unavailable',
code: 'AUTH_SESSION_TEMPORARILY_UNAVAILABLE',
});
t.is(
(await t.context.authSessions.refresh(rotated.refreshToken)).status,
'rotated'
);
}
);
test.serial('rejects expired and revoked auth sessions', async t => {
const issued = await t.context.authSessions.create({
userSessionId: t.context.userSessionId,
installationId: 'installation-1',
platform: 'electron',
});
await t.context.db.authSession.update({
where: { id: issued.session.id },
data: { idleExpiresAt: new Date(Date.now() - 1000) },
});
const expired = await t.context.authSessions.refresh(issued.refreshToken);
t.is(expired.status, 'expired');
if (expired.status === 'expired') {
t.is(expired.code, 'AUTH_SESSION_EXPIRED');
}
await t.context.authSessions.revoke(issued.session.id, 'user_action');
const revoked = await t.context.authSessions.refresh(issued.refreshToken);
t.is(revoked.status, 'revoked');
if (revoked.status === 'revoked') {
t.is(revoked.code, 'AUTH_SESSION_REVOKED');
}
});
test.serial('revokes refresh for a disabled user', async t => {
const issued = await t.context.authSessions.create({
userSessionId: t.context.userSessionId,
installationId: 'installation-disabled',
platform: 'ios',
});
await t.context.db.user.update({
where: { id: t.context.userId },
data: { disabled: true },
});
const result = await t.context.authSessions.refresh(issued.refreshToken);
t.deepEqual(result, {
status: 'revoked',
code: 'AUTH_SESSION_REVOKED',
});
const session = await t.context.db.authSession.findUniqueOrThrow({
where: { id: issued.session.id },
});
t.is(session.revokeReason, 'user_disabled');
});
for (const expiry of [
'absolute session',
'refresh token',
'parent user session',
] as const) {
test.serial(`rejects ${expiry} expiry`, async t => {
const issued = await t.context.authSessions.create({
userSessionId: t.context.userSessionId,
installationId: 'installation-1',
platform: 'electron',
});
const expiredAt = new Date(Date.now() - 1000);
if (expiry === 'absolute session') {
await t.context.db.authSession.update({
where: { id: issued.session.id },
data: { absoluteExpiresAt: expiredAt },
});
} else if (expiry === 'refresh token') {
await t.context.db.authRefreshToken.updateMany({
where: { authSessionId: issued.session.id },
data: { expiresAt: expiredAt },
});
} else {
await t.context.db.userSession.update({
where: { id: t.context.userSessionId },
data: { expiresAt: expiredAt },
});
}
const result = await t.context.authSessions.refresh(issued.refreshToken);
t.deepEqual(result, {
status: 'expired',
code: 'AUTH_SESSION_EXPIRED',
});
});
}
test.serial(
'returns a stable code for malformed refresh credentials',
async t => {
const result = await t.context.authSessions.refresh('invalid');
t.deepEqual(result, {
status: 'invalid',
code: 'REFRESH_TOKEN_INVALID',
});
}
);
test.serial(
'deleting the user session cascades to auth-session credentials',
async t => {
const issued = await t.context.authSessions.create({
userSessionId: t.context.userSessionId,
installationId: 'installation-1',
platform: 'ios',
});
await t.context.db.userSession.delete({
where: { id: t.context.userSessionId },
});
t.is(
await t.context.db.authSession.findUnique({
where: { id: issued.session.id },
}),
null
);
t.is(await t.context.db.authRefreshToken.count(), 0);
}
);
test.serial('cleans auth sessions after the retention window', async t => {
const issued = await t.context.authSessions.create({
userSessionId: t.context.userSessionId,
installationId: 'installation-1',
platform: 'ios',
});
await t.context.db.authSession.update({
where: { id: issued.session.id },
data: {
absoluteExpiresAt: new Date(Date.now() - 31 * 24 * 60 * 60 * 1000),
},
});
t.is(await t.context.authSessions.cleanup(), 1);
t.is(await t.context.db.authSession.count(), 0);
t.is(await t.context.db.authRefreshToken.count(), 0);
});
test.serial('rolls back a failed refresh generation insert', async t => {
const issued = await t.context.authSessions.create({
userSessionId: t.context.userSessionId,
installationId: 'installation-1',
platform: 'ios',
});
const current = await t.context.db.authRefreshToken.findFirstOrThrow({
where: { authSessionId: issued.session.id },
});
await t.throwsAsync(() =>
t.context.models.authSession.rotate({
id: current.id,
secretHash: current.secretHash,
now: new Date(),
idleExpiresAt: new Date(Date.now() + 60_000),
graceMs: 30_000,
next: {
id: current.id,
secretHash: current.secretHash,
expiresAt: new Date(Date.now() + 60_000),
},
})
);
const after = await t.context.db.authRefreshToken.findUniqueOrThrow({
where: { id: current.id },
});
t.is(after.usedAt, null);
t.is(after.replacedById, null);
});
test.serial(
'selects one active signing key and keeps retiring keys readable',
async t => {
const retiredAt = new Date();
const verifyUntil = new Date(retiredAt.getTime() + (900 + 30) * 1000);
await setSigningKeys(t.context.db, [
{
id: 'active',
secret: Buffer.alloc(32, 1).toString('base64url'),
status: 'active',
source: 'auto',
},
{
id: 'retiring',
secret: Buffer.alloc(32, 2).toString('base64url'),
status: 'retiring',
source: 'admin',
retiredAt: retiredAt.toISOString(),
verifyUntil: verifyUntil.toISOString(),
},
]);
await t.context.signingKeys.onSigningKeysChanged();
t.is((await t.context.signingKeys.active()).id, 'active');
t.is((await t.context.signingKeys.verify('retiring'))?.status, 'retiring');
t.is(
await t.context.signingKeys.verify(
'retiring',
new Date(verifyUntil.getTime() + 1)
),
undefined
);
await setSigningKeys(t.context.db, [
{
id: 'active',
secret: Buffer.alloc(32, 1).toString('base64url'),
status: 'active',
source: 'auto',
},
]);
await t.context.signingKeys.onSigningKeysChanged();
t.is(await t.context.signingKeys.verify('retiring'), undefined);
t.is(await t.context.signingKeys.verify('missing'), undefined);
}
);
test.serial('rejects ambiguous signing key rings', async t => {
await setSigningKeys(t.context.db, [
{
id: 'one',
secret: Buffer.alloc(32, 1).toString('base64url'),
status: 'active',
source: 'auto',
},
{
id: 'two',
secret: Buffer.alloc(32, 2).toString('base64url'),
status: 'active',
source: 'admin',
},
]);
await t.throwsAsync(() => t.context.signingKeys.onSigningKeysChanged(), {
message: 'Auth session requires exactly one active signing key.',
});
});
test.serial(
'rejects invalid signing key material and retirement data',
async t => {
const cases = [
{
name: 'short key',
keys: [
{
id: 'short',
secret: Buffer.alloc(8).toString('base64url'),
status: 'active' as const,
source: 'auto' as const,
},
],
message: /at least 32 bytes/,
},
{
name: 'non-canonical key',
keys: [
{
id: 'non-canonical',
secret: `${Buffer.alloc(32).toString('base64url')}=`,
status: 'active' as const,
source: 'auto' as const,
},
],
message: /canonical base64url/,
},
{
name: 'duplicate id',
keys: [
{
id: 'duplicate',
secret: Buffer.alloc(32, 1).toString('base64url'),
status: 'active' as const,
source: 'auto' as const,
},
{
id: 'duplicate',
secret: Buffer.alloc(32, 2).toString('base64url'),
status: 'active' as const,
source: 'admin' as const,
},
],
message: /ids must be unique/,
},
{
name: 'retiring without deadline',
keys: [
{
id: 'active',
secret: Buffer.alloc(32, 1).toString('base64url'),
status: 'active' as const,
source: 'auto' as const,
},
{
id: 'retiring',
secret: Buffer.alloc(32, 2).toString('base64url'),
status: 'retiring' as const,
source: 'admin' as const,
},
],
message: /requires retiredAt and verifyUntil/,
},
];
for (const testCase of cases) {
await setSigningKeys(t.context.db, testCase.keys);
await t.throwsAsync(() => t.context.signingKeys.onSigningKeysChanged(), {
message: testCase.message,
});
}
}
);
test.serial('persists one stable database signing key', async t => {
await t.context.db.appConfig.deleteMany({
where: { id: 'auth.session.signingKeys' },
});
await t.context.signingKeys.onConfigInit();
const first = (await t.context.signingKeys.active()).id;
const stored = await t.context.db.appConfig.findUniqueOrThrow({
where: { id: 'auth.session.signingKeys' },
});
t.is((stored.value as Array<{ id: string }>)[0]?.id, first);
await t.context.signingKeys.onConfigInit();
t.is((await t.context.signingKeys.active()).id, first);
});
test.serial('rotates and safely deletes signing keys', async t => {
await t.context.signingKeys.onConfigInit();
const previous = (await t.context.signingKeys.active()).id;
const rotated = await t.context.signingKeys.rotate(
t.context.userId,
previous
);
const active = rotated.find(key => key.status === 'active');
const retiring = rotated.find(key => key.id === previous);
t.truthy(active);
t.is(retiring?.status, 'retiring');
t.truthy(await t.context.signingKeys.verify(previous));
t.false(JSON.stringify(rotated).includes('secret'));
await t.throwsAsync(t.context.signingKeys.rotate(t.context.userId, previous));
await t.throwsAsync(t.context.signingKeys.delete(t.context.userId, previous));
const stored = await t.context.db.appConfig.findUniqueOrThrow({
where: { id: 'auth.session.signingKeys' },
});
const ring = stored.value as Array<Record<string, unknown>>;
const verifyUntil = new Date(Date.now() - 1);
const retiredAt = new Date(verifyUntil.getTime() - 931_000);
await t.context.db.appConfig.update({
where: { id: stored.id },
data: {
value: ring.map(key =>
key.id === previous
? {
...key,
retiredAt: retiredAt.toISOString(),
verifyUntil: verifyUntil.toISOString(),
}
: key
) as Prisma.InputJsonValue,
},
});
await t.context.signingKeys.onSigningKeysChanged();
const afterDelete = await t.context.signingKeys.delete(
t.context.userId,
previous
);
t.false(afterDelete.some(key => key.id === previous));
t.is(afterDelete.filter(key => key.status === 'active').length, 1);
});
async function setSigningKeys(db: PrismaClient, keys: unknown) {
await db.appConfig.upsert({
where: { id: 'auth.session.signingKeys' },
update: { value: keys as Prisma.InputJsonValue },
create: {
id: 'auth.session.signingKeys',
value: keys as Prisma.InputJsonValue,
},
});
}
@@ -1,9 +1,11 @@
import { randomBytes } from 'node:crypto';
import { PrismaClient } from '@prisma/client';
import type { TestFn } from 'ava';
import ava from 'ava';
import supertest from 'supertest';
import { AuthService, AuthSessionService } from '../../core/auth';
import {
changeEmail,
changePassword,
@@ -108,6 +110,12 @@ test('set and change password', async t => {
const u1Email = 'u1@affine.pro';
const u1 = await app.signupV1(u1Email);
const parent = await app.get(AuthService).createUserSession(u1.id);
const authSession = await app.get(AuthSessionService).create({
userSessionId: parent.id,
installationId: 'password-change-device',
platform: 'ios',
});
await sendSetPasswordEmail(app, u1Email, '/password-change');
const setPasswordMail = app.mails.last('SetPassword');
@@ -130,6 +138,12 @@ test('set and change password', async t => {
);
t.true(success, 'failed to change password');
t.is(
await app.get(PrismaClient).authSession.count({
where: { id: authSession.session.id },
}),
0
);
let user = await currentUser(app);
@@ -4,6 +4,7 @@ import { IncomingMessage } from 'node:http';
import { HttpStatus } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import ava, { ExecutionContext, TestFn } from 'ava';
import jwt from 'jsonwebtoken';
import Sinon from 'sinon';
import supertest from 'supertest';
@@ -13,8 +14,10 @@ import {
getRequestHeader,
parseCookies as safeParseCookies,
} from '../../base/utils/request';
import { AuthSessionService } from '../../core/auth/auth-session';
import { MagicLinkAuthService } from '../../core/auth/magic-link';
import { AuthService } from '../../core/auth/service';
import { mintChallengeResponse } from '../../native';
import {
createTestingApp,
currentUser,
@@ -25,6 +28,7 @@ import {
const test = ava as TestFn<{
auth: AuthService;
magicLink: MagicLinkAuthService;
authSessions: AuthSessionService;
db: PrismaClient;
config: ConfigFactory;
app: TestingApp;
@@ -35,6 +39,7 @@ test.before(async t => {
t.context.auth = app.get(AuthService);
t.context.magicLink = app.get(MagicLinkAuthService);
t.context.authSessions = app.get(AuthSessionService);
t.context.db = app.get(PrismaClient);
t.context.config = app.get(ConfigFactory);
t.context.app = app;
@@ -45,6 +50,17 @@ test.beforeEach(async t => {
await t.context.app.initTestingDB();
t.context.config.override({
auth: { allowSignup: true, requireEmailDomainVerification: false },
captcha: {
enabled: false,
config: {
turnstile: {
secret: '',
siteKey: '',
action: 'auth-sign-in',
},
challenge: { bits: 20 },
},
},
});
});
@@ -70,6 +86,156 @@ test('should be able to sign in with credential', async t => {
t.is(session?.id, u1.id);
});
test('should accept one Hashcash proof and reject its replay', async t => {
const { app, config } = t.context;
const user = await app.createUser('hashcash-login@affine.pro');
config.override({
captcha: {
enabled: true,
config: {
turnstile: {
secret: 'secret',
siteKey: 'site-key',
action: 'auth-sign-in',
},
challenge: { bits: 20 },
},
},
});
const challenge = await app
.GET('/api/auth/captcha')
.set('x-affine-client-kind', 'native')
.expect(200);
t.is(challenge.body.provider, 'hashcash');
const token = await mintChallengeResponse(challenge.body.resource, 20);
if (!token) throw new Error('Failed to mint Hashcash proof');
const request = () =>
app
.POST('/api/auth/sign-in')
.set('x-captcha-provider', 'hashcash')
.set('x-captcha-challenge', challenge.body.challenge)
.set('x-captcha-token', token)
.send({ email: user.email, password: user.password });
await request().expect(200);
const replay = await request().expect(400);
t.is(replay.body.name, 'CAPTCHA_VERIFICATION_FAILED');
});
test('should validate Turnstile action and reject query credentials', async t => {
const { app, config } = t.context;
const user = await app.createUser('turnstile-login@affine.pro');
config.override({
captcha: {
enabled: true,
config: {
turnstile: {
secret: 'secret',
siteKey: 'site-key',
action: 'auth-sign-in',
},
challenge: { bits: 20 },
},
},
});
const publicConfig = await app.GET('/api/auth/captcha').expect(200);
t.deepEqual(publicConfig.body, {
provider: 'turnstile',
siteKey: 'site-key',
action: 'auth-sign-in',
});
const verify = Sinon.stub(globalThis, 'fetch').resolves(
new Response(
JSON.stringify({
success: true,
hostname: config.config.server.host,
action: 'auth-sign-in',
}),
{ status: 200, headers: { 'content-type': 'application/json' } }
)
);
await app
.POST('/api/auth/sign-in')
.set('x-captcha-provider', 'turnstile')
.set('x-captcha-token', 'turnstile-token')
.send({ email: user.email, password: user.password })
.expect(200);
t.true(verify.calledOnce);
verify.restore();
await app
.POST('/api/auth/sign-in?provider=turnstile&token=turnstile-token')
.send({ email: user.email, password: user.password })
.expect(400);
});
test('should reject a Turnstile response for another action', async t => {
const { app, config } = t.context;
const user = await app.createUser('turnstile-action@affine.pro');
config.override({
captcha: {
enabled: true,
config: {
turnstile: {
secret: 'secret',
siteKey: 'site-key',
action: 'auth-sign-in',
},
challenge: { bits: 20 },
},
},
});
const verify = Sinon.stub(globalThis, 'fetch').resolves(
new Response(
JSON.stringify({
success: true,
hostname: config.config.server.host,
action: 'another-action',
}),
{ status: 200, headers: { 'content-type': 'application/json' } }
)
);
const response = await app
.POST('/api/auth/sign-in')
.set('x-captcha-provider', 'turnstile')
.set('x-captcha-token', 'turnstile-token')
.send({ email: user.email, password: user.password })
.expect(400);
verify.restore();
t.is(response.body.name, 'CAPTCHA_VERIFICATION_FAILED');
});
test('should expose a retryable error when Turnstile is unavailable', async t => {
const { app, config } = t.context;
const user = await app.createUser('turnstile-unavailable@affine.pro');
config.override({
captcha: {
enabled: true,
config: {
turnstile: {
secret: 'secret',
siteKey: 'site-key',
action: 'auth-sign-in',
},
challenge: { bits: 20 },
},
},
});
const verify = Sinon.stub(globalThis, 'fetch').rejects(
new Error('siteverify unavailable')
);
const response = await app
.POST('/api/auth/sign-in')
.set('x-captcha-provider', 'turnstile')
.set('x-captcha-token', 'turnstile-token')
.send({ email: user.email, password: user.password })
.expect(504);
verify.restore();
t.is(response.body.name, 'NETWORK_ERROR');
});
test('should not cache auth session response', async t => {
const { app } = t.context;
@@ -78,15 +244,23 @@ test('should not cache auth session response', async t => {
t.is(res.headers['cache-control'], 'no-store');
});
async function exchangeSession(app: TestingApp, code: string) {
async function exchangeSession(
app: TestingApp,
code: string,
installationId = '00000000-0000-4000-8000-000000000001'
) {
return await supertest(app.getHttpServer())
.post('/api/auth/native/exchange')
.post('/api/auth/session/exchange')
.set('x-affine-client-kind', 'native')
.send({ code })
.send({
code,
installationId,
platform: 'electron',
})
.expect(201);
}
function assertClearsNativeAuthCookies(
function assertClearsClientAuthCookies(
t: ExecutionContext,
res: supertest.Response
) {
@@ -119,11 +293,302 @@ test('should issue exchange code only for native credential sign in', async t =>
t.is(res.body.id, u1.id);
t.truthy(res.body.exchangeCode);
assertClearsNativeAuthCookies(t, res);
assertClearsClientAuthCookies(t, res);
const exchangeRes = await exchangeSession(app, res.body.exchangeCode);
t.truthy(exchangeRes.body.token);
t.truthy(exchangeRes.body.expiresAt);
t.truthy(exchangeRes.body.accessToken);
t.is(exchangeRes.body.tokenType, 'Bearer');
t.is(exchangeRes.body.expiresIn, 15 * 60);
t.truthy(exchangeRes.body.refreshToken);
t.truthy(exchangeRes.body.refreshExpiresAt);
t.is(exchangeRes.headers['cache-control'], 'no-store');
t.is(exchangeRes.headers.pragma, 'no-cache');
t.falsy(exchangeRes.body.token);
t.falsy(exchangeRes.body.expiresAt);
const decoded = jwt.decode(exchangeRes.body.accessToken, { complete: true });
t.truthy(decoded);
if (!decoded || typeof decoded.payload === 'string') return;
t.is(decoded.payload.typ, 'session_access');
t.is(decoded.payload.sid, exchangeRes.body.session.id);
t.is((decoded.payload.exp ?? 0) - (decoded.payload.iat ?? 0), 15 * 60);
t.is(typeof decoded.header.kid, 'string');
});
test('should rotate token pairs and manage auth sessions', async t => {
const { app } = t.context;
const user = await app.createUser('token-lifecycle@affine.pro');
const signIn = await app
.POST('/api/auth/sign-in')
.set('x-affine-client-kind', 'native')
.send({ email: user.email, password: user.password })
.expect(200);
const issued = await exchangeSession(app, signIn.body.exchangeCode);
const refreshed = await app
.POST('/api/auth/session/refresh')
.set('x-affine-client-kind', 'native')
.send({
refreshToken: issued.body.refreshToken,
})
.expect(201);
t.is(refreshed.headers['cache-control'], 'no-store');
t.is(refreshed.headers.pragma, 'no-cache');
t.not(refreshed.body.refreshToken, issued.body.refreshToken);
t.is(refreshed.body.session.id, issued.body.session.id);
const sessions = await app
.GET('/api/auth/sessions')
.set('Authorization', `Bearer ${refreshed.body.accessToken}`)
.expect(200);
t.is(sessions.headers['cache-control'], 'no-store');
t.is(sessions.headers.pragma, 'no-cache');
t.is(sessions.body.length, 1);
t.is(sessions.body[0].id, issued.body.session.id);
t.is(sessions.body[0].platform, 'electron');
t.true(sessions.body[0].current);
const revoked = await app
.DELETE(`/api/auth/sessions/${issued.body.session.id}`)
.set('Authorization', `Bearer ${refreshed.body.accessToken}`)
.expect(200);
t.is(revoked.headers['cache-control'], 'no-store');
t.is(revoked.headers.pragma, 'no-cache');
const rejected = await app
.GET('/api/auth/session')
.set('Authorization', `Bearer ${refreshed.body.accessToken}`)
.expect(401);
t.is(rejected.body.code, 'AUTH_SESSION_REVOKED');
});
test('should revoke another device immediately and revoke all devices', async t => {
const { app } = t.context;
const user = await app.createUser('device-lifecycle@affine.pro');
const firstSignIn = await app
.POST('/api/auth/sign-in')
.set('x-affine-client-kind', 'native')
.send({ email: user.email, password: user.password })
.expect(200);
const first = await exchangeSession(
app,
firstSignIn.body.exchangeCode,
'00000000-0000-4000-8000-000000000011'
);
const secondSignIn = await app
.POST('/api/auth/sign-in')
.set('x-affine-client-kind', 'native')
.send({ email: user.email, password: user.password })
.expect(200);
const second = await exchangeSession(
app,
secondSignIn.body.exchangeCode,
'00000000-0000-4000-8000-000000000012'
);
await t.context.db.authSession.update({
where: { id: first.body.session.id },
data: { createdAt: new Date(Date.now() - 10 * 60 * 1000) },
});
await app
.DELETE(`/api/auth/sessions/${second.body.session.id}`)
.set('Authorization', `Bearer ${first.body.accessToken}`)
.expect(200);
await app
.GET('/api/auth/session')
.set('Authorization', `Bearer ${second.body.accessToken}`)
.expect(401);
await app
.POST('/api/auth/session/refresh')
.set('x-affine-client-kind', 'native')
.send({ refreshToken: second.body.refreshToken })
.expect(401);
await t.context.db.authSession.update({
where: { id: first.body.session.id },
data: { createdAt: new Date() },
});
await app
.POST('/api/auth/sessions/revoke-all')
.set('Authorization', `Bearer ${first.body.accessToken}`)
.expect(201);
await app
.GET('/api/auth/session')
.set('Authorization', `Bearer ${first.body.accessToken}`)
.expect(401);
t.pass();
});
test('should require csrf and revoke cookie plus auth sessions', async t => {
const { app, db } = t.context;
const user = await app.createUser('cookie-revoke-all@affine.pro');
const signedIn = await app
.POST('/api/auth/sign-in')
.send({ email: user.email, password: user.password })
.expect(200);
const cookies = signedIn.get('Set-Cookie') ?? [];
const parsed = parseCookies(signedIn);
const parent = await app.get(AuthService).createUserSession(user.id);
await app.get(AuthSessionService).create({
userSessionId: parent.id,
installationId: 'cookie-managed-device',
platform: 'electron',
});
app.clearAuth();
await app
.POST('/api/auth/sessions/revoke-all')
.set('Cookie', cookies)
.expect(403);
await app
.POST('/api/auth/sessions/revoke-all')
.set('Cookie', cookies)
.set('x-affine-csrf-token', parsed[AuthService.csrfCookieName])
.expect(201);
t.is(await db.userSession.count({ where: { userId: user.id } }), 0);
t.is(
await db.authSession.count({
where: { userSession: { userId: user.id } },
}),
0
);
});
test('should idempotently revoke a auth session with its refresh token', async t => {
const { app } = t.context;
const user = await app.createUser('token-revoke@affine.pro');
const signIn = await app
.POST('/api/auth/sign-in')
.set('x-affine-client-kind', 'native')
.send({ email: user.email, password: user.password })
.expect(200);
const issued = await exchangeSession(app, signIn.body.exchangeCode);
for (let attempt = 0; attempt < 2; attempt++) {
const revoked = await app
.POST('/api/auth/session/revoke')
.set('x-affine-client-kind', 'native')
.send({ refreshToken: issued.body.refreshToken })
.expect(201);
t.is(revoked.headers['cache-control'], 'no-store');
t.is(revoked.headers.pragma, 'no-cache');
}
const rejected = await app
.POST('/api/auth/session/refresh')
.set('x-affine-client-kind', 'native')
.send({ refreshToken: issued.body.refreshToken })
.expect(401);
t.is(rejected.body.code, 'AUTH_SESSION_REVOKED');
});
test('should strictly validate auth-session refresh and revoke bodies', async t => {
const refresh = await t.context.app
.POST('/api/auth/session/refresh')
.set('x-affine-client-kind', 'native')
.send({ refreshToken: 'invalid', extra: true })
.expect(400);
t.is(refresh.body.name, 'VALIDATION_ERROR');
const revoke = await t.context.app
.POST('/api/auth/session/revoke')
.set('x-affine-client-kind', 'native')
.send({ extra: true })
.expect(400);
t.is(revoke.body.name, 'VALIDATION_ERROR');
});
test('should roll back user-session issuance when auth-session issuance fails', async t => {
const { app } = t.context;
const user = await app.createUser('token-rollback@affine.pro');
const signIn = await app
.POST('/api/auth/sign-in')
.set('x-affine-client-kind', 'native')
.send({ email: user.email, password: user.password })
.expect(200);
t.is(await t.context.db.userSession.count({ where: { userId: user.id } }), 0);
const sessionCount = await t.context.db.session.count();
const createStub = Sinon.stub(t.context.authSessions, 'create').rejects(
new Error('issuance failure')
);
await supertest(app.getHttpServer())
.post('/api/auth/session/exchange')
.set('x-affine-client-kind', 'native')
.send({
code: signIn.body.exchangeCode,
installationId: '00000000-0000-4000-8000-000000000002',
platform: 'ios',
})
.expect(500);
createStub.restore();
t.is(await t.context.db.userSession.count({ where: { userId: user.id } }), 0);
t.is(await t.context.db.session.count(), sessionCount);
});
for (const userState of ['disabled', 'deleted'] as const) {
test(`should reject exchange after the user is ${userState}`, async t => {
const { app, db } = t.context;
const user = await app.createUser(`token-${userState}@affine.pro`);
const signIn = await app
.POST('/api/auth/sign-in')
.set('x-affine-client-kind', 'native')
.send({ email: user.email, password: user.password })
.expect(200);
if (userState === 'disabled') {
await db.user.update({
where: { id: user.id },
data: { disabled: true },
});
} else {
await db.user.delete({ where: { id: user.id } });
}
const rejected = await supertest(app.getHttpServer())
.post('/api/auth/session/exchange')
.set('x-affine-client-kind', 'native')
.send({
code: signIn.body.exchangeCode,
installationId:
userState === 'disabled'
? '00000000-0000-4000-8000-000000000003'
: '00000000-0000-4000-8000-000000000004',
platform: 'ios',
})
.expect(400);
t.is(rejected.body.name, 'INVALID_AUTH_STATE');
t.is(await db.userSession.count({ where: { userId: user.id } }), 0);
t.is(await db.authSession.count(), 0);
});
}
test('should allow an authenticated device to revoke its session', async t => {
const { app, db } = t.context;
const user = await app.createUser('token-recent-auth@affine.pro');
const signIn = await app
.POST('/api/auth/sign-in')
.set('x-affine-client-kind', 'native')
.send({ email: user.email, password: user.password })
.expect(200);
const issued = await exchangeSession(app, signIn.body.exchangeCode);
await db.authSession.update({
where: { id: issued.body.session.id },
data: { createdAt: new Date(Date.now() - 10 * 60 * 1000) },
});
const refreshed = await app
.POST('/api/auth/session/refresh')
.set('x-affine-client-kind', 'native')
.send({ refreshToken: issued.body.refreshToken })
.expect(201);
await app
.DELETE(`/api/auth/sessions/${issued.body.session.id}`)
.set('Authorization', `Bearer ${refreshed.body.accessToken}`)
.expect(200);
const session = await db.authSession.findUniqueOrThrow({
where: { id: issued.body.session.id },
});
t.truthy(session.revokedAt);
});
test('should not issue jwt for browser-origin credential sign in', async t => {
@@ -161,6 +626,109 @@ test('should write legacy auth cookies when signing in with credential', async t
t.truthy(cookies[AuthService.csrfCookieName]);
});
test('should preserve Electron 0.26 cookie authentication', async t => {
const { app, db } = t.context;
const user = await app.createUser('electron-026@affine.pro');
const signIn = await app
.POST('/api/auth/sign-in')
.set('x-affine-version', '0.26.7')
.send({
email: user.email,
password: user.password,
verifyToken: 'legacy-captcha-token',
challenge: 'legacy-captcha-challenge',
})
.expect(200);
const cookies = signIn.get('Set-Cookie') ?? [];
t.falsy(signIn.body.exchangeCode);
t.true(
cookies.some(cookie =>
cookie.startsWith(`${AuthService.sessionCookieName}=`)
)
);
const session = await app
.GET('/api/auth/session')
.set('x-affine-version', '0.26.7')
.set('Cookie', cookies)
.expect(200);
t.is(session.body.user.id, user.id);
const currentUserData = await app.gql<{ currentUser: { id: string } }>(`
query {
currentUser { id }
}
`);
t.is(currentUserData.currentUser.id, user.id);
const beforeRefresh = await db.userSession.findFirstOrThrow({
where: { userId: user.id },
});
await db.userSession.update({
where: { id: beforeRefresh.id },
data: { expiresAt: new Date(Date.now() + 1000) },
});
await app
.GET('/api/auth/session')
.set('x-affine-version', '0.26.7')
.expect(200);
const afterRefresh = await db.userSession.findUniqueOrThrow({
where: { id: beforeRefresh.id },
});
t.true(
(afterRefresh.expiresAt?.getTime() ?? 0) >
Date.now() + 14 * 24 * 60 * 60 * 1000
);
t.is(afterRefresh.refreshClientVersion, '0.26.7');
});
test('should reject an invalid refresh token with a stable code', async t => {
const res = await t.context.app
.POST('/api/auth/session/refresh')
.set('x-affine-client-kind', 'native')
.send({ refreshToken: 'unavailable' })
.expect(401);
t.is(res.body.code, 'REFRESH_TOKEN_INVALID');
});
test('should not fall back to a cookie on auth-session refresh', async t => {
const user = await t.context.app.createUser('refresh-cookie@affine.pro');
await t.context.app
.POST('/api/auth/sign-in')
.send({ email: user.email, password: user.password })
.expect(200);
const res = await t.context.app
.POST('/api/auth/session/refresh')
.set('x-affine-client-kind', 'native')
.send({ refreshToken: 'invalid' })
.expect(401);
t.is(res.body.code, 'REFRESH_TOKEN_INVALID');
});
test('should rate limit refresh attempts by token selector', async t => {
const refreshToken = `aff_rt_v1.${randomUUID()}.invalid`;
for (let attempt = 0; attempt < 30; attempt++) {
await t.context.app
.POST('/api/auth/session/refresh')
.set('x-affine-client-kind', 'native')
.send({ refreshToken })
.expect(401);
}
await t.context.app
.POST('/api/auth/session/refresh')
.set('x-affine-client-kind', 'native')
.send({ refreshToken })
.expect(429);
t.pass();
});
test('should record sign in client version when header is provided', async t => {
const { app, db } = t.context;
@@ -492,7 +1060,7 @@ test('should be able to sign out with jwt without csrf', async t => {
.send({ email: u1.email, password: u1.password })
.expect(200);
const token = (await exchangeSession(app, signInRes.body.exchangeCode)).body
.token;
.accessToken;
await supertest(app.getHttpServer())
.post('/api/auth/sign-out')
@@ -502,9 +1070,8 @@ test('should be able to sign out with jwt without csrf', async t => {
const sessionRes = await supertest(app.getHttpServer())
.get('/api/auth/session')
.set('Authorization', `Bearer ${token}`)
.expect(200);
t.falsy(sessionRes.body.user);
.expect(401);
t.is(sessionRes.body.code, 'AUTH_SESSION_REVOKED');
});
test('should ignore user_id query when signing out with jwt', async t => {
@@ -519,7 +1086,7 @@ test('should ignore user_id query when signing out with jwt', async t => {
.send({ email: u1.email, password: u1.password })
.expect(200);
const u1Token = (await exchangeSession(app, u1SignIn.body.exchangeCode)).body
.token;
.accessToken;
await app
.POST('/api/auth/sign-in')
.send({ email: u2.email, password: u2.password })
@@ -533,14 +1100,14 @@ test('should ignore user_id query when signing out with jwt', async t => {
const u1Session = await supertest(app.getHttpServer())
.get('/api/auth/session')
.set('Authorization', `Bearer ${u1Token}`)
.expect(200);
t.falsy(u1Session.body.user);
.expect(401);
t.is(u1Session.body.code, 'AUTH_SESSION_REVOKED');
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 => {
test('should isolate auth sessions when signing in another account', async t => {
const { app } = t.context;
const u1 = await app.createUser('u1@affine.pro');
@@ -552,13 +1119,15 @@ test('should reuse jwt session when signing in another account without cookies',
.send({ email: u1.email, password: u1.password })
.expect(200);
const u1Token = (await exchangeSession(app, u1SignIn.body.exchangeCode)).body
.token;
.accessToken;
const u2SignIn = await supertest(app.getHttpServer())
.post('/api/auth/sign-in')
.set('Authorization', `Bearer ${u1Token}`)
.set('x-affine-client-kind', 'native')
.send({ email: u2.email, password: u2.password })
.expect(200);
await exchangeSession(app, u2SignIn.body.exchangeCode);
const u1Session = await t.context.db.userSession.findFirstOrThrow({
where: { userId: u1.id },
@@ -568,7 +1137,7 @@ test('should reuse jwt session when signing in another account without cookies',
});
t.is(u2SignIn.body.id, u2.id);
t.is(u2Session.sessionId, u1Session.sessionId);
t.not(u2Session.sessionId, u1Session.sessionId);
});
test('should not reuse legacy bearer session id when signing in another account without cookies', async t => {
@@ -675,14 +1244,15 @@ test('should sign in desktop app via one-time open-app code', async t => {
t.is(exchangeRes.body.id, u1.id);
t.truthy(exchangeRes.body.exchangeCode);
assertClearsNativeAuthCookies(t, exchangeRes);
assertClearsClientAuthCookies(t, exchangeRes);
const tokenRes = await exchangeSession(app, exchangeRes.body.exchangeCode);
t.truthy(tokenRes.body.token);
t.truthy(tokenRes.body.expiresAt);
t.truthy(tokenRes.body.accessToken);
t.is(tokenRes.body.expiresIn, 15 * 60);
t.truthy(tokenRes.body.refreshToken);
const sessionRes = await supertest(app.getHttpServer())
.get('/api/auth/session')
.set('Authorization', `Bearer ${tokenRes.body.token}`)
.set('Authorization', `Bearer ${tokenRes.body.accessToken}`)
.expect(200);
t.is(sessionRes.body.user?.id, u1.id);
@@ -6,9 +6,10 @@ import request from 'supertest';
import { CANARY_CLIENT_VERSION_MAX_AGE_DAYS, ConfigFactory } from '../../base';
import {
AccessTokenService,
AuthModule,
AuthSessionService,
CurrentUser,
JwtSessionService,
Public,
Session,
} from '../../core/auth';
@@ -43,12 +44,14 @@ const test = ava as TestFn<{
app: TestingApp;
server: any;
auth: AuthService;
jwtSession: JwtSessionService;
accessTokens: AccessTokenService;
authSessions: AuthSessionService;
models: Models;
db: PrismaClient;
config: ConfigFactory;
u1: CurrentUser;
sessionId: string;
authSessionId: string;
}>;
test.before(async t => {
@@ -60,7 +63,8 @@ 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.accessTokens = app.get(AccessTokenService);
t.context.authSessions = app.get(AuthSessionService);
t.context.models = app.get(Models);
t.context.db = app.get(PrismaClient);
t.context.config = app.get(ConfigFactory);
@@ -81,7 +85,17 @@ test.beforeEach(async t => {
t.context.u1 = await t.context.auth.signUp('u1@affine.pro', '1');
const session = await t.context.models.session.createSession();
t.context.sessionId = session.id;
await t.context.auth.createUserSession(t.context.u1.id, t.context.sessionId);
const userSession = await t.context.auth.createUserSession(
t.context.u1.id,
t.context.sessionId
);
t.context.authSessionId = (
await t.context.authSessions.create({
userSessionId: userSession.id,
installationId: 'installation-1',
platform: 'ios',
})
).session.id;
});
test.after.always(async t => {
@@ -127,13 +141,12 @@ test('should be able to visit private api with cookie session', async t => {
t.is(res.body.user.id, t.context.u1.id);
});
test('should be able to visit private api with legacy bearer session id', async t => {
const res = await request(t.context.server)
test('should reject a legacy bearer session id', async t => {
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);
.expect(HttpStatus.UNAUTHORIZED);
t.pass();
});
test('should be able to visit private api with personal access token', async t => {
@@ -150,8 +163,11 @@ test('should be able to visit private api with personal access token', async t =
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);
test('should be able to visit private api with auth-session access jwt', async t => {
const jwt = await t.context.accessTokens.sign(
t.context.u1.id,
t.context.authSessionId
);
const res = await request(t.context.server)
.get('/private')
@@ -164,7 +180,15 @@ test('should be able to visit private api with jwt session', async t => {
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 u2AuthSessionPrincipal = await t.context.authSessions.create({
userSessionId: u2Session.id,
installationId: 'installation-2',
platform: 'android',
});
const jwt = await t.context.accessTokens.sign(
u2.id,
u2AuthSessionPrincipal.session.id
);
const res = await request(t.context.server)
.get('/private')
@@ -176,7 +200,10 @@ test('should prefer bearer jwt over cookie session', async t => {
});
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);
const jwt = await t.context.accessTokens.sign(
t.context.u1.id,
t.context.authSessionId
);
await t.context.auth.signOut(t.context.sessionId, t.context.u1.id);
@@ -188,7 +215,7 @@ test('should reject jwt after its user session is deleted', async t => {
t.pass();
});
test('should enforce client version for jwt and bearer session id auth', async t => {
test('should enforce client version for auth-session access jwt auth', async t => {
t.context.config.override({
client: {
versionControl: {
@@ -198,47 +225,44 @@ test('should enforce client version for jwt and bearer session id auth', async t
},
});
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;
},
},
];
const session = await t.context.auth.createUserSession(t.context.u1.id);
const authSession = await t.context.authSessions.create({
userSessionId: session.id,
installationId: 'version-installation',
platform: 'electron',
});
const token = (
await t.context.accessTokens.sign(t.context.u1.id, authSession.session.id)
).token;
const res = await request(t.context.server)
.get('/private')
.set('Authorization', `Bearer ${token}`)
.set('x-affine-version', '0.24.0')
.expect(HttpStatus.FORBIDDEN);
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
);
}
t.is(
res.body.message,
'Unsupported client with version [0.24.0], required version is [>=0.25.0].'
);
});
test('should fall back to cookie session on public api when jwt is invalid', async t => {
test('should not hide an invalid auth-session jwt behind a cookie session', 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);
.expect(HttpStatus.UNAUTHORIZED);
t.is(res.body.user.id, t.context.u1.id);
t.is(res.body.code, 'ACCESS_TOKEN_INVALID');
});
test('should return a stable error for invalid jwt on public api', async t => {
const res = await request(t.context.server)
.get('/public')
.set('Authorization', 'Bearer invalid.jwt.token')
.expect(HttpStatus.UNAUTHORIZED);
t.is(res.body.code, 'ACCESS_TOKEN_INVALID');
});
test('should be able to parse session cookie', async t => {
@@ -252,7 +276,7 @@ test('should be able to parse session cookie', async t => {
spy.restore();
});
test('should be able to parse bearer token', async t => {
test('should not parse a legacy bearer session id', async t => {
const spy = Sinon.spy(t.context.auth, 'getUserSession');
await request(t.context.server)
@@ -260,10 +284,31 @@ test('should be able to parse bearer token', async t => {
.auth(t.context.sessionId, { type: 'bearer' })
.expect(200);
t.deepEqual(spy.firstCall.args, [t.context.sessionId, undefined]);
t.false(spy.called);
spy.restore();
});
test('should expose auth-session version rejection on a public api', async t => {
t.context.config.override({
client: {
versionControl: {
enabled: true,
requiredVersion: '>=0.25.0',
},
},
});
const token = (
await t.context.accessTokens.sign(t.context.u1.id, t.context.authSessionId)
).token;
const res = await request(t.context.server)
.get('/public')
.set('Authorization', `Bearer ${token}`)
.set('x-affine-version', '0.24.0')
.expect(HttpStatus.FORBIDDEN);
t.is(res.body.name, 'UNSUPPORTED_CLIENT_VERSION');
});
test('should be able to refresh session if needed', async t => {
await t.context.app.get(PrismaClient).userSession.updateMany({
where: {
@@ -1,250 +0,0 @@
import { PrismaClient } from '@prisma/client';
import ava, { ExecutionContext, TestFn } from 'ava';
import jwt from 'jsonwebtoken';
import { ConfigFactory } from '../../base';
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;
config: ConfigFactory;
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.config = app.get(ConfigFactory);
t.context.models = app.get(Models);
t.context.db = app.get(PrismaClient);
});
test.beforeEach(async t => {
await t.context.app.initTestingDB();
resetAuthSessionConfig(t.context.config);
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();
});
const DEFAULT_SESSION_TTL = 60 * 60 * 24 * 15;
const DEFAULT_SESSION_TTR = 60 * 60 * 24 * 7;
function resetAuthSessionConfig(config: ConfigFactory) {
config.override({
auth: {
session: {
ttl: DEFAULT_SESSION_TTL,
ttr: DEFAULT_SESSION_TTR,
},
},
});
}
function currentJwtKey(crypto: CryptoHelper) {
return Buffer.concat([
Buffer.from('affine:user-session-jwt:v1:'),
crypto.keyPair.sha256.privateKey,
]);
}
function assertSignedTtl(
t: ExecutionContext,
signed: { token: string; expiresAt: Date },
expectedTtl: number
) {
const ttlMs = signed.expiresAt.getTime() - Date.now();
t.true(ttlMs > (expectedTtl - 5) * 1000);
t.true(ttlMs <= (expectedTtl + 1) * 1000);
const payload = jwt.decode(signed.token);
t.truthy(payload);
t.true(typeof payload !== 'string');
if (!payload || typeof payload === 'string') return;
t.is(typeof payload.iat, 'number');
t.is(typeof payload.exp, 'number');
if (typeof payload.iat !== 'number' || typeof payload.exp !== 'number') {
return;
}
t.is(payload.exp - payload.iat, expectedTtl);
}
test.serial('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.serial(
'should use application session ttl for jwt expiration',
async t => {
const defaultSigned = t.context.jwtSession.sign(
t.context.user.id,
t.context.sessionId
);
assertSignedTtl(t, defaultSigned, DEFAULT_SESSION_TTL);
const ttl = 120;
t.context.config.override({
auth: {
session: {
ttl,
},
},
});
const configuredSigned = t.context.jwtSession.sign(
t.context.user.id,
t.context.sessionId
);
assertSignedTtl(t, configuredSigned, ttl);
}
);
test.serial('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.serial(
'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.',
});
}
);
@@ -1,7 +1,8 @@
import { PrismaClient } from '@prisma/client';
import ava, { TestFn } from 'ava';
import Sinon from 'sinon';
import { CurrentUser } from '../../core/auth';
import { AuthSessionService, CurrentUser } from '../../core/auth';
import { AuthService } from '../../core/auth/service';
import { FeatureModule } from '../../core/features';
import { QuotaModule } from '../../core/quota';
@@ -11,6 +12,7 @@ import { createTestingModule, type TestingModule } from '../utils';
const test = ava as TestFn<{
auth: AuthService;
authSessions: AuthSessionService;
models: Models;
u1: CurrentUser;
db: PrismaClient;
@@ -24,6 +26,7 @@ test.before(async t => {
});
t.context.auth = m.get(AuthService);
t.context.authSessions = m.get(AuthSessionService);
t.context.models = m.get(Models);
t.context.db = m.get(PrismaClient);
t.context.m = m;
@@ -95,6 +98,57 @@ test('should be able to change password', async t => {
t.is(signedInU1.email, u1.email);
});
test('rolls back password change when session revocation fails', async t => {
const { auth, authSessions, u1 } = t.context;
const revoke = Sinon.stub(authSessions, 'revokeUserSessions').rejects(
new Error('revocation failed')
);
await t.throwsAsync(() =>
auth.changePasswordAndRevokeSessions(u1.id, 'new password')
);
revoke.restore();
await t.notThrowsAsync(() => auth.signIn(u1.email, '1'));
await t.throwsAsync(() => auth.signIn(u1.email, 'new password'));
});
test('rolls back email change when session revocation fails', async t => {
const { auth, authSessions, u1, models } = t.context;
const revoke = Sinon.stub(authSessions, 'revokeUserSessions').rejects(
new Error('revocation failed')
);
await t.throwsAsync(() =>
auth.changeEmailAndRevokeSessions(u1.id, 'replacement@affine.pro')
);
revoke.restore();
t.is((await models.user.get(u1.id))?.email, u1.email);
});
test('rolls back auth-session revocation when cookie revocation fails', async t => {
const { auth, authSessions, db, models, u1 } = t.context;
const userSession = await auth.createUserSession(u1.id);
const issued = await authSessions.create({
userSessionId: userSession.id,
installationId: 'transactional-revocation',
platform: 'ios',
});
const revokeCookies = Sinon.stub(
models.session,
'deleteUserSessions'
).rejects(new Error('cookie revocation failed'));
await t.throwsAsync(() => auth.revokeUserSessions(u1.id));
revokeCookies.restore();
const session = await db.authSession.findUniqueOrThrow({
where: { id: issued.session.id },
});
t.is(session.revokedAt, null);
});
test('should be able to change email', async t => {
const { auth, u1 } = t.context;
@@ -67,6 +67,11 @@ test('should be able to incr/decr number cache', async t => {
// increase an nonexists number
t.is(await cache.increase(key('test-incr2')), 1);
t.is(await cache.increase(key('test-incr2')), 2);
const expiringKey = key('test-incr-with-ttl');
t.is(await cache.increaseWithTtl(expiringKey, 1000), 1);
t.is(await cache.increaseWithTtl(expiringKey, 1000), 2);
t.true((await cache.ttl(expiringKey)) > 0);
});
test('should be able to manipulate list cache', async t => {
@@ -93,7 +93,7 @@ test("should be able to redirect to oauth provider's login page", async t => {
const res = await app
.POST('/api/oauth/preflight')
.send({ provider: 'Google', client_nonce: 'test-nonce' })
.send({ provider: 'Google', client: 'web', client_nonce: 'test-nonce' })
.expect(HttpStatus.OK);
const { url } = res.body;
@@ -125,7 +125,7 @@ test('should be able to redirect to oauth provider with multiple hosts', async t
const res = await app
.POST('/api/oauth/preflight')
.set('host', 'test.affine.dev')
.send({ provider: 'Google', client_nonce: 'test-nonce' })
.send({ provider: 'Google', client: 'web', client_nonce: 'test-nonce' })
.expect(HttpStatus.OK);
const { url } = res.body;
@@ -181,6 +181,17 @@ test('should be able to redirect to oauth provider with client_nonce', async t =
t.truthy(state.state);
});
test('should reject unsupported oauth client identifiers', async t => {
const { app } = t.context;
const res = await app.POST('/api/oauth/preflight').send({
provider: 'Google',
client: 'untrusted-client',
client_nonce: 'test-nonce',
});
t.is(res.status, 403);
});
test('should record sign in client version from oauth preflight state', async t => {
const { app, db } = t.context;
@@ -197,7 +208,7 @@ test('should record sign in client version from oauth preflight state', async t
const preflightRes = await app
.POST('/api/oauth/preflight')
.set('x-affine-version', '0.25.3')
.send({ provider: 'Google', client_nonce: 'test-nonce' })
.send({ provider: 'Google', client: 'web', client_nonce: 'test-nonce' })
.expect(HttpStatus.OK);
const redirect = new URL(preflightRes.body.url as string);
@@ -238,6 +249,7 @@ test('should forbid preflight with untrusted redirect_uri', async t => {
.POST('/api/oauth/preflight')
.send({
provider: 'Google',
client: 'web',
redirect_uri: 'https://evil.example',
client_nonce: 'test-nonce',
})
@@ -251,7 +263,7 @@ test('should throw if client_nonce is missing in preflight', async t => {
await app
.POST('/api/oauth/preflight')
.send({ provider: 'Google' })
.send({ provider: 'Google', client: 'web' })
.expect(HttpStatus.BAD_REQUEST)
.expect({
status: 400,
@@ -270,7 +282,7 @@ test('should throw if provider is invalid', async t => {
await app
.POST('/api/oauth/preflight')
.send({ provider: 'Invalid', client_nonce: 'test-nonce' })
.send({ provider: 'Invalid', client: 'web', client_nonce: 'test-nonce' })
.expect(HttpStatus.BAD_REQUEST)
.expect({
status: 400,
@@ -575,12 +587,17 @@ test('should be able to sign up with oauth', async t => {
t.truthy(res.body.exchangeCode);
const tokenRes = await app
.POST('/api/auth/native/exchange')
.POST('/api/auth/session/exchange')
.set('x-affine-client-kind', 'native')
.send({ code: res.body.exchangeCode })
.send({
code: res.body.exchangeCode,
installationId: '00000000-0000-4000-8000-000000000005',
platform: 'electron',
})
.expect(201);
t.truthy(tokenRes.body.token);
t.truthy(tokenRes.body.expiresAt);
t.truthy(tokenRes.body.accessToken);
t.is(tokenRes.body.expiresIn, 15 * 60);
t.truthy(tokenRes.body.refreshToken);
const setCookies = res.get('Set-Cookie') ?? [];
for (const name of [
AuthService.sessionCookieName,
@@ -598,7 +615,7 @@ test('should be able to sign up with oauth', async t => {
const sessionUserRes = await app
.GET('/api/auth/session')
.set('Authorization', `Bearer ${tokenRes.body.token}`)
.set('Authorization', `Bearer ${tokenRes.body.accessToken}`)
.expect(200);
const sessionUser = sessionUserRes.body.user;
@@ -148,25 +148,36 @@ function expectNoEvent(
}
async function login(app: TestingApp) {
const user = await app.createUser();
const cookieRes = await app
.POST('/api/auth/sign-in')
.send({ email: user.email, password: user.password })
.expect(200);
const { user, cookieHeader } = await loginWithCookie(app);
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')
.POST('/api/auth/session/exchange')
.set('x-affine-client-kind', 'native')
.send({ code: nativeRes.body.exchangeCode })
.send({
code: nativeRes.body.exchangeCode,
installationId: '00000000-0000-4000-8000-000000000005',
platform: 'electron',
})
.expect(201);
return { user, cookieHeader, token: tokenRes.body.accessToken as string };
}
async function loginWithCookie(app: TestingApp) {
const user = await app.createUser();
const cookieRes = await app
.POST('/api/auth/sign-in')
.set('x-affine-version', '0.26.7')
.send({ email: user.email, password: user.password })
.expect(200);
const cookies = cookieRes.get('Set-Cookie') ?? [];
const cookieHeader = cookies.map(c => c.split(';')[0]).join('; ');
return { user, cookieHeader, token: tokenRes.body.token as string };
return { user, cookieHeader };
}
function createYjsUpdateBase64() {
@@ -366,7 +377,7 @@ test('clientVersion=0.25.0 should only receive space:broadcast-doc-update', asyn
});
test('clientVersion>=0.26.0 should only receive space:broadcast-doc-updates', async t => {
const { user, cookieHeader } = await login(app);
const { user, cookieHeader } = await loginWithCookie(app);
const spaceId = user.id;
const update = createYjsUpdateBase64();
@@ -381,7 +392,7 @@ test('clientVersion>=0.26.0 should only receive space:broadcast-doc-updates', as
await emitWithAck<{ clientId: string; success: boolean }>(
receiver,
'space:join',
{ spaceType: 'userspace', spaceId, clientVersion: '0.26.0' }
{ spaceType: 'userspace', spaceId, clientVersion: '0.26.7' }
)
);
t.true(receiverJoin.success);
@@ -21,7 +21,7 @@ import {
JobQueue,
} from '../../base';
import { SocketIoAdapter } from '../../base/websocket';
import { AuthService } from '../../core/auth';
import { AuthService, AuthSigningKeyRing } from '../../core/auth';
import { Mailer } from '../../core/mail';
import { UserModel } from '../../models';
import {
@@ -110,6 +110,7 @@ export class TestingApp extends ApplyType<INestApplication>() {
async initTestingDB() {
await initTestingDB(this);
await this.get(AuthSigningKeyRing).onConfigInit();
this.clearAuth();
}