mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-26 23:02:57 +08:00
feat(server): support refresh token (#15218)
This commit is contained in:
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user