diff --git a/.eslintrc.json b/.eslintrc.json index b20a733c..3b0ce798 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -16,7 +16,8 @@ "no-unmodified-loop-condition": ["error"], "no-unreachable-loop": ["error"], "no-unused-private-class-members": ["error"], - "require-atomic-updates": ["error"] + "require-atomic-updates": ["error"], + "@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_" }] }, "parser": "@typescript-eslint/parser", "plugins": [ diff --git a/__test__/auth/AccessTokenService.test.ts b/__test__/auth/AccessTokenService.test.ts new file mode 100644 index 00000000..62a986c7 --- /dev/null +++ b/__test__/auth/AccessTokenService.test.ts @@ -0,0 +1,86 @@ +import AccessTokenService from "../../src/features/auth/domain/accessToken/AccessTokenService" +import OAuthToken from "../../src/features/auth/domain/oAuthToken/OAuthToken" + +test("It gets the access token for the user", async () => { + let readUserID: string | undefined + const sut = new AccessTokenService({ + userIdReader: { + async getUserId() { + return "1234" + } + }, + repository: { + async get(userId) { + readUserID = userId + return { accessToken: "foo", refreshToken: "bar" } + }, + async set() {}, + async delete() {}, + }, + refresher: { + async refreshOAuthToken() { + return { accessToken: "foo", refreshToken: "bar" } + } + } + }) + const accessToken = await sut.getAccessToken() + expect(readUserID).toBe("1234") + expect(accessToken).toBe("foo") +}) + +test("It refreshes OAuth using stored refresh token", async () => { + let usedRefreshToken: string | undefined + const sut = new AccessTokenService({ + userIdReader: { + async getUserId() { + return "1234" + } + }, + repository: { + async get() { + return { accessToken: "oldAccessToken", refreshToken: "oldRefreshToken" } + }, + async set() {}, + async delete() {}, + }, + refresher: { + async refreshOAuthToken(refreshToken) { + usedRefreshToken = refreshToken + return { accessToken: "newAccessToken", refreshToken: "newRefreshToken" } + } + } + }) + await sut.refreshAccessToken("oldAccessToken") + expect(usedRefreshToken).toBe("oldRefreshToken") +}) + +test("It stores the new OAuth token for the user", async () => { + let storedUserId: string | undefined + let storedOAuthToken: OAuthToken | undefined + const sut = new AccessTokenService({ + userIdReader: { + async getUserId() { + return "1234" + } + }, + repository: { + async get() { + return { accessToken: "oldAccessToken", refreshToken: "oldRefreshToken" } + }, + async set(userId, oAuthToken) { + storedUserId = userId + storedOAuthToken = oAuthToken + }, + async delete() {}, + }, + refresher: { + async refreshOAuthToken() { + return { accessToken: "newAccessToken", refreshToken: "newRefreshToken" } + } + } + }) + await sut.refreshAccessToken("foo") + expect(storedUserId).toBe("1234") + expect(storedOAuthToken?.accessToken).toBe("newAccessToken") + expect(storedOAuthToken?.refreshToken).toBe("newRefreshToken") +}) diff --git a/__test__/auth/InitialOAuthTokenService.test.ts b/__test__/auth/InitialOAuthTokenService.test.ts index e1e9263e..307b61f6 100644 --- a/__test__/auth/InitialOAuthTokenService.test.ts +++ b/__test__/auth/InitialOAuthTokenService.test.ts @@ -1,5 +1,5 @@ -import InitialOAuthTokenService from "../../src/features/auth/domain/InitialOAuthTokenService" -import OAuthToken from "../../src/features/auth/domain/OAuthToken" +import InitialOAuthTokenService from "../../src/features/auth/domain/oAuthToken/InitialOAuthTokenService" +import OAuthToken from "../../src/features/auth/domain/oAuthToken/OAuthToken" test("It fetches refresh token for specified user", async () => { let fetchedUserId: string | undefined @@ -16,11 +16,11 @@ test("It fetches refresh token for specified user", async () => { } }, oAuthTokenRepository: { - async getOAuthToken() { + async get() { return { accessToken: "foo", refreshToken: "bar" } }, - async storeOAuthToken() {}, - async deleteOAuthToken() {} + async set() {}, + async delete() {} } }) await sut.fetchInitialAuthTokenForUser("123") @@ -42,11 +42,11 @@ test("It refreshes the fetched refresh token", async () => { } }, oAuthTokenRepository: { - async getOAuthToken() { + async get() { return { accessToken: "foo", refreshToken: "bar" } }, - async storeOAuthToken() {}, - async deleteOAuthToken() {} + async set() {}, + async delete() {} } }) await sut.fetchInitialAuthTokenForUser("123") @@ -68,18 +68,18 @@ test("It stores the refreshed auth token for the correct user ID", async () => { } }, oAuthTokenRepository: { - async getOAuthToken() { + async get() { return { accessToken: "foo", refreshToken: "bar" } }, - async storeOAuthToken(userId, token) { + async set(userId, token) { storedAuthToken = token storedUserId = userId }, - async deleteOAuthToken() {} + async delete() {} } }) await sut.fetchInitialAuthTokenForUser("123") expect(storedAuthToken?.accessToken).toBe("foo") expect(storedAuthToken?.refreshToken).toBe("bar") expect(storedUserId).toBe("123") -}) +}) \ No newline at end of file diff --git a/__test__/auth/LockingAccessTokenService.test.ts b/__test__/auth/LockingAccessTokenService.test.ts new file mode 100644 index 00000000..28dad317 --- /dev/null +++ b/__test__/auth/LockingAccessTokenService.test.ts @@ -0,0 +1,91 @@ +import LockingAccessTokenService from "../../src/features/auth/domain/accessToken/LockingAccessTokenService" + +test("It reads access token", async () => { + let didReadAccessToken = false + const sut = new LockingAccessTokenService({ + async makeMutex() { + return { + async acquire() {}, + async release() {} + } + } + }, { + async getAccessToken() { + didReadAccessToken = true + return "foo" + }, + async refreshAccessToken() { + return "foo" + } + }) + await sut.getAccessToken() + expect(didReadAccessToken).toBeTruthy() +}) + +test("It acquires a lock", async () => { + let didAcquireLock = false + const sut = new LockingAccessTokenService({ + async makeMutex() { + return { + async acquire() { + didAcquireLock = true + }, + async release() {} + } + } + }, { + async getAccessToken() { + return "foo" + }, + async refreshAccessToken() { + return "foo" + } + }) + await sut.refreshAccessToken("bar") + expect(didAcquireLock).toBeTruthy() +}) + +test("It releases the acquired lock", async () => { + let didReleaseLock = false + const sut = new LockingAccessTokenService({ + async makeMutex() { + return { + async acquire() {}, + async release() { + didReleaseLock = true + } + } + } + }, { + async getAccessToken() { + return "foo" + }, + async refreshAccessToken() { + return "foo" + } + }) + await sut.refreshAccessToken("bar") + expect(didReleaseLock).toBeTruthy() +}) + +test("It refreshes access token", async () => { + let didRefreshAccessToken = false + const sut = new LockingAccessTokenService({ + async makeMutex() { + return { + async acquire() {}, + async release() {} + } + } + }, { + async getAccessToken() { + return "foo" + }, + async refreshAccessToken() { + didRefreshAccessToken = true + return "foo" + } + }) + await sut.refreshAccessToken("foo") + expect(didRefreshAccessToken).toBeTruthy() +}) diff --git a/__test__/auth/LockingOAuthTokenRefresher.test.ts b/__test__/auth/LockingOAuthTokenRefresher.test.ts deleted file mode 100644 index 6ba65f2b..00000000 --- a/__test__/auth/LockingOAuthTokenRefresher.test.ts +++ /dev/null @@ -1,131 +0,0 @@ -import LockingAccessTokenRefresher from "../../src/features/auth/domain/LockingAccessTokenRefresher" -import OAuthToken from "../../src/features/auth/domain/OAuthToken" - -test("It acquires a lock", async () => { - let didAcquireLock = false - const sut = new LockingAccessTokenRefresher({ - async makeMutex() { - return { - async acquire() { - didAcquireLock = true - }, - async release() {} - } - } - }, { - async getOAuthToken() { - return { accessToken: "foo", refreshToken: "bar" } - }, - async storeOAuthToken() {}, - async deleteOAuthToken() {} - }, { - async refreshOAuthToken() { - return { accessToken: "foo", refreshToken: "bar" } - } - }) - await sut.refreshAccessToken("bar") - expect(didAcquireLock).toBeTruthy() -}) - -test("It releases the acquired lock", async () => { - let didReleaseLock = false - const sut = new LockingAccessTokenRefresher({ - async makeMutex() { - return { - async acquire() {}, - async release() { - didReleaseLock = true - } - } - } - }, { - async getOAuthToken() { - return { accessToken: "foo", refreshToken: "bar" } - }, - async storeOAuthToken() {}, - async deleteOAuthToken() {} - }, { - async refreshOAuthToken() { - return { accessToken: "foo", refreshToken: "bar" } - } - }) - await sut.refreshAccessToken("bar") - expect(didReleaseLock).toBeTruthy() -}) - -test("It refreshes the access token when the input access token matches the stored access token", async () => { - let didRefreshAccessToken = false - const sut = new LockingAccessTokenRefresher({ - async makeMutex() { - return { - async acquire() {}, - async release() {} - } - } - }, { - async getOAuthToken() { - return { accessToken: "foo", refreshToken: "bar" } - }, - async storeOAuthToken() {}, - async deleteOAuthToken() {} - }, { - async refreshOAuthToken() { - didRefreshAccessToken = true - return { accessToken: "foo", refreshToken: "bar" } - } - }) - await sut.refreshAccessToken("foo") - expect(didRefreshAccessToken).toBeTruthy() -}) - -test("It skips refreshing the access token when the input access token is not equal to the stored access token", async () => { - let didRefreshAccessToken = false - const sut = new LockingAccessTokenRefresher({ - async makeMutex() { - return { - async acquire() {}, - async release() {} - } - } - }, { - async getOAuthToken() { - return { accessToken: "new", refreshToken: "bar" } - }, - async storeOAuthToken() {}, - async deleteOAuthToken() {} - }, { - async refreshOAuthToken() { - didRefreshAccessToken = true - return { accessToken: "foo", refreshToken: "bar" } - } - }) - await sut.refreshAccessToken("outdated") - expect(didRefreshAccessToken).toBeFalsy() -}) - -test("It stores the refreshed tokens", async () => { - let storedToken: OAuthToken | undefined - const sut = new LockingAccessTokenRefresher({ - async makeMutex() { - return { - async acquire() {}, - async release() {} - } - } - }, { - async getOAuthToken() { - return { accessToken: "foo", refreshToken: "bar" } - }, - async storeOAuthToken(token) { - storedToken = token - }, - async deleteOAuthToken() {} - }, { - async refreshOAuthToken() { - return { accessToken: "newAccessToken", refreshToken: "newRefreshToken" } - } - }) - await sut.refreshAccessToken("foo") - expect(storedToken?.accessToken).toEqual("newAccessToken") - expect(storedToken?.refreshToken).toEqual("newRefreshToken") -}) diff --git a/__test__/auth/OAuthTokenRepository.test.ts b/__test__/auth/OAuthTokenRepository.test.ts new file mode 100644 index 00000000..3894338e --- /dev/null +++ b/__test__/auth/OAuthTokenRepository.test.ts @@ -0,0 +1,57 @@ +import OAuthTokenRepository from "../../src/features/auth/domain/oAuthToken/OAuthTokenRepository" + +test("It reads the auth token for the specified user", async () => { + let readUserId: string | undefined + const sut = new OAuthTokenRepository({ + async get(userId) { + readUserId = userId + return JSON.stringify({ + accessToken: "foo", + refreshToken: "bar" + }) + }, + async set() {}, + async delete() {} + }) + await sut.get("1234") + expect(readUserId).toBe("1234") +}) + +test("It stores the auth token for the specified user", async () => { + let storedUserId: string | undefined + let storedJSON: any | undefined + const sut = new OAuthTokenRepository({ + async get() { + return "" + }, + async set(userId, data) { + storedUserId = userId + storedJSON = data + }, + async delete() {} + }) + const authToken = { + accessToken: "foo", + refreshToken: "bar" + } + await sut.set("1234", authToken) + const storedObj = JSON.parse(storedJSON) + expect(storedUserId).toBe("1234") + expect(storedObj.accessToken).toBe(authToken.accessToken) + expect(storedObj.refreshToken).toBe(authToken.refreshToken) +}) + +test("It deletes the auth token for the specified user", async () => { + let deletedUserId: string | undefined + const sut = new OAuthTokenRepository({ + async get() { + return "" + }, + async set() {}, + async delete(userId) { + deletedUserId = userId + } + }) + await sut.delete("1234") + expect(deletedUserId).toBe("1234") +}) diff --git a/__test__/auth/OnlyStaleRefreshingAccessTokenService.test.ts b/__test__/auth/OnlyStaleRefreshingAccessTokenService.test.ts new file mode 100644 index 00000000..016d224d --- /dev/null +++ b/__test__/auth/OnlyStaleRefreshingAccessTokenService.test.ts @@ -0,0 +1,46 @@ +import OnlyStaleRefreshingAccessTokenService from "../../src/features/auth/domain/accessToken/OnlyStaleRefreshingAccessTokenService" + +test("It refreshes the access token when the input access token is equal to the stored access token", async () => { + let didRefreshAccessToken = false + const sut = new OnlyStaleRefreshingAccessTokenService({ + async getAccessToken() { + return "foo" + }, + async refreshAccessToken() { + didRefreshAccessToken = true + return "foo" + } + }) + await sut.refreshAccessToken("foo") + expect(didRefreshAccessToken).toBeTruthy() +}) + +test("It skips refreshing the access token when the input access token is not equal to the stored access token", async () => { + let didRefreshAccessToken = false + const sut = new OnlyStaleRefreshingAccessTokenService({ + async getAccessToken() { + return "foo" + }, + async refreshAccessToken() { + didRefreshAccessToken = true + return "foo" + } + }) + await sut.refreshAccessToken("outdated") + expect(didRefreshAccessToken).toBeFalsy() +}) + +test("It reads access token", async () => { + let didReadAccessToken = false + const sut = new OnlyStaleRefreshingAccessTokenService({ + async getAccessToken() { + didReadAccessToken = true + return "foo" + }, + async refreshAccessToken() { + return "foo" + } + }) + await sut.getAccessToken() + expect(didReadAccessToken).toBeTruthy() +}) \ No newline at end of file diff --git a/__test__/auth/SessionOAuthTokenRepository.test.ts b/__test__/auth/SessionOAuthTokenRepository.test.ts deleted file mode 100644 index 573ac459..00000000 --- a/__test__/auth/SessionOAuthTokenRepository.test.ts +++ /dev/null @@ -1,54 +0,0 @@ -import SessionOAuthTokenRepository from "../../src/features/auth/domain/SessionOAuthTokenRepository" - -test("It reads the auth token", async () => { - let didRead = false - const sut = new SessionOAuthTokenRepository({ - async get() { - didRead = true - return JSON.stringify({ - accessToken: "foo", - refreshToken: "bar" - }) - }, - async set() {}, - async delete() {} - }) - await sut.getOAuthToken() - expect(didRead).toBeTruthy() -}) - -test("It stores the auth token", async () => { - let storedJSON: any | undefined - const sut = new SessionOAuthTokenRepository({ - async get() { - return "" - }, - async set(data) { - storedJSON = data - }, - async delete() {} - }) - const authToken = { - accessToken: "foo", - refreshToken: "bar" - } - await sut.storeOAuthToken(authToken) - const storedObj = JSON.parse(storedJSON) - expect(storedObj.accessToken).toBe(authToken.accessToken) - expect(storedObj.refreshToken).toBe(authToken.refreshToken) -}) - -test("It deletes the auth token", async () => { - let didDelete = false - const sut = new SessionOAuthTokenRepository({ - async get() { - return "" - }, - async set() {}, - async delete() { - didDelete = true - } - }) - await sut.deleteOAuthToken() - expect(didDelete).toBeTruthy() -}) diff --git a/__test__/common/github/AccessTokenRefreshingGitHubClient.test.ts b/__test__/common/github/AccessTokenRefreshingGitHubClient.test.ts index 7913e1c3..59b667b4 100644 --- a/__test__/common/github/AccessTokenRefreshingGitHubClient.test.ts +++ b/__test__/common/github/AccessTokenRefreshingGitHubClient.test.ts @@ -11,8 +11,7 @@ test("It forwards a GraphQL request", async () => { const sut = new AccessTokenRefreshingGitHubClient({ async getAccessToken() { return "foo" - } - }, { + }, async refreshAccessToken() { return "foo" } @@ -45,8 +44,7 @@ test("It forwards a request to get the repository content", async () => { const sut = new AccessTokenRefreshingGitHubClient({ async getAccessToken() { return "foo" - } - }, { + }, async refreshAccessToken() { return "foo" } @@ -81,8 +79,7 @@ test("It forwards a request to get comments to a pull request", async () => { const sut = new AccessTokenRefreshingGitHubClient({ async getAccessToken() { return "foo" - } - }, { + }, async refreshAccessToken() { return "foo" } @@ -117,8 +114,7 @@ test("It forwards a request to add a comment to a pull request", async () => { const sut = new AccessTokenRefreshingGitHubClient({ async getAccessToken() { return "foo" - } - }, { + }, async refreshAccessToken() { return "foo" } @@ -156,8 +152,7 @@ test("It retries with a refreshed access token when receiving HTTP 401", async ( const sut = new AccessTokenRefreshingGitHubClient({ async getAccessToken() { return "foo" - } - }, { + }, async refreshAccessToken() { didRefreshAccessToken = true return "foo" @@ -194,8 +189,7 @@ test("It only retries a request once when receiving HTTP 401", async () => { const sut = new AccessTokenRefreshingGitHubClient({ async getAccessToken() { return "foo" - } - }, { + }, async refreshAccessToken() { return "foo" } @@ -232,8 +226,7 @@ test("It does not refresh an access token when the initial request was successfu const sut = new AccessTokenRefreshingGitHubClient({ async getAccessToken() { return "foo" - } - }, { + }, async refreshAccessToken() { return "foo" } diff --git a/src/common/github/AccessTokenRefreshingGitHubClient.ts b/src/common/github/AccessTokenRefreshingGitHubClient.ts index e5e4a9e7..4a250d5c 100644 --- a/src/common/github/AccessTokenRefreshingGitHubClient.ts +++ b/src/common/github/AccessTokenRefreshingGitHubClient.ts @@ -15,26 +15,20 @@ const HttpErrorSchema = z.object({ status: z.number() }) -interface IGitHubAccessTokenReader { +interface IGitHubAccessTokenService { getAccessToken(): Promise -} - -interface IGitHubAccessTokenRefresher { refreshAccessToken(accessToken: string): Promise } export default class AccessTokenRefreshingGitHubClient implements IGitHubClient { - private readonly accessTokenReader: IGitHubAccessTokenReader - private readonly accessTokenRefresher: IGitHubAccessTokenRefresher + private readonly accessTokenService: IGitHubAccessTokenService private readonly gitHubClient: IGitHubClient constructor( - accessTokenReader: IGitHubAccessTokenReader, - accessTokenRefresher: IGitHubAccessTokenRefresher, + accessTokenService: IGitHubAccessTokenService, gitHubClient: IGitHubClient ) { - this.accessTokenReader = accessTokenReader - this.accessTokenRefresher = accessTokenRefresher + this.accessTokenService = accessTokenService this.gitHubClient = gitHubClient } @@ -71,7 +65,7 @@ export default class AccessTokenRefreshingGitHubClient implements IGitHubClient } private async send(fn: () => Promise): Promise { - const accessToken = await this.accessTokenReader.getAccessToken() + const accessToken = await this.accessTokenService.getAccessToken() try { return await fn() } catch (e) { @@ -79,7 +73,7 @@ export default class AccessTokenRefreshingGitHubClient implements IGitHubClient const error = HttpErrorSchema.parse(e) if (error.status == 401) { // Refresh access token and try the request one last time. - await this.accessTokenRefresher.refreshAccessToken(accessToken) + await this.accessTokenService.refreshAccessToken(accessToken) return await fn() } else { // Not an error we can handle so forward it. diff --git a/src/common/userData/ISessionDataRepository.ts b/src/common/userData/ISessionDataRepository.ts deleted file mode 100644 index 68c4426a..00000000 --- a/src/common/userData/ISessionDataRepository.ts +++ /dev/null @@ -1,5 +0,0 @@ -export default interface ISessionDataRepository { - get(): Promise - set(value: T): Promise - delete(): Promise -} diff --git a/src/common/userData/SessionDataRepository.ts b/src/common/userData/SessionDataRepository.ts deleted file mode 100644 index 490862d6..00000000 --- a/src/common/userData/SessionDataRepository.ts +++ /dev/null @@ -1,28 +0,0 @@ -import ISession from "../session/ISession" -import ISessionDataRepository from "@/common/userData/ISessionDataRepository" -import IUserDataRepository from "@/common/userData/IUserDataRepository" - -export default class SessionDataRepository implements ISessionDataRepository { - private readonly session: ISession - private readonly repository: IUserDataRepository - - constructor(session: ISession, repository: IUserDataRepository) { - this.session = session - this.repository = repository - } - - async get(): Promise { - const userId = await this.session.getUserId() - return await this.repository.get(userId) - } - - async set(value: T): Promise { - const userId = await this.session.getUserId() - return await this.repository.set(userId, value) - } - - async delete(): Promise { - const userId = await this.session.getUserId() - return await this.repository.delete(userId) - } -} diff --git a/src/composition.ts b/src/composition.ts index af57ee2f..077cb29f 100644 --- a/src/composition.ts +++ b/src/composition.ts @@ -1,4 +1,5 @@ import AccessTokenRefreshingGitHubClient from "@/common/github/AccessTokenRefreshingGitHubClient" +import AccessTokenService from "@/features/auth/domain/accessToken/AccessTokenService" import Auth0RefreshTokenReader from "@/features/auth/data/Auth0RefreshTokenReader" import Auth0Session from "@/common/session/Auth0Session" import CachingProjectDataSource from "@/features/projects/domain/CachingProjectDataSource" @@ -8,18 +9,16 @@ import GitHubClient from "@/common/github/GitHubClient" import GitHubOAuthTokenRefresher from "@/features/auth/data/GitHubOAuthTokenRefresher" import GitHubOrganizationSessionValidator from "@/common/session/GitHubOrganizationSessionValidator" import GitHubProjectDataSource from "@/features/projects/data/GitHubProjectDataSource" -import InitialOAuthTokenService from "@/features/auth/domain/InitialOAuthTokenService" +import InitialOAuthTokenService from "@/features/auth/domain/oAuthToken/InitialOAuthTokenService" import KeyValueUserDataRepository from "@/common/userData/KeyValueUserDataRepository" -import LockingAccessTokenRefresher from "@/features/auth/domain/LockingAccessTokenRefresher" +import LockingAccessTokenService from "@/features/auth/domain/accessToken/LockingAccessTokenService" +import OnlyStaleRefreshingAccessTokenService from "@/features/auth/domain/accessToken/OnlyStaleRefreshingAccessTokenService" import ProjectRepository from "@/features/projects/domain/ProjectRepository" import RedisKeyedMutexFactory from "@/common/mutex/RedisKeyedMutexFactory" import RedisKeyValueStore from "@/common/keyValueStore/RedisKeyValueStore" -import SessionAccessTokenReader from "@/features/auth/domain/SessionAccessTokenReader" -import SessionDataRepository from "@/common/userData/SessionDataRepository" import SessionMutexFactory from "@/common/mutex/SessionMutexFactory" -import SessionOAuthTokenRepository from "@/features/auth/domain/SessionOAuthTokenRepository" import SessionValidatingProjectDataSource from "@/features/projects/domain/SessionValidatingProjectDataSource" -import OAuthTokenRepository from "@/features/auth/domain/OAuthTokenRepository" +import OAuthTokenRepository from "@/features/auth/domain/oAuthToken/OAuthTokenRepository" import UserDataCleanUpLogOutHandler from "@/features/auth/domain/logOut/UserDataCleanUpLogOutHandler" const { @@ -36,43 +35,41 @@ const { const gitHubPrivateKey = Buffer.from(GITHUB_PRIVATE_KEY_BASE_64, "base64").toString("utf-8") -const session = new Auth0Session() +export const session = new Auth0Session() -const oAuthTokenRepository = new KeyValueUserDataRepository( - new RedisKeyValueStore(REDIS_URL), - "authToken" +export const oAuthTokenRepository = new OAuthTokenRepository( + new KeyValueUserDataRepository( + new RedisKeyValueStore(REDIS_URL), + "authToken" + ) ) -export const sessionOAuthTokenRepository = new SessionOAuthTokenRepository( - new SessionDataRepository(session, oAuthTokenRepository) +const accessTokenService = new LockingAccessTokenService( + new SessionMutexFactory( + new RedisKeyedMutexFactory(REDIS_URL), + session, + "mutexAccessToken" + ), + new OnlyStaleRefreshingAccessTokenService( + new AccessTokenService({ + userIdReader: session, + repository: oAuthTokenRepository, + refresher: new GitHubOAuthTokenRefresher({ + clientId: GITHUB_CLIENT_ID, + clientSecret: GITHUB_CLIENT_SECRET + }) + }) + ) ) -const gitHubOAuthTokenRefresher = new GitHubOAuthTokenRefresher({ - clientId: GITHUB_CLIENT_ID, - clientSecret: GITHUB_CLIENT_SECRET -}) - export const gitHubClient = new AccessTokenRefreshingGitHubClient( - new SessionAccessTokenReader( - sessionOAuthTokenRepository - ), - new LockingAccessTokenRefresher( - new SessionMutexFactory( - new RedisKeyedMutexFactory(REDIS_URL), - session, - "mutexAccessToken" - ), - sessionOAuthTokenRepository, - gitHubOAuthTokenRefresher - ), + accessTokenService, new GitHubClient({ appId: GITHUB_APP_ID, clientId: GITHUB_CLIENT_ID, clientSecret: GITHUB_CLIENT_SECRET, privateKey: gitHubPrivateKey, - accessTokenReader: new SessionAccessTokenReader( - sessionOAuthTokenRepository - ) + accessTokenReader: accessTokenService }) ) @@ -109,8 +106,11 @@ export const initialOAuthTokenService = new InitialOAuthTokenService({ clientSecret: AUTH0_MANAGEMENT_CLIENT_SECRET, connection: "github" }), - oAuthTokenRefresher: gitHubOAuthTokenRefresher, - oAuthTokenRepository: new OAuthTokenRepository(oAuthTokenRepository) + oAuthTokenRefresher: new GitHubOAuthTokenRefresher({ + clientId: GITHUB_CLIENT_ID, + clientSecret: GITHUB_CLIENT_SECRET + }), + oAuthTokenRepository: oAuthTokenRepository }) export const logOutHandler = new ErrorIgnoringLogOutHandler( diff --git a/src/features/auth/data/Auth0RefreshTokenReader.ts b/src/features/auth/data/Auth0RefreshTokenReader.ts index 79fd6bfb..642aebc2 100644 --- a/src/features/auth/data/Auth0RefreshTokenReader.ts +++ b/src/features/auth/data/Auth0RefreshTokenReader.ts @@ -1,15 +1,14 @@ import { ManagementClient } from "auth0" import { UnauthorizedError } from "@/common/errors" -import IRefreshTokenReader from "../domain/IRefreshTokenReader" interface Auth0RefreshTokenReaderConfig { - domain: string - clientId: string - clientSecret: string - connection: string + readonly domain: string + readonly clientId: string + readonly clientSecret: string + readonly connection: string } -export default class Auth0RefreshTokenReader implements IRefreshTokenReader { +export default class Auth0RefreshTokenReader { private readonly managementClient: ManagementClient private readonly connection: string diff --git a/src/features/auth/data/GitHubOAuthTokenRefresher.ts b/src/features/auth/data/GitHubOAuthTokenRefresher.ts index d5e88607..255e8d13 100644 --- a/src/features/auth/data/GitHubOAuthTokenRefresher.ts +++ b/src/features/auth/data/GitHubOAuthTokenRefresher.ts @@ -1,6 +1,6 @@ import { UnauthorizedError } from "@/common/errors" -import OAuthToken from "../domain/OAuthToken" -import IOAuthTokenRefresher from "../domain/IOAuthTokenRefresher" +import OAuthToken from "../domain/oAuthToken/OAuthToken" +import IOAuthTokenRefresher from "../domain/oAuthToken/IOAuthTokenRefresher" export interface GitHubOAuthTokenRefresherConfig { readonly clientId: string diff --git a/src/features/auth/domain/IAccessTokenRefresher.ts b/src/features/auth/domain/IAccessTokenRefresher.ts deleted file mode 100644 index 3e1da196..00000000 --- a/src/features/auth/domain/IAccessTokenRefresher.ts +++ /dev/null @@ -1,3 +0,0 @@ -export default interface IAccessTokenRefresher { - refreshAccessToken(accessToken: string): Promise -} diff --git a/src/features/auth/domain/IOAuthTokenRepository.ts b/src/features/auth/domain/IOAuthTokenRepository.ts deleted file mode 100644 index ddee24f3..00000000 --- a/src/features/auth/domain/IOAuthTokenRepository.ts +++ /dev/null @@ -1,7 +0,0 @@ -import OAuthToken from "./OAuthToken" - -export default interface IOAuthTokenRepository { - getOAuthToken(userId: string): Promise - storeOAuthToken(userId: string, token: OAuthToken): Promise - deleteOAuthToken(userId: string): Promise -} diff --git a/src/features/auth/domain/IRefreshTokenReader.ts b/src/features/auth/domain/IRefreshTokenReader.ts deleted file mode 100644 index 4c10f9d6..00000000 --- a/src/features/auth/domain/IRefreshTokenReader.ts +++ /dev/null @@ -1,3 +0,0 @@ -export default interface IRefreshTokenReader { - getRefreshToken(userId: string): Promise -} diff --git a/src/features/auth/domain/ISessionOAuthTokenRepository.ts b/src/features/auth/domain/ISessionOAuthTokenRepository.ts deleted file mode 100644 index 8b195c60..00000000 --- a/src/features/auth/domain/ISessionOAuthTokenRepository.ts +++ /dev/null @@ -1,7 +0,0 @@ -import OAuthToken from "./OAuthToken" - -export default interface ISessionOAuthTokenRepository { - getOAuthToken(): Promise - storeOAuthToken(token: OAuthToken): Promise - deleteOAuthToken(): Promise -} diff --git a/src/features/auth/domain/LockingAccessTokenRefresher.ts b/src/features/auth/domain/LockingAccessTokenRefresher.ts deleted file mode 100644 index 309918f2..00000000 --- a/src/features/auth/domain/LockingAccessTokenRefresher.ts +++ /dev/null @@ -1,35 +0,0 @@ -import IMutexFactory from "@/common/mutex/IMutexFactory" -import IAccessTokenRefresher from "./IAccessTokenRefresher" -import IOAuthTokenRefresher from "./IOAuthTokenRefresher" -import ISessionOAuthTokenRepository from "./ISessionOAuthTokenRepository" -import withMutex from "../../../common/mutex/withMutex" - -export default class LockingAccessTokenRefresher implements IAccessTokenRefresher { - private readonly mutexFactory: IMutexFactory - private readonly tokenRepository: ISessionOAuthTokenRepository - private readonly tokenRefresher: IOAuthTokenRefresher - - constructor( - mutexFactory: IMutexFactory, - tokenRepository: ISessionOAuthTokenRepository, - tokenRefresher: IOAuthTokenRefresher - ) { - this.mutexFactory = mutexFactory - this.tokenRepository = tokenRepository - this.tokenRefresher = tokenRefresher - } - - async refreshAccessToken(accessToken: string): Promise { - const mutex = await this.mutexFactory.makeMutex() - return await withMutex(mutex, async () => { - const authToken = await this.tokenRepository.getOAuthToken() - if (accessToken != authToken.accessToken) { - // Given access token is outdated so we use our current access token. - return authToken.accessToken - } - const refreshResult = await this.tokenRefresher.refreshOAuthToken(authToken.refreshToken) - await this.tokenRepository.storeOAuthToken(refreshResult) - return refreshResult.accessToken - }) - } -} diff --git a/src/features/auth/domain/SessionAccessTokenReader.ts b/src/features/auth/domain/SessionAccessTokenReader.ts deleted file mode 100644 index e7d05dcc..00000000 --- a/src/features/auth/domain/SessionAccessTokenReader.ts +++ /dev/null @@ -1,14 +0,0 @@ -import ISessionOAuthTokenRepository from "./ISessionOAuthTokenRepository" - -export default class AccessTokenReader { - private readonly oAuthTokenRepository: ISessionOAuthTokenRepository - - constructor(oAuthTokenRepository: ISessionOAuthTokenRepository) { - this.oAuthTokenRepository = oAuthTokenRepository - } - - async getAccessToken(): Promise { - const authToken = await this.oAuthTokenRepository.getOAuthToken() - return authToken.accessToken - } -} diff --git a/src/features/auth/domain/SessionOAuthTokenRepository.ts b/src/features/auth/domain/SessionOAuthTokenRepository.ts deleted file mode 100644 index 2f5d0905..00000000 --- a/src/features/auth/domain/SessionOAuthTokenRepository.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { UnauthorizedError } from "../../../common/errors" -import ZodJSONCoder from "../../../common/utils/ZodJSONCoder" -import ISessionDataRepository from "@/common/userData/ISessionDataRepository" -import ISessionOAuthTokenRepository from "./SessionOAuthTokenRepository" -import OAuthToken, { OAuthTokenSchema } from "./OAuthToken" - -export default class SessionOAuthTokenRepository implements ISessionOAuthTokenRepository { - private readonly repository: ISessionDataRepository - - constructor(repository: ISessionDataRepository) { - this.repository = repository - } - - async getOAuthToken(): Promise { - const string = await this.repository.get() - if (!string) { - throw new UnauthorizedError(`No OAuthToken stored for user.`) - } - return ZodJSONCoder.decode(OAuthTokenSchema, string) - } - - async storeOAuthToken(token: OAuthToken): Promise { - const string = ZodJSONCoder.encode(OAuthTokenSchema, token) - await this.repository.set(string) - } - - async deleteOAuthToken(): Promise { - await this.repository.delete() - } -} diff --git a/src/features/auth/domain/accessToken/AccessTokenService.ts b/src/features/auth/domain/accessToken/AccessTokenService.ts new file mode 100644 index 00000000..729f40c3 --- /dev/null +++ b/src/features/auth/domain/accessToken/AccessTokenService.ts @@ -0,0 +1,39 @@ +import IAccessTokenService from "./IAccessTokenService" +import IOAuthTokenRepository from "../oAuthToken/IOAuthTokenRepository" +import IOAuthTokenRefresher from "../oAuthToken/IOAuthTokenRefresher" + +export interface IUserIDReader { + getUserId(): Promise +} + +type AccessTokenServiceConfig = { + readonly userIdReader: IUserIDReader + readonly repository: IOAuthTokenRepository + readonly refresher: IOAuthTokenRefresher +} + +export default class AccessTokenService implements IAccessTokenService { + private readonly userIdReader: IUserIDReader + private readonly repository: IOAuthTokenRepository + private readonly refresher: IOAuthTokenRefresher + + constructor(config: AccessTokenServiceConfig) { + this.userIdReader = config.userIdReader + this.repository = config.repository + this.refresher = config.refresher + } + + async getAccessToken(): Promise { + const userId = await this.userIdReader.getUserId() + const oAuthToken = await this.repository.get(userId) + return oAuthToken.accessToken + } + + async refreshAccessToken(_accessToken: string): Promise { + const userId = await this.userIdReader.getUserId() + const oAuthToken = await this.repository.get(userId) + const newOAuthToken = await this.refresher.refreshOAuthToken(oAuthToken.refreshToken) + await this.repository.set(userId, newOAuthToken) + return newOAuthToken.accessToken + } +} diff --git a/src/features/auth/domain/accessToken/IAccessTokenService.ts b/src/features/auth/domain/accessToken/IAccessTokenService.ts new file mode 100644 index 00000000..579211c1 --- /dev/null +++ b/src/features/auth/domain/accessToken/IAccessTokenService.ts @@ -0,0 +1,4 @@ +export default interface IAccessTokenService { + getAccessToken(): Promise + refreshAccessToken(accessToken: string): Promise +} diff --git a/src/features/auth/domain/accessToken/LockingAccessTokenService.ts b/src/features/auth/domain/accessToken/LockingAccessTokenService.ts new file mode 100644 index 00000000..cda75ecf --- /dev/null +++ b/src/features/auth/domain/accessToken/LockingAccessTokenService.ts @@ -0,0 +1,27 @@ +import IMutexFactory from "@/common/mutex/IMutexFactory" +import IAccessTokenService from "./IAccessTokenService" +import withMutex from "../../../../common/mutex/withMutex" + +export default class LockingAccessTokenService implements IAccessTokenService { + private readonly mutexFactory: IMutexFactory + private readonly accessTokenService: IAccessTokenService + + constructor( + mutexFactory: IMutexFactory, + accessTokenService: IAccessTokenService + ) { + this.mutexFactory = mutexFactory + this.accessTokenService = accessTokenService + } + + async getAccessToken(): Promise { + return await this.accessTokenService.getAccessToken() + } + + async refreshAccessToken(accessToken: string): Promise { + const mutex = await this.mutexFactory.makeMutex() + return await withMutex(mutex, async () => { + return await this.accessTokenService.refreshAccessToken(accessToken) + }) + } +} diff --git a/src/features/auth/domain/accessToken/OnlyStaleRefreshingAccessTokenService.ts b/src/features/auth/domain/accessToken/OnlyStaleRefreshingAccessTokenService.ts new file mode 100644 index 00000000..9b3aabce --- /dev/null +++ b/src/features/auth/domain/accessToken/OnlyStaleRefreshingAccessTokenService.ts @@ -0,0 +1,23 @@ +import IAccessTokenService from "./IAccessTokenService" + +export default class OnlyStaleRefreshingAccessTokenService implements IAccessTokenService { + private readonly service: IAccessTokenService + + constructor(service: IAccessTokenService) { + this.service = service + } + + async getAccessToken(): Promise { + return await this.service.getAccessToken() + } + + async refreshAccessToken(accessToken: string): Promise { + const storedAccessToken = await this.getAccessToken() + if (accessToken != storedAccessToken) { + // Given access token is outdated so we use our stored access token. + return storedAccessToken + } + // Given access token is stale so we refresh it. + return await this.service.refreshAccessToken(accessToken) + } +} \ No newline at end of file diff --git a/src/features/auth/domain/IOAuthTokenRefresher.ts b/src/features/auth/domain/oAuthToken/IOAuthTokenRefresher.ts similarity index 100% rename from src/features/auth/domain/IOAuthTokenRefresher.ts rename to src/features/auth/domain/oAuthToken/IOAuthTokenRefresher.ts diff --git a/src/features/auth/domain/oAuthToken/IOAuthTokenRepository.ts b/src/features/auth/domain/oAuthToken/IOAuthTokenRepository.ts new file mode 100644 index 00000000..745a68c4 --- /dev/null +++ b/src/features/auth/domain/oAuthToken/IOAuthTokenRepository.ts @@ -0,0 +1,7 @@ +import OAuthToken from "./OAuthToken" + +export default interface IOAuthTokenRepository { + get(userId: string): Promise + set(userId: string, token: OAuthToken): Promise + delete(userId: string): Promise +} diff --git a/src/features/auth/domain/InitialOAuthTokenService.ts b/src/features/auth/domain/oAuthToken/InitialOAuthTokenService.ts similarity index 84% rename from src/features/auth/domain/InitialOAuthTokenService.ts rename to src/features/auth/domain/oAuthToken/InitialOAuthTokenService.ts index c9922e66..c75bd8e2 100644 --- a/src/features/auth/domain/InitialOAuthTokenService.ts +++ b/src/features/auth/domain/oAuthToken/InitialOAuthTokenService.ts @@ -1,7 +1,10 @@ -import IRefreshTokenReader from "./IRefreshTokenReader" import IOAuthTokenRefresher from "./IOAuthTokenRefresher" import IOAuthTokenRepository from "./IOAuthTokenRepository" +interface IRefreshTokenReader { + getRefreshToken(userId: string): Promise +} + type InitialOAuthTokenServiceConfig = { readonly refreshTokenReader: IRefreshTokenReader readonly oAuthTokenRefresher: IOAuthTokenRefresher @@ -18,6 +21,6 @@ export default class InitialOAuthTokenService { async fetchInitialAuthTokenForUser(userId: string): Promise { const refreshToken = await this.config.refreshTokenReader.getRefreshToken(userId) const authToken = await this.config.oAuthTokenRefresher.refreshOAuthToken(refreshToken) - this.config.oAuthTokenRepository.storeOAuthToken(userId, authToken) + this.config.oAuthTokenRepository.set(userId, authToken) } -} +} \ No newline at end of file diff --git a/src/features/auth/domain/OAuthToken.ts b/src/features/auth/domain/oAuthToken/OAuthToken.ts similarity index 100% rename from src/features/auth/domain/OAuthToken.ts rename to src/features/auth/domain/oAuthToken/OAuthToken.ts diff --git a/src/features/auth/domain/OAuthTokenRepository.ts b/src/features/auth/domain/oAuthToken/OAuthTokenRepository.ts similarity index 73% rename from src/features/auth/domain/OAuthTokenRepository.ts rename to src/features/auth/domain/oAuthToken/OAuthTokenRepository.ts index fd416386..a28bc29a 100644 --- a/src/features/auth/domain/OAuthTokenRepository.ts +++ b/src/features/auth/domain/oAuthToken/OAuthTokenRepository.ts @@ -1,6 +1,6 @@ -import ZodJSONCoder from "@/common/utils/ZodJSONCoder" +import ZodJSONCoder from "../../../../common/utils/ZodJSONCoder" import IUserDataRepository from "@/common/userData/IUserDataRepository" -import { UnauthorizedError } from "@/common/errors" +import { UnauthorizedError } from "../../../../common/errors" import IOAuthTokenRepository from "./IOAuthTokenRepository" import OAuthToken, { OAuthTokenSchema } from "./OAuthToken" @@ -11,7 +11,7 @@ export default class OAuthTokenRepository implements IOAuthTokenRepository { this.repository = repository } - async getOAuthToken(userId: string): Promise { + async get(userId: string): Promise { const string = await this.repository.get(userId) if (!string) { throw new UnauthorizedError(`No OAuthToken stored for user with ID ${userId}.`) @@ -19,12 +19,12 @@ export default class OAuthTokenRepository implements IOAuthTokenRepository { return ZodJSONCoder.decode(OAuthTokenSchema, string) } - async storeOAuthToken(userId: string, token: OAuthToken): Promise { + async set(userId: string, token: OAuthToken): Promise { const string = ZodJSONCoder.encode(OAuthTokenSchema, token) await this.repository.set(userId, string) } - async deleteOAuthToken(userId: string): Promise { + async delete(userId: string): Promise { await this.repository.delete(userId) } } diff --git a/src/features/auth/view/SessionOAuthTokenBarrier.tsx b/src/features/auth/view/SessionOAuthTokenBarrier.tsx index 619bb50f..158ec381 100644 --- a/src/features/auth/view/SessionOAuthTokenBarrier.tsx +++ b/src/features/auth/view/SessionOAuthTokenBarrier.tsx @@ -1,6 +1,6 @@ import { ReactNode } from "react" import { redirect } from "next/navigation" -import { sessionOAuthTokenRepository } from "@/composition" +import { session, oAuthTokenRepository } from "@/composition" export default async function SessionOAuthTokenBarrier({ children @@ -8,7 +8,8 @@ export default async function SessionOAuthTokenBarrier({ children: ReactNode }) { try { - await sessionOAuthTokenRepository.getOAuthToken() + const userId = await session.getUserId() + await oAuthTokenRepository.get(userId) return <>{children} } catch { redirect("/api/auth/logout")