Added a persistent session store

Signed-off-by: Fredrik Adelöw <freben@gmail.com>
This commit is contained in:
Fredrik Adelöw
2023-04-28 13:55:17 +02:00
parent c9eff7b1fa
commit 3ffcdac7d0
11 changed files with 334 additions and 153 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-auth-backend': patch
---
Added a persistent session store through the database
@@ -0,0 +1,52 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// @ts-check
/**
* @param {import('knex').Knex} knex
*/
exports.up = async function up(knex) {
// See https://github.com/gx0r/connect-session-knex
// Modeled loosely after https://github.com/gx0r/connect-session-knex/blob/4e0e36a9afbb13c3000a89f5e341f2d2d4339a02/lib/index.js#L114
// For simplicity we always make the session a string
// Do NOT change around this table or column names; the connect-session-knex library makes assumptions about them
await knex.schema.createTable('sessions', table => {
table.comment('Session data');
table.string('sid').primary().notNullable().comment('ID of the session');
table
.text('sess', 'longtext')
.notNullable()
.comment('Session data, JSON serialized');
table
.timestamp('expired')
.notNullable()
.comment('The point in time when the session expires');
table.index('sid', 'sessions_sid_idx');
table.index('expired', 'sessions_expired_idx');
});
};
/**
* @param {import('knex').Knex} knex
*/
exports.down = async function down(knex) {
await knex.schema.alterTable('sessions', table => {
table.dropIndex([], 'sessions_sid_idx');
table.dropIndex([], 'sessions_expired_idx');
});
await knex.schema.dropTable('sessions');
};
+1
View File
@@ -44,6 +44,7 @@
"@types/express": "^4.17.6",
"@types/passport": "^1.0.3",
"compression": "^1.7.4",
"connect-session-knex": "^3.0.1",
"cookie-parser": "^1.4.5",
"cors": "^2.8.5",
"express": "^4.17.1",
@@ -0,0 +1,76 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
DatabaseManager,
PluginDatabaseManager,
resolvePackagePath,
} from '@backstage/backend-common';
import { ConfigReader } from '@backstage/config';
import { Knex } from 'knex';
const migrationsDir = resolvePackagePath(
'@backstage/plugin-auth-backend',
'migrations',
);
/**
* Ensures that a database connection is established exactly once and only when
* asked for, and runs migrations.
*/
export class AuthDatabase {
readonly #database: PluginDatabaseManager;
#promise: Promise<Knex> | undefined;
static create(database: PluginDatabaseManager): AuthDatabase {
return new AuthDatabase(database);
}
static forTesting(): AuthDatabase {
const config = new ConfigReader({
backend: {
database: {
client: 'better-sqlite3',
connection: ':memory:',
useNullAsDefault: true,
},
},
});
const database = DatabaseManager.fromConfig(config).forPlugin('auth');
return new AuthDatabase(database);
}
static async runMigrations(knex: Knex): Promise<void> {
await knex.migrate.latest({
directory: migrationsDir,
});
}
private constructor(database: PluginDatabaseManager) {
this.#database = database;
}
get(): Promise<Knex> {
this.#promise ??= this.#database.getClient().then(async client => {
if (!this.#database.migrations?.skip) {
await AuthDatabase.runMigrations(client);
}
return client;
});
return this.#promise;
}
}
@@ -14,33 +14,10 @@
* limitations under the License.
*/
import Knex, { Knex as KnexType } from 'knex';
import { DatabaseKeyStore } from './DatabaseKeyStore';
import { DateTime } from 'luxon';
function createDatabaseManager(
client: KnexType,
skipMigrations: boolean = false,
) {
return {
getClient: async () => client,
migrations: {
skip: skipMigrations,
},
};
}
function createDB() {
const knex = Knex({
client: 'better-sqlite3',
connection: ':memory:',
useNullAsDefault: true,
});
knex.client.pool.on('createSuccess', (_eventId: any, resource: any) => {
resource.run('PRAGMA foreign_keys = ON', () => {});
});
return knex;
}
import { AuthDatabase } from '../database/AuthDatabase';
import { DatabaseKeyStore } from './DatabaseKeyStore';
import { TestDatabases } from '@backstage/backend-test-utils';
const keyBase = {
use: 'sig',
@@ -48,95 +25,107 @@ const keyBase = {
alg: 'Base64',
} as const;
jest.setTimeout(60_000);
describe('DatabaseKeyStore', () => {
it('should store a key', async () => {
const client = createDB();
const store = await DatabaseKeyStore.create({
database: createDatabaseManager(client),
});
const key = {
kid: '123',
...keyBase,
};
await expect(store.listKeys()).resolves.toEqual({ items: [] });
await store.addKey(key);
const { items } = await store.listKeys();
expect(items).toEqual([{ createdAt: expect.anything(), key }]);
expect(
Math.abs(
DateTime.fromJSDate(items[0].createdAt).diffNow('seconds').seconds,
),
).toBeLessThan(10);
const databases = TestDatabases.create({
ids: ['POSTGRES_13', 'POSTGRES_9', 'SQLITE_3'],
});
it('should remove stored keys', async () => {
const client = createDB();
const store = await DatabaseKeyStore.create({
database: createDatabaseManager(client),
});
it.each(databases.eachSupportedId())(
'should store a key, %p',
async databaseId => {
const knex = await databases.init(databaseId);
await AuthDatabase.runMigrations(knex);
const key1 = { kid: '1', ...keyBase };
const key2 = { kid: '2', ...keyBase };
const key3 = { kid: '3', ...keyBase };
const store = new DatabaseKeyStore(knex);
await store.addKey(key1);
await store.addKey(key2);
await store.addKey(key3);
const key = {
kid: '123',
...keyBase,
};
await expect(store.listKeys()).resolves.toEqual({
items: [
{ key: key1, createdAt: expect.anything() },
{ key: key2, createdAt: expect.anything() },
{ key: key3, createdAt: expect.anything() },
],
});
await expect(store.listKeys()).resolves.toEqual({ items: [] });
await store.addKey(key);
store.removeKeys(['1']);
const { items } = await store.listKeys();
expect(items).toEqual([{ createdAt: expect.anything(), key }]);
expect(
Math.abs(
DateTime.fromJSDate(items[0].createdAt).diffNow('seconds').seconds,
),
).toBeLessThan(10);
},
);
await expect(store.listKeys()).resolves.toEqual({
items: [
{ key: key2, createdAt: expect.anything() },
{ key: key3, createdAt: expect.anything() },
],
});
it.each(databases.eachSupportedId())(
'should remove stored keys, %p',
async databaseId => {
const knex = await databases.init(databaseId);
await AuthDatabase.runMigrations(knex);
store.removeKeys(['1', '2']);
const store = new DatabaseKeyStore(knex);
await expect(store.listKeys()).resolves.toEqual({
items: [{ key: key3, createdAt: expect.anything() }],
});
const key1 = { kid: '1', ...keyBase };
const key2 = { kid: '2', ...keyBase };
const key3 = { kid: '3', ...keyBase };
store.removeKeys([]);
await store.addKey(key1);
await store.addKey(key2);
await store.addKey(key3);
await expect(store.listKeys()).resolves.toEqual({
items: [{ key: key3, createdAt: expect.anything() }],
});
await expect(store.listKeys()).resolves.toEqual({
items: [
{ key: key1, createdAt: expect.anything() },
{ key: key2, createdAt: expect.anything() },
{ key: key3, createdAt: expect.anything() },
],
});
store.removeKeys(['3', '4']);
await store.removeKeys(['1']);
await expect(store.listKeys()).resolves.toEqual({
items: [],
});
await expect(store.listKeys()).resolves.toEqual({
items: [
{ key: key2, createdAt: expect.anything() },
{ key: key3, createdAt: expect.anything() },
],
});
await store.addKey(key1);
await store.addKey(key2);
await store.addKey(key3);
await store.removeKeys(['1', '2']);
await expect(store.listKeys()).resolves.toEqual({
items: [
{ key: key1, createdAt: expect.anything() },
{ key: key2, createdAt: expect.anything() },
{ key: key3, createdAt: expect.anything() },
],
});
await expect(store.listKeys()).resolves.toEqual({
items: [{ key: key3, createdAt: expect.anything() }],
});
store.removeKeys(['1', '2', '3']);
await store.removeKeys([]);
await expect(store.listKeys()).resolves.toEqual({
items: [],
});
});
await expect(store.listKeys()).resolves.toEqual({
items: [{ key: key3, createdAt: expect.anything() }],
});
await store.removeKeys(['3', '4']);
await expect(store.listKeys()).resolves.toEqual({
items: [],
});
await store.addKey(key1);
await store.addKey(key2);
await store.addKey(key3);
await expect(store.listKeys()).resolves.toEqual({
items: [
{ key: key1, createdAt: expect.anything() },
{ key: key2, createdAt: expect.anything() },
{ key: key3, createdAt: expect.anything() },
],
});
await store.removeKeys(['1', '2', '3']);
await expect(store.listKeys()).resolves.toEqual({
items: [],
});
},
);
});
@@ -14,19 +14,10 @@
* limitations under the License.
*/
import {
PluginDatabaseManager,
resolvePackagePath,
} from '@backstage/backend-common';
import { Knex } from 'knex';
import { DateTime } from 'luxon';
import { AnyJWK, KeyStore, StoredKey } from './types';
const migrationsDir = resolvePackagePath(
'@backstage/plugin-auth-backend',
'migrations',
);
const TABLE = 'signing_keys';
type Row = {
@@ -35,10 +26,6 @@ type Row = {
key: string;
};
type Options = {
database: PluginDatabaseManager;
};
const parseDate = (date: string | Date) => {
const parsedDate =
typeof date === 'string'
@@ -55,24 +42,7 @@ const parseDate = (date: string | Date) => {
};
export class DatabaseKeyStore implements KeyStore {
static async create(options: Options): Promise<DatabaseKeyStore> {
const { database } = options;
const client = await database.getClient();
if (!database.migrations?.skip) {
await client.migrate.latest({
directory: migrationsDir,
});
}
return new DatabaseKeyStore(client);
}
private readonly client: Knex;
private constructor(client: Knex) {
this.client = client;
}
constructor(private readonly client: Knex) {}
async addKey(key: AnyJWK): Promise<void> {
await this.client<Row>(TABLE).insert({
@@ -14,13 +14,12 @@
* limitations under the License.
*/
import { DatabaseManager } from '@backstage/backend-common';
import { ConfigReader } from '@backstage/config';
import { MemoryKeyStore } from './MemoryKeyStore';
import { AuthDatabase } from '../database/AuthDatabase';
import { DatabaseKeyStore } from './DatabaseKeyStore';
import { FirestoreKeyStore } from './FirestoreKeyStore';
import { KeyStores } from './KeyStores';
import { MemoryKeyStore } from './MemoryKeyStore';
describe('KeyStores', () => {
const defaultConfigOptions = {
@@ -46,18 +45,9 @@ describe('KeyStores', () => {
});
it('can handle without auth config', async () => {
const config = new ConfigReader({
backend: {
database: {
client: 'better-sqlite3',
connection: ':memory:',
},
},
const keyStore = await KeyStores.fromConfig(new ConfigReader({}), {
database: AuthDatabase.forTesting(),
});
const database =
DatabaseManager.fromConfig(config).forPlugin('auth-backend');
const keyStore = await KeyStores.fromConfig(config, { database });
expect(keyStore).toBeInstanceOf(DatabaseKeyStore);
});
@@ -14,20 +14,20 @@
* limitations under the License.
*/
import { Logger } from 'winston';
import { pickBy } from 'lodash';
import { Logger } from 'winston';
import { PluginDatabaseManager } from '@backstage/backend-common';
import { Config } from '@backstage/config';
import { AuthDatabase } from '../database/AuthDatabase';
import { DatabaseKeyStore } from './DatabaseKeyStore';
import { MemoryKeyStore } from './MemoryKeyStore';
import { FirestoreKeyStore } from './FirestoreKeyStore';
import { MemoryKeyStore } from './MemoryKeyStore';
import { KeyStore } from './types';
type Options = {
logger?: Logger;
database?: PluginDatabaseManager;
database: AuthDatabase;
};
export class KeyStores {
@@ -52,8 +52,7 @@ export class KeyStores {
if (!database) {
throw new Error('This KeyStore provider requires a database');
}
return await DatabaseKeyStore.create({ database });
return new DatabaseKeyStore(await database.get());
}
if (provider === 'memory') {
@@ -0,0 +1,78 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Knex } from 'knex';
import { TestDatabases } from '@backstage/backend-test-utils';
import fs from 'fs';
const migrationsDir = `${__dirname}/../migrations`;
const migrationsFiles = fs.readdirSync(migrationsDir).sort();
async function migrateUpOnce(knex: Knex): Promise<void> {
await knex.migrate.up({ directory: migrationsDir });
}
async function migrateDownOnce(knex: Knex): Promise<void> {
await knex.migrate.down({ directory: migrationsDir });
}
async function migrateUntilBefore(knex: Knex, target: string): Promise<void> {
const index = migrationsFiles.indexOf(target);
if (index === -1) {
throw new Error(`Migration ${target} not found`);
}
for (let i = 0; i < index; i++) {
await migrateUpOnce(knex);
}
}
jest.setTimeout(60_000);
describe('migrations', () => {
const databases = TestDatabases.create({
ids: ['POSTGRES_13', 'POSTGRES_9', 'SQLITE_3'],
});
it.each(databases.eachSupportedId())(
'20230428155633_sessions.js, %p',
async databaseId => {
const knex = await databases.init(databaseId);
await migrateUntilBefore(knex, '20230428155633_sessions.js');
await migrateUpOnce(knex);
// Ensure that large cookies are supported
const data = `{"cookie":"${'a'.repeat(100_000)}"}`;
await knex
.insert({ sid: 'abc', expired: knex.fn.now(), sess: data })
.into('sessions');
await knex
.insert({ sid: 'def', expired: knex.fn.now(), sess: data })
.into('sessions');
await expect(knex('sessions').orderBy('sid', 'asc')).resolves.toEqual([
{ sid: 'abc', expired: expect.anything(), sess: data },
{ sid: 'def', expired: expect.anything(), sess: data },
]);
await migrateDownOnce(knex);
await expect(knex('sessions')).rejects.toThrow();
await knex.destroy();
},
);
});
+12 -2
View File
@@ -32,9 +32,11 @@ import { CatalogApi, CatalogClient } from '@backstage/catalog-client';
import { Config } from '@backstage/config';
import { createOidcRouter, TokenFactory, KeyStores } from '../identity';
import session from 'express-session';
import connectSessionKnex from 'connect-session-knex';
import passport from 'passport';
import { Minimatch } from 'minimatch';
import { CatalogAuthResolverContext } from '../lib/resolvers';
import { AuthDatabase } from '../database/AuthDatabase';
/** @public */
export type ProviderFactories = { [s: string]: AuthProviderFactory };
@@ -70,7 +72,11 @@ export async function createRouter(
const appUrl = config.getString('app.baseUrl');
const authUrl = await discovery.getExternalBaseUrl('auth');
const keyStore = await KeyStores.fromConfig(config, { logger, database });
const authDb = AuthDatabase.create(database);
const keyStore = await KeyStores.fromConfig(config, {
logger,
database: authDb,
});
const keyDurationSeconds = 3600;
const tokenIssuer = new TokenFactory({
@@ -84,14 +90,18 @@ export async function createRouter(
const secret = config.getOptionalString('auth.session.secret');
if (secret) {
router.use(cookieParser(secret));
// TODO: Configure the server-side session storage. The default MemoryStore is not designed for production
const enforceCookieSSL = authUrl.startsWith('https');
const KnexSessionStore = connectSessionKnex(session);
router.use(
session({
secret,
saveUninitialized: false,
resave: false,
cookie: { secure: enforceCookieSSL ? 'auto' : false },
store: new KnexSessionStore({
createtable: false,
knex: await authDb.get(),
}),
}),
);
router.use(passport.initialize());
+12 -1
View File
@@ -4796,6 +4796,7 @@ __metadata:
"@types/passport-strategy": ^0.2.35
"@types/xml2js": ^0.4.7
compression: ^1.7.4
connect-session-knex: ^3.0.1
cookie-parser: ^1.4.5
cors: ^2.8.5
express: ^4.17.1
@@ -20544,6 +20545,16 @@ __metadata:
languageName: node
linkType: hard
"connect-session-knex@npm:^3.0.1":
version: 3.0.1
resolution: "connect-session-knex@npm:3.0.1"
dependencies:
bluebird: ^3.7.2
knex: ^2.3.0
checksum: f5a80c3c34d30e7cd4e79a6aae09d112825986d51ddc3a4563ef95ade425178239d4e3e05420fb3b6204b2353b0dfd754d318b288989b539583f6c0e52758b7a
languageName: node
linkType: hard
"consola@npm:^2.15.0":
version: 2.15.3
resolution: "consola@npm:2.15.3"
@@ -28982,7 +28993,7 @@ __metadata:
languageName: node
linkType: hard
"knex@npm:^2.0.0, knex@npm:^2.4.2":
"knex@npm:^2.0.0, knex@npm:^2.3.0, knex@npm:^2.4.2":
version: 2.4.2
resolution: "knex@npm:2.4.2"
dependencies: