diff --git a/package.json b/package.json index d512459..cb6f250 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "@types/underscore": "1.10.24", "@types/uuid": "^8.3.0", "axios": "^0.21.1", + "dayjs": "^1.10.4", "eta": "^1.12.1", "express": "^4.17.1", "fp-ts": "^2.9.5", diff --git a/src/access_tokens.ts b/src/access_tokens.ts new file mode 100644 index 0000000..d46f7f5 --- /dev/null +++ b/src/access_tokens.ts @@ -0,0 +1,50 @@ +import dayjs, { Dayjs } from 'dayjs'; +import { v4 as uuid } from 'uuid'; + +export interface Clock { + now(): Dayjs +} + +type AccessToken = { + value: string; + authToken: string; + expiry: Dayjs; +}; + +export interface AccessTokens { + mint(authToken: string): string; + authTokenFor(value: string): string | undefined; +} + +export class ExpiringAccessTokens implements AccessTokens { + tokens = new Map(); + clock: Clock; + + constructor(clock : Clock = { now: () => dayjs() }) { + this.clock = clock + } + + mint(authToken: string): string { + this.clearOutExpired(); + const accessToken = { + value: uuid(), + authToken, + expiry: this.clock.now().add(12, 'hours') + }; + this.tokens.set(accessToken.value, accessToken); + return accessToken.value; + } + + authTokenFor(value: string): string | undefined { + this.clearOutExpired(); + return this.tokens.get(value)?.authToken; + } + + clearOutExpired() { + Array.from(this.tokens.values()).filter(it => it.expiry.isBefore(this.clock.now())).forEach(expired => { + this.tokens.delete(expired.value); + }) + } + + count = () => this.tokens.size; +} diff --git a/tests/access_tokens.test.ts b/tests/access_tokens.test.ts new file mode 100644 index 0000000..d5cb45a --- /dev/null +++ b/tests/access_tokens.test.ts @@ -0,0 +1,107 @@ +import { v4 as uuid } from 'uuid'; +import { ExpiringAccessTokens } from '../src/access_tokens'; +import dayjs from 'dayjs'; + +describe("ExpiringAccessTokens", () => { + let now = dayjs(); + + const accessTokens = new ExpiringAccessTokens({ now: () => now }) + + describe("tokens", () => { + it("they should be unique", () => { + const authToken = uuid(); + expect(accessTokens.mint(authToken)).not.toEqual(accessTokens.mint(authToken)); + }); + }); + + describe("tokens that dont exist", () => { + it("should return undefined", () => { + expect(accessTokens.authTokenFor("doesnt exist")).toBeUndefined(); + }); + }); + + describe("tokens that have not expired", () => { + it("should be able to return them", () => { + const authToken = uuid(); + + const accessToken = accessTokens.mint(authToken); + + expect(accessTokens.authTokenFor(accessToken)).toEqual(authToken); + }); + + it("should be able to have many per authToken", () => { + const authToken = uuid(); + + const accessToken1 = accessTokens.mint(authToken); + const accessToken2 = accessTokens.mint(authToken); + + + expect(accessTokens.authTokenFor(accessToken1)).toEqual(authToken); + expect(accessTokens.authTokenFor(accessToken2)).toEqual(authToken); + }); + }); + + describe('tokens that have expired', () => { + describe("retrieving it", () => { + it("should return undefined", () => { + const authToken = uuid(); + + now = dayjs(); + const accessToken = accessTokens.mint(authToken); + + now = now.add(12, 'hours').add(1, 'second'); + + expect(accessTokens.authTokenFor(accessToken)).toBeUndefined() + }); + }); + + describe("should be cleared out", () => { + const authToken1 = uuid(); + const authToken2 = uuid(); + + now = dayjs(); + + const accessToken1_1 = accessTokens.mint(authToken1); + const accessToken2_1 = accessTokens.mint(authToken2); + + expect(accessTokens.count()).toEqual(2); + expect(accessTokens.authTokenFor(accessToken1_1)).toEqual(authToken1); + expect(accessTokens.authTokenFor(accessToken2_1)).toEqual(authToken2); + + now = now.add(12, 'hours').add(1, 'second'); + + const accessToken1_2 = accessTokens.mint(authToken1); + + expect(accessTokens.count()).toEqual(1); + expect(accessTokens.authTokenFor(accessToken1_1)).toBeUndefined(); + expect(accessTokens.authTokenFor(accessToken2_1)).toBeUndefined(); + expect(accessTokens.authTokenFor(accessToken1_2)).toEqual(authToken1); + + now = now.add(6, 'hours'); + + const accessToken2_2 = accessTokens.mint(authToken2); + + expect(accessTokens.count()).toEqual(2); + expect(accessTokens.authTokenFor(accessToken1_1)).toBeUndefined(); + expect(accessTokens.authTokenFor(accessToken2_1)).toBeUndefined(); + expect(accessTokens.authTokenFor(accessToken1_2)).toEqual(authToken1); + expect(accessTokens.authTokenFor(accessToken2_2)).toEqual(authToken2); + + now = now.add(6, 'hours').add(1, 'minute'); + + expect(accessTokens.authTokenFor(accessToken1_1)).toBeUndefined(); + expect(accessTokens.authTokenFor(accessToken2_1)).toBeUndefined(); + expect(accessTokens.authTokenFor(accessToken1_2)).toBeUndefined(); + expect(accessTokens.authTokenFor(accessToken2_2)).toEqual(authToken2); + expect(accessTokens.count()).toEqual(1); + + now = now.add(6, 'hours').add(1, 'minute'); + + expect(accessTokens.authTokenFor(accessToken1_1)).toBeUndefined(); + expect(accessTokens.authTokenFor(accessToken2_1)).toBeUndefined(); + expect(accessTokens.authTokenFor(accessToken1_2)).toBeUndefined(); + expect(accessTokens.authTokenFor(accessToken2_2)).toBeUndefined(); + expect(accessTokens.count()).toEqual(0); + }); + }) +}) \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index c7b64be..f07fa44 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1459,6 +1459,11 @@ data-urls@^2.0.0: whatwg-mimetype "^2.3.0" whatwg-url "^8.0.0" +dayjs@^1.10.4: + version "1.10.4" + resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.10.4.tgz#8e544a9b8683f61783f570980a8a80eaf54ab1e2" + integrity sha512-RI/Hh4kqRc1UKLOAf/T5zdMMX5DQIlDxwUe3wSyMMnEbGunnpENCdbUgM+dW7kXidZqCttBrmw7BhN4TMddkCw== + debug@2.6.9, debug@^2.2.0, debug@^2.3.3: version "2.6.9" resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f"