Files
qinglong/test/back/legacyPanelAuthentication.test.cjs

417 lines
12 KiB
JavaScript

require('ts-node/register/transpile-only');
const assert = require('node:assert/strict');
const { createHash } = require('node:crypto');
const { test } = require('node:test');
const jwt = require('jsonwebtoken');
const { Sequelize } = require('sequelize');
const {
defineSchemaMigrationModel,
} = require('../../back/data/schemaMigration');
const {
IDENTITY_AUTHENTICATION_BINDING_CURRENT_INDEX,
IDENTITY_AUTHENTICATION_BINDING_SUBJECT_INDEX,
IDENTITY_AUTHENTICATION_BINDING_TABLE,
IDENTITY_SUBJECT_STATUS_INDEX,
IDENTITY_SUBJECT_TABLE,
identityDirectoryMigration,
} = require('../../back/migrations/0019-identity-directory');
const { runMigrations } = require('../../back/migrations/runner');
const {
LegacyAuthInfoSessionSource,
LegacyPanelSessionUnavailableError,
MAX_LEGACY_PANEL_TOKENS_PER_PLATFORM,
} = require('../../back/runtime/adapters/authentication/legacyAuthInfoSessionSource');
const {
LegacySequelizeIdentityDirectoryRepository,
} = require('../../back/runtime/adapters/legacy-sequelize/identityDirectoryRepository');
const {
LegacyPanelAuthenticationRejectedError,
LegacyPanelAuthenticationService,
LegacyPanelAuthenticationUnavailableError,
} = require('../../back/runtime/application/legacyPanelAuthenticationService');
const {
IdentityDirectoryUnavailableError,
LEGACY_PANEL_IDENTITY_PROVIDER,
LEGACY_PANEL_PROVIDER_SUBJECT,
LEGACY_PRIMARY_USER_SUBJECT_ID,
} = require('../../back/runtime/domain/identityDirectory');
const SECRET = 'test-only-legacy-jwt-secret';
const ISSUED_AT_SECONDS = 100;
const EXPIRES_AT_SECONDS = 200;
const NOW_MS = 150_000;
function signToken(
payload = {
data: 'legacy-session-random-data',
iat: ISSUED_AT_SECONDS,
exp: EXPIRES_AT_SECONDS,
},
algorithm = 'HS384',
secret = SECRET,
) {
return jwt.sign(payload, secret, { algorithm });
}
async function setup(t) {
const database = new Sequelize({
dialect: 'sqlite',
storage: ':memory:',
logging: false,
});
t.after(() => database.close());
await runMigrations({
database,
migrationModel: defineSchemaMigrationModel(database),
migrations: [identityDirectoryMigration],
logger: { info() {} },
});
return {
database,
directory: new LegacySequelizeIdentityDirectoryRepository(database),
};
}
function sessionSource(snapshot) {
return new LegacyAuthInfoSessionSource(async () => snapshot);
}
function authentication(directory, snapshot, secret = SECRET) {
return new LegacyPanelAuthenticationService(
directory,
sessionSource(snapshot),
secret,
);
}
test('migration creates a stable singleton identity without copying legacy credentials', async (t) => {
const { database, directory } = await setup(t);
const subjects = await database
.getQueryInterface()
.select(null, IDENTITY_SUBJECT_TABLE);
const bindings = await database
.getQueryInterface()
.select(null, IDENTITY_AUTHENTICATION_BINDING_TABLE);
assert.deepEqual(subjects, [
{
id: LEGACY_PRIMARY_USER_SUBJECT_ID,
type: 'user',
status: 'active',
version: 1,
created_at_ms: 0,
updated_at_ms: 0,
},
]);
assert.deepEqual(bindings, [
{
provider: LEGACY_PANEL_IDENTITY_PROVIDER,
provider_subject: LEGACY_PANEL_PROVIDER_SUBJECT,
version: 1,
state: 'active',
subject_id: LEGACY_PRIMARY_USER_SUBJECT_ID,
created_at_ms: 0,
},
]);
const persisted = JSON.stringify({ subjects, bindings });
assert.equal(persisted.includes('username'), false);
assert.equal(persisted.includes('password'), false);
assert.equal(persisted.includes('token'), false);
assert.deepEqual(
await directory.resolveAuthenticationSubject(
LEGACY_PANEL_IDENTITY_PROVIDER,
LEGACY_PANEL_PROVIDER_SUBJECT,
),
{ type: 'user', id: LEGACY_PRIMARY_USER_SUBJECT_ID },
);
const subjectIndexes = new Set(
(await database.getQueryInterface().showIndex(IDENTITY_SUBJECT_TABLE)).map(
(index) => index.name,
),
);
const bindingIndexes = new Set(
(
await database
.getQueryInterface()
.showIndex(IDENTITY_AUTHENTICATION_BINDING_TABLE)
).map((index) => index.name),
);
assert.ok(subjectIndexes.has(IDENTITY_SUBJECT_STATUS_INDEX));
assert.ok(bindingIndexes.has(IDENTITY_AUTHENTICATION_BINDING_CURRENT_INDEX));
assert.ok(bindingIndexes.has(IDENTITY_AUTHENTICATION_BINDING_SUBJECT_INDEX));
});
test('authenticates a current HS384 legacy session as one stable single-factor user', async (t) => {
const { directory } = await setup(t);
const token = signToken();
const service = authentication(directory, {
token: '',
tokens: {
desktop: [
{
value: token,
timestamp: 123,
platform: 'desktop',
},
],
},
username: 'a-display-name-that-may-change',
twoFactorActivated: true,
});
assert.deepEqual(
await service.authenticate({ token, platform: 'desktop', nowMs: NOW_MS }),
{
subject: { type: 'user', id: LEGACY_PRIMARY_USER_SUBJECT_ID },
authenticationId: `legacy_panel:${createHash('sha256')
.update(token, 'utf8')
.digest('hex')}`,
authenticatedAtMs: ISSUED_AT_SECONDS * 1000,
expiresAtMs: EXPIRES_AT_SECONDS * 1000,
assurance: 'single_factor',
},
);
});
test('supports only the bounded legacy primary, string and TokenInfo list formats', async () => {
const token = signToken();
assert.equal(await sessionSource({ token }).isActive(token, 'mobile'), true);
assert.equal(
await sessionSource({ tokens: { desktop: token } }).isActive(
token,
'desktop',
),
true,
);
assert.equal(
await sessionSource({
tokens: { desktop: [{ value: token }] },
}).isActive(token, 'desktop'),
true,
);
assert.equal(
await sessionSource({ tokens: { mobile: [{ value: token }] } }).isActive(
token,
'desktop',
),
false,
);
await assert.rejects(
sessionSource({
tokens: {
desktop: Array.from(
{ length: MAX_LEGACY_PANEL_TOKENS_PER_PLATFORM + 1 },
() => ({ value: token }),
),
},
}).isActive(token, 'desktop'),
LegacyPanelSessionUnavailableError,
);
await assert.rejects(
sessionSource({ tokens: { desktop: [{}] } }).isActive(token, 'desktop'),
LegacyPanelSessionUnavailableError,
);
});
test('rejects logout, platform drift, expiry, wrong signature and wrong algorithm', async (t) => {
const { directory } = await setup(t);
const token = signToken();
for (const [service, request] of [
[authentication(directory, { tokens: {} }), {}],
[
authentication(directory, {
tokens: { mobile: [{ value: token }] },
}),
{},
],
[
authentication(directory, {
tokens: { desktop: [{ value: token }] },
}),
{ nowMs: EXPIRES_AT_SECONDS * 1000 },
],
[
authentication(
directory,
{ tokens: { desktop: [{ value: token }] } },
'different-secret',
),
{},
],
[
authentication(directory, {
tokens: { desktop: [{ value: signToken(undefined, 'HS256') }] },
}),
{ token: signToken(undefined, 'HS256') },
],
]) {
await assert.rejects(
service.authenticate({
token,
platform: 'desktop',
nowMs: NOW_MS,
...request,
}),
LegacyPanelAuthenticationRejectedError,
);
}
});
test('rejects extensible JWTs and request-supplied subjects before identity lookup', async (t) => {
const { directory } = await setup(t);
let sessionReads = 0;
const extraPayloadToken = signToken({
data: 'legacy-session-random-data',
iat: ISSUED_AT_SECONDS,
exp: EXPIRES_AT_SECONDS,
subject: 'attacker',
});
const service = new LegacyPanelAuthenticationService(
directory,
{
async isActive() {
sessionReads += 1;
return true;
},
},
SECRET,
);
await assert.rejects(
service.authenticate({
token: extraPayloadToken,
platform: 'desktop',
nowMs: NOW_MS,
}),
LegacyPanelAuthenticationRejectedError,
);
assert.equal(sessionReads, 0);
const futureIssuedToken = signToken({
data: 'legacy-session-random-data',
iat: ISSUED_AT_SECONDS + 60,
exp: EXPIRES_AT_SECONDS,
});
await assert.rejects(
service.authenticate({
token: futureIssuedToken,
platform: 'desktop',
nowMs: NOW_MS,
}),
LegacyPanelAuthenticationRejectedError,
);
assert.equal(sessionReads, 0);
await assert.rejects(
service.authenticate({
token: signToken(),
platform: 'desktop',
nowMs: NOW_MS,
subject: { type: 'user', id: 'attacker' },
}),
/request shape is invalid/,
);
assert.equal(sessionReads, 0);
});
test('revocation and subject disablement remove legacy authentication authority', async (t) => {
const revoked = await setup(t);
await revoked.database
.getQueryInterface()
.bulkInsert(IDENTITY_AUTHENTICATION_BINDING_TABLE, [
{
provider: LEGACY_PANEL_IDENTITY_PROVIDER,
provider_subject: LEGACY_PANEL_PROVIDER_SUBJECT,
version: 2,
state: 'revoked',
subject_id: LEGACY_PRIMARY_USER_SUBJECT_ID,
created_at_ms: NOW_MS,
},
]);
assert.equal(
await revoked.directory.resolveAuthenticationSubject(
LEGACY_PANEL_IDENTITY_PROVIDER,
LEGACY_PANEL_PROVIDER_SUBJECT,
),
null,
);
const disabled = await setup(t);
await disabled.database
.getQueryInterface()
.bulkUpdate(
IDENTITY_SUBJECT_TABLE,
{ status: 'disabled', version: 2, updated_at_ms: NOW_MS },
{ id: LEGACY_PRIMARY_USER_SUBJECT_ID },
);
const token = signToken();
await assert.rejects(
authentication(disabled.directory, {
tokens: { desktop: [{ value: token }] },
}).authenticate({ token, platform: 'desktop', nowMs: NOW_MS }),
LegacyPanelAuthenticationRejectedError,
);
});
test('fails closed on corrupt identity storage and session source failures', async (t) => {
const { database, directory } = await setup(t);
await database.query('PRAGMA ignore_check_constraints = ON');
await database
.getQueryInterface()
.bulkUpdate(
IDENTITY_AUTHENTICATION_BINDING_TABLE,
{ state: 'corrupt' },
{ provider: LEGACY_PANEL_IDENTITY_PROVIDER },
);
await assert.rejects(
directory.resolveAuthenticationSubject(
LEGACY_PANEL_IDENTITY_PROVIDER,
LEGACY_PANEL_PROVIDER_SUBJECT,
),
IdentityDirectoryUnavailableError,
);
const orphaned = await setup(t);
await orphaned.database.query('PRAGMA foreign_keys = OFF');
await orphaned.database
.getQueryInterface()
.bulkDelete(IDENTITY_SUBJECT_TABLE, {
id: LEGACY_PRIMARY_USER_SUBJECT_ID,
});
await assert.rejects(
orphaned.directory.resolveAuthenticationSubject(
LEGACY_PANEL_IDENTITY_PROVIDER,
LEGACY_PANEL_PROVIDER_SUBJECT,
),
IdentityDirectoryUnavailableError,
);
const token = signToken();
const service = new LegacyPanelAuthenticationService(
directory,
{
async isActive() {
throw new Error(`must not leak ${token}`);
},
},
SECRET,
);
await assert.rejects(
service.authenticate({ token, platform: 'desktop', nowMs: NOW_MS }),
(error) => {
assert.ok(error instanceof LegacyPanelAuthenticationUnavailableError);
assert.equal(error.message.includes(token), false);
return true;
},
);
});
test('rejects non-SQLite identity directory repositories', () => {
assert.throws(
() =>
new LegacySequelizeIdentityDirectoryRepository({
getDialect() {
return 'postgres';
},
}),
/SQLite-only/,
);
});