diff --git a/plugins/auth-backend/src/identity/FirestoreKeyStore.test.ts b/plugins/auth-backend/src/identity/FirestoreKeyStore.test.ts new file mode 100644 index 0000000000..03fcc15a77 --- /dev/null +++ b/plugins/auth-backend/src/identity/FirestoreKeyStore.test.ts @@ -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' }], + }); + }); +}); diff --git a/plugins/auth-backend/src/identity/FirestoreKeyStore.ts b/plugins/auth-backend/src/identity/FirestoreKeyStore.ts new file mode 100644 index 0000000000..cf8c082540 --- /dev/null +++ b/plugins/auth-backend/src/identity/FirestoreKeyStore.ts @@ -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 { + 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 { + // 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(); + * + */ + } +} diff --git a/yarn.lock b/yarn.lock index 9915fb6279..b110393471 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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==