Add class to use Firestore as a key-store

Signed-off-by: Marcus Eide <eide@spotify.com>
This commit is contained in:
Marcus Eide
2021-10-12 16:14:30 +02:00
parent d8ea1edcdb
commit 3a62f7619a
3 changed files with 293 additions and 1 deletions
@@ -0,0 +1,152 @@
/*
* Copyright 2021 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 { Firestore } from '@google-cloud/firestore';
import { FirestoreKeyStore } from './FirestoreKeyStore';
import { AnyJWK } from './types';
const data = jest.fn().mockReturnValue('data');
const toDate = jest.fn().mockReturnValue('date');
const firestoreMock = {
collection: jest.fn().mockReturnThis(),
delete: jest.fn(),
doc: jest.fn().mockReturnThis(),
get: jest.fn().mockReturnValue({
docs: [{ data, createTime: { toDate } }],
}),
set: jest.fn(),
};
jest.mock('@google-cloud/firestore', () => ({
Firestore: jest.fn().mockImplementation(() => firestoreMock),
}));
describe('FirestoreKeyStore', () => {
const OLD_ENV = process.env;
const key = {
kid: '123',
use: 'sig',
kty: 'plain',
alg: 'Base64',
} as AnyJWK;
beforeEach(() => {
jest.clearAllMocks();
});
afterAll(() => {
process.env = OLD_ENV;
});
it('can create an instance without settings', () => {
const keyStore = FirestoreKeyStore.create();
expect(keyStore).toBeInstanceOf(FirestoreKeyStore);
});
it('can set the project id', async () => {
FirestoreKeyStore.create({ projectId: 'my-project' });
expect(Firestore).toHaveBeenCalledWith(
expect.objectContaining({
projectId: 'my-project',
}),
);
});
it('can handle keyfile file', async () => {
FirestoreKeyStore.create({ keyFilename: 'keyFile.json' });
expect(Firestore).toHaveBeenCalledWith(
expect.objectContaining({
keyFilename: 'keyFile.json',
}),
);
});
it('can use default google credentials', () => {
process.env.GOOGLE_APPLICATION_CREDENTIALS = 'cred.json';
FirestoreKeyStore.create();
expect(Firestore).toHaveBeenCalledWith(
expect.objectContaining({
keyFilename: 'cred.json',
}),
);
});
it('can uses a default path', async () => {
const keyStore = FirestoreKeyStore.create();
await keyStore.addKey(key);
expect(firestoreMock.collection).toBeCalledWith('sessions');
});
it('can set the path', async () => {
const keyStore = FirestoreKeyStore.create({
path: 'my-path',
});
await keyStore.addKey(key);
expect(firestoreMock.collection).toBeCalledWith('my-path');
});
it('can add keys', async () => {
const keyStore = FirestoreKeyStore.create();
await keyStore.addKey(key);
expect(firestoreMock.collection).toBeCalledWith('sessions');
expect(firestoreMock.doc).toBeCalledWith(key.kid);
expect(firestoreMock.set).toHaveBeenCalledWith({
kid: key.kid,
key: JSON.stringify(key),
});
});
it('can delete a single key', async () => {
const keyStore = FirestoreKeyStore.create();
await keyStore.removeKeys(['123']);
expect(firestoreMock.collection).toBeCalledWith('sessions');
expect(firestoreMock.doc).toBeCalledWith('123');
expect(firestoreMock.delete).toBeCalledTimes(1);
});
it('can delete a multiple keys', async () => {
const keyStore = FirestoreKeyStore.create();
await keyStore.removeKeys(['123', '456']);
expect(firestoreMock.collection).toBeCalledWith('sessions');
expect(firestoreMock.doc).toBeCalledWith('123');
expect(firestoreMock.doc).toBeCalledWith('456');
expect(firestoreMock.delete).toBeCalledTimes(2);
});
it('can list keys', async () => {
const keyStore = FirestoreKeyStore.create();
const items = await keyStore.listKeys();
expect(firestoreMock.collection).toBeCalledWith('sessions');
expect(firestoreMock.get).toBeCalledTimes(1);
expect(data).toBeCalledTimes(1);
expect(toDate).toBeCalledTimes(1);
expect(items).toMatchObject({
items: [{ key: 'data', createdAt: 'date' }],
});
});
});
@@ -0,0 +1,90 @@
/*
* Copyright 2021 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 { Firestore, Settings } from '@google-cloud/firestore';
import { AnyJWK, KeyStore, StoredKey } from './types';
type FirestoreSettings = Settings & {
path?: string;
};
export class FirestoreKeyStore implements KeyStore {
static create(settings?: FirestoreSettings): FirestoreKeyStore {
const { projectId, keyFilename, path } = settings ?? {};
const database = new Firestore({
projectId,
keyFilename: keyFilename ?? process.env.GOOGLE_APPLICATION_CREDENTIALS,
});
return new FirestoreKeyStore(database, path ?? 'sessions');
}
private constructor(
private readonly database: Firestore,
private readonly path: string,
) {}
async addKey(key: AnyJWK): Promise<void> {
await this.database
.collection(this.path)
.doc(key.kid)
.set({
kid: key.kid,
key: JSON.stringify(key),
});
}
async listKeys(): Promise<{ items: StoredKey[] }> {
const docs = await this.database.collection(this.path).get();
return {
items: docs.docs.map(doc => ({
key: doc.data() as AnyJWK,
createdAt: doc.createTime.toDate(),
})),
};
}
async removeKeys(kids: string[]): Promise<void> {
// This is probably really slow, but it's done async in the background
for (const kid of kids) {
await this.database.collection(this.path).doc(kid).delete();
}
/**
* This could be achieved with batching but there's a couple of limitations with that:
*
* - A batched write can contain a maximum of 500 operations
* https://firebase.google.com/docs/firestore/manage-data/transactions#batched-writes
*
* - The "in" operator can combine a maximum of 10 equality clauses
* https://firebase.google.com/docs/firestore/query-data/queries#in_not-in_and_array-contains-any
*
* Example:
*
* const batch = this.database.batch();
* const docs = await this.database
* .collection(this.path)
* .where('kid', 'in', kids)
* .get();
* docs.forEach(doc => {
* batch.delete(doc.ref);
* });
* await batch.commit();
*
*/
}
}
+51 -1
View File
@@ -3000,6 +3000,16 @@
dependencies:
google-gax "^2.12.0"
"@google-cloud/firestore@^4.15.1":
version "4.15.1"
resolved "https://registry.npmjs.org/@google-cloud/firestore/-/firestore-4.15.1.tgz#ed764fc76823ce120e68fe8c27ef1edd0650cd93"
integrity sha512-2PWsCkEF1W02QbghSeRsNdYKN1qavrHBP3m72gPDMHQSYrGULOaTi7fSJquQmAtc4iPVB2/x6h80rdLHTATQtA==
dependencies:
fast-deep-equal "^3.1.1"
functional-red-black-tree "^1.0.1"
google-gax "^2.24.1"
protobufjs "^6.8.6"
"@google-cloud/paginator@^3.0.0":
version "3.0.5"
resolved "https://registry.npmjs.org/@google-cloud/paginator/-/paginator-3.0.5.tgz#9d6b96c421a89bd560c1bc2c197c7611ef21db6c"
@@ -15053,6 +15063,21 @@ google-auth-library@^7.0.0, google-auth-library@^7.0.2:
jws "^4.0.0"
lru-cache "^6.0.0"
google-auth-library@^7.6.1:
version "7.10.0"
resolved "https://registry.npmjs.org/google-auth-library/-/google-auth-library-7.10.0.tgz#6ab852f8e1abbe425aec695ac6501f80bd5eba56"
integrity sha512-ICsqaU+lxMHVlDUzMrfVIEqnARw2AwBiZ/2KnNM6BcTf9Nott+Af87DTIzmlnW865p3REUP2MVL0xkPC3a61aQ==
dependencies:
arrify "^2.0.0"
base64-js "^1.3.0"
ecdsa-sig-formatter "^1.0.11"
fast-text-encoding "^1.0.0"
gaxios "^4.0.0"
gcp-metadata "^4.2.0"
gtoken "^5.0.4"
jws "^4.0.0"
lru-cache "^6.0.0"
google-gax@^2.12.0:
version "2.14.1"
resolved "https://registry.npmjs.org/google-gax/-/google-gax-2.14.1.tgz#74885c5d9f01db412917fc49bbf20c4884828d36"
@@ -15071,6 +15096,25 @@ google-gax@^2.12.0:
protobufjs "^6.10.2"
retry-request "^4.0.0"
google-gax@^2.24.1:
version "2.27.0"
resolved "https://registry.npmjs.org/google-gax/-/google-gax-2.27.0.tgz#78ba655ae7707cb92ba37c35932381b5970c1772"
integrity sha512-xcLCeNKCqNm/w0At7/vdZHV/zol/iRS+PSAZTu7i6xNGBra/kWI3cfn4M6ZLQXeUEGbTVLJ4zGm53TVc4lvbDA==
dependencies:
"@grpc/grpc-js" "~1.3.0"
"@grpc/proto-loader" "^0.6.1"
"@types/long" "^4.0.0"
abort-controller "^3.0.0"
duplexify "^4.0.0"
fast-text-encoding "^1.0.3"
google-auth-library "^7.6.1"
is-stream-ended "^0.1.4"
node-fetch "^2.6.1"
object-hash "^2.1.1"
proto3-json-serializer "^0.1.1"
protobufjs "6.11.2"
retry-request "^4.0.0"
google-p12-pem@^3.0.3:
version "3.0.3"
resolved "https://registry.npmjs.org/google-p12-pem/-/google-p12-pem-3.0.3.tgz#673ac3a75d3903a87f05878f3c75e06fc151669e"
@@ -19722,6 +19766,7 @@ minipass-fetch@^1.3.0, minipass-fetch@^1.3.2:
resolved "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-1.3.3.tgz#34c7cea038c817a8658461bf35174551dce17a0a"
integrity sha512-akCrLDWfbdAWkMLBxJEeWTdNsjML+dt5YgOI4gJ53vuO0vrmYQkUPxa6j6V65s9CcePIr2SSWqjT2EcrNseryQ==
dependencies:
encoding "^0.1.12"
minipass "^3.1.0"
minipass-sized "^1.0.3"
minizlib "^2.0.0"
@@ -22556,7 +22601,12 @@ proto-list@~1.2.1:
resolved "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz#212d5bfe1318306a420f6402b8e26ff39647a849"
integrity sha1-IS1b/hMYMGpCD2QCuOJv85ZHqEk=
protobufjs@^6.10.0, protobufjs@^6.10.2:
proto3-json-serializer@^0.1.1:
version "0.1.4"
resolved "https://registry.npmjs.org/proto3-json-serializer/-/proto3-json-serializer-0.1.4.tgz#aa2dc4c9c9b7ea05631354b2c2e52c227539a7f0"
integrity sha512-bFzdsKU/zaTobWrRxRniMZIzzcgKYlmBWL1gAcTXZ2M7TQTGPI0JoYYs6bN7tpWj59ZCfwg7Ii/A2e8BbQGYnQ==
protobufjs@6.11.2, protobufjs@^6.10.0, protobufjs@^6.10.2, protobufjs@^6.8.6:
version "6.11.2"
resolved "https://registry.npmjs.org/protobufjs/-/protobufjs-6.11.2.tgz#de39fabd4ed32beaa08e9bb1e30d08544c1edf8b"
integrity sha512-4BQJoPooKJl2G9j3XftkIXjoC9C0Av2NOrWmbLWT1vH32GcSUHjM0Arra6UfTsVyfMAuFzaLucXn1sadxJydAw==