From b7f54feb7535575f880d5095da0f9f7d9dbf3e48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Mon, 6 Nov 2023 12:23:30 +0100 Subject: [PATCH 01/10] Renames InitialOAuthTokenService to OAuthTokenTransferer --- ...ervice.test.ts => OAuthTokenTransferer.test.ts} | 14 +++++++------- src/app/api/auth/[auth0]/route.ts | 10 +++++++--- src/composition.ts | 4 ++-- ...AuthTokenService.ts => OAuthTokenTransferer.ts} | 10 +++++----- 4 files changed, 21 insertions(+), 17 deletions(-) rename __test__/auth/{InitialOAuthTokenService.test.ts => OAuthTokenTransferer.test.ts} (85%) rename src/features/auth/domain/{InitialOAuthTokenService.ts => OAuthTokenTransferer.ts} (69%) diff --git a/__test__/auth/InitialOAuthTokenService.test.ts b/__test__/auth/OAuthTokenTransferer.test.ts similarity index 85% rename from __test__/auth/InitialOAuthTokenService.test.ts rename to __test__/auth/OAuthTokenTransferer.test.ts index e1e9263e..2c8ec21b 100644 --- a/__test__/auth/InitialOAuthTokenService.test.ts +++ b/__test__/auth/OAuthTokenTransferer.test.ts @@ -1,9 +1,9 @@ -import InitialOAuthTokenService from "../../src/features/auth/domain/InitialOAuthTokenService" +import OAuthTokenTransferer from "../../src/features/auth/domain/OAuthTokenTransferer" import OAuthToken from "../../src/features/auth/domain/OAuthToken" test("It fetches refresh token for specified user", async () => { let fetchedUserId: string | undefined - const sut = new InitialOAuthTokenService({ + const sut = new OAuthTokenTransferer({ refreshTokenReader: { async getRefreshToken(userId) { fetchedUserId = userId @@ -23,13 +23,13 @@ test("It fetches refresh token for specified user", async () => { async deleteOAuthToken() {} } }) - await sut.fetchInitialAuthTokenForUser("123") + await sut.transferAuthTokenForUser("123") expect(fetchedUserId).toBe("123") }) test("It refreshes the fetched refresh token", async () => { let refreshedRefreshToken: string | undefined - const sut = new InitialOAuthTokenService({ + const sut = new OAuthTokenTransferer({ refreshTokenReader: { async getRefreshToken() { return "helloworld" @@ -49,14 +49,14 @@ test("It refreshes the fetched refresh token", async () => { async deleteOAuthToken() {} } }) - await sut.fetchInitialAuthTokenForUser("123") + await sut.transferAuthTokenForUser("123") expect(refreshedRefreshToken).toBe("helloworld") }) test("It stores the refreshed auth token for the correct user ID", async () => { let storedAuthToken: OAuthToken | undefined let storedUserId: string | undefined - const sut = new InitialOAuthTokenService({ + const sut = new OAuthTokenTransferer({ refreshTokenReader: { async getRefreshToken() { return "helloworld" @@ -78,7 +78,7 @@ test("It stores the refreshed auth token for the correct user ID", async () => { async deleteOAuthToken() {} } }) - await sut.fetchInitialAuthTokenForUser("123") + await sut.transferAuthTokenForUser("123") expect(storedAuthToken?.accessToken).toBe("foo") expect(storedAuthToken?.refreshToken).toBe("bar") expect(storedUserId).toBe("123") diff --git a/src/app/api/auth/[auth0]/route.ts b/src/app/api/auth/[auth0]/route.ts index 42f7aca6..84d54ccb 100644 --- a/src/app/api/auth/[auth0]/route.ts +++ b/src/app/api/auth/[auth0]/route.ts @@ -9,7 +9,7 @@ import { AppRouterOnError } from "@auth0/nextjs-auth0" import { - initialOAuthTokenService, + oAuthTokenTransferer, sessionOAuthTokenRepository, sessionProjectRepository, logoutHandler @@ -18,16 +18,20 @@ import { const { SHAPE_DOCS_BASE_URL } = process.env const afterCallback: AfterCallbackAppRoute = async (_req, session) => { - await initialOAuthTokenService.fetchInitialAuthTokenForUser(session.user.sub) + console.log("After callback") + console.log(session) + await oAuthTokenTransferer.transferAuthTokenForUser(session.user.sub) return session } -const onError: AppRouterOnError = async () => { +const onError: AppRouterOnError = async (error: any) => { + console.log(error) const url = new URL(SHAPE_DOCS_BASE_URL + "/api/auth/forceLogout") return NextResponse.redirect(url) } const onLogout: NextAppRouterHandler = async (req: NextRequest, ctx: AppRouteHandlerFnContext) => { + console.log("Log out") await Promise.all([ sessionOAuthTokenRepository.deleteOAuthToken().catch(() => null), sessionProjectRepository.deleteProjects().catch(() => null) diff --git a/src/composition.ts b/src/composition.ts index dcfac50c..f8388bfd 100644 --- a/src/composition.ts +++ b/src/composition.ts @@ -6,9 +6,9 @@ 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 KeyValueUserDataRepository from "@/common/userData/KeyValueUserDataRepository" import LockingAccessTokenRefresher from "@/features/auth/domain/LockingAccessTokenRefresher" +import OAuthTokenTransferer from "@/features/auth/domain/OAuthTokenTransferer" import RedisKeyedMutexFactory from "@/common/mutex/RedisKeyedMutexFactory" import RedisKeyValueStore from "@/common/keyValueStore/RedisKeyValueStore" import SessionAccessTokenReader from "@/features/auth/domain/SessionAccessTokenReader" @@ -98,7 +98,7 @@ export const projectDataSource = new CachingProjectDataSource( sessionProjectRepository ) -export const initialOAuthTokenService = new InitialOAuthTokenService({ +export const oAuthTokenTransferer = new OAuthTokenTransferer({ refreshTokenReader: new Auth0RefreshTokenReader({ domain: AUTH0_MANAGEMENT_DOMAIN, clientId: AUTH0_MANAGEMENT_CLIENT_ID, diff --git a/src/features/auth/domain/InitialOAuthTokenService.ts b/src/features/auth/domain/OAuthTokenTransferer.ts similarity index 69% rename from src/features/auth/domain/InitialOAuthTokenService.ts rename to src/features/auth/domain/OAuthTokenTransferer.ts index c9922e66..27189b39 100644 --- a/src/features/auth/domain/InitialOAuthTokenService.ts +++ b/src/features/auth/domain/OAuthTokenTransferer.ts @@ -2,20 +2,20 @@ import IRefreshTokenReader from "./IRefreshTokenReader" import IOAuthTokenRefresher from "./IOAuthTokenRefresher" import IOAuthTokenRepository from "./IOAuthTokenRepository" -type InitialOAuthTokenServiceConfig = { +type OAuthTokenTransfererConfig = { readonly refreshTokenReader: IRefreshTokenReader readonly oAuthTokenRefresher: IOAuthTokenRefresher readonly oAuthTokenRepository: IOAuthTokenRepository } -export default class InitialOAuthTokenService { - private readonly config: InitialOAuthTokenServiceConfig +export default class OAuthTokenTransferer { + private readonly config: OAuthTokenTransfererConfig - constructor(config: InitialOAuthTokenServiceConfig) { + constructor(config: OAuthTokenTransfererConfig) { this.config = config } - async fetchInitialAuthTokenForUser(userId: string): Promise { + async transferAuthTokenForUser(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) From 4c104770d54458cbe9c1d548e44e591b42e42568 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Mon, 6 Nov 2023 12:45:56 +0100 Subject: [PATCH 02/10] Removes debug logging --- src/app/api/auth/[auth0]/route.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/app/api/auth/[auth0]/route.ts b/src/app/api/auth/[auth0]/route.ts index 84d54ccb..7dc805fa 100644 --- a/src/app/api/auth/[auth0]/route.ts +++ b/src/app/api/auth/[auth0]/route.ts @@ -18,20 +18,16 @@ import { const { SHAPE_DOCS_BASE_URL } = process.env const afterCallback: AfterCallbackAppRoute = async (_req, session) => { - console.log("After callback") - console.log(session) await oAuthTokenTransferer.transferAuthTokenForUser(session.user.sub) return session } const onError: AppRouterOnError = async (error: any) => { - console.log(error) const url = new URL(SHAPE_DOCS_BASE_URL + "/api/auth/forceLogout") return NextResponse.redirect(url) } const onLogout: NextAppRouterHandler = async (req: NextRequest, ctx: AppRouteHandlerFnContext) => { - console.log("Log out") await Promise.all([ sessionOAuthTokenRepository.deleteOAuthToken().catch(() => null), sessionProjectRepository.deleteProjects().catch(() => null) From 1abd29defcefb2932cd6e7b3e9e0a13824714e34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Mon, 6 Nov 2023 13:06:30 +0100 Subject: [PATCH 03/10] Removes unused parameter --- src/app/api/auth/[auth0]/route.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/api/auth/[auth0]/route.ts b/src/app/api/auth/[auth0]/route.ts index 7dc805fa..1beb7e25 100644 --- a/src/app/api/auth/[auth0]/route.ts +++ b/src/app/api/auth/[auth0]/route.ts @@ -22,7 +22,7 @@ const afterCallback: AfterCallbackAppRoute = async (_req, session) => { return session } -const onError: AppRouterOnError = async (error: any) => { +const onError: AppRouterOnError = async () => { const url = new URL(SHAPE_DOCS_BASE_URL + "/api/auth/forceLogout") return NextResponse.redirect(url) } From 5b1693669a69db8f940b0538df5c618f88fb30ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Tue, 7 Nov 2023 09:02:38 +0100 Subject: [PATCH 04/10] Simplifies project interface --- .../projects/CachingProjectDataSource.test.ts | 6 +-- src/app/[...slug]/page.tsx | 4 +- src/app/page.tsx | 6 +-- src/composition.ts | 2 +- .../domain/CachingProjectDataSource.ts | 10 ++--- .../projects/domain/IProjectRepository.ts | 5 ++- .../domain/ISessionProjectRepository.ts | 7 ---- .../projects/domain/ProjectRepository.ts | 37 +++++++++++++++++++ .../domain/SessionProjectRepository.ts | 29 --------------- src/features/projects/view/ProjectsPage.tsx | 8 ++-- 10 files changed, 57 insertions(+), 57 deletions(-) delete mode 100644 src/features/projects/domain/ISessionProjectRepository.ts create mode 100644 src/features/projects/domain/ProjectRepository.ts delete mode 100644 src/features/projects/domain/SessionProjectRepository.ts diff --git a/__test__/projects/CachingProjectDataSource.test.ts b/__test__/projects/CachingProjectDataSource.test.ts index f92efe23..1a0f2f76 100644 --- a/__test__/projects/CachingProjectDataSource.test.ts +++ b/__test__/projects/CachingProjectDataSource.test.ts @@ -32,13 +32,13 @@ test("It caches projects read from the data source", async () => { return projects } }, { - async getProjects() { + async get() { return [] }, - async storeProjects(projects) { + async set(projects) { cachedProjects = projects }, - async deleteProjects() {} + async delete() {} }) await sut.getProjects() expect(cachedProjects).toEqual(projects) diff --git a/src/app/[...slug]/page.tsx b/src/app/[...slug]/page.tsx index ba172ad2..2193779a 100644 --- a/src/app/[...slug]/page.tsx +++ b/src/app/[...slug]/page.tsx @@ -1,7 +1,7 @@ import { getProjectId, getSpecificationId, getVersionId } from "@/common/utils/url" import SessionOAuthTokenBarrier from "@/features/auth/view/SessionOAuthTokenBarrier" import ProjectsPage from "@/features/projects/view/ProjectsPage" -import { sessionProjectRepository } from "@/composition" +import { projectRepository } from "@/composition" type PageParams = { slug: string | string[] } @@ -10,7 +10,7 @@ export default async function Page({ params }: { params: PageParams }) { return ( - + ) } diff --git a/src/composition.ts b/src/composition.ts index f8388bfd..fc248457 100644 --- a/src/composition.ts +++ b/src/composition.ts @@ -9,13 +9,13 @@ import GitHubProjectDataSource from "@/features/projects/data/GitHubProjectDataS import KeyValueUserDataRepository from "@/common/userData/KeyValueUserDataRepository" import LockingAccessTokenRefresher from "@/features/auth/domain/LockingAccessTokenRefresher" import OAuthTokenTransferer from "@/features/auth/domain/OAuthTokenTransferer" +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 SessionProjectRepository from "@/features/projects/domain/SessionProjectRepository" import SessionValidatingProjectDataSource from "@/features/projects/domain/SessionValidatingProjectDataSource" import OAuthTokenRepository from "@/features/auth/domain/OAuthTokenRepository" import authLogoutHandler from "@/common/authHandler/logout" diff --git a/src/features/projects/domain/CachingProjectDataSource.ts b/src/features/projects/domain/CachingProjectDataSource.ts index f076ae7d..a95dae13 100644 --- a/src/features/projects/domain/CachingProjectDataSource.ts +++ b/src/features/projects/domain/CachingProjectDataSource.ts @@ -1,22 +1,22 @@ import Project from "./Project" import IProjectDataSource from "./IProjectDataSource" -import ISessionProjectRepository from "./ISessionProjectRepository" +import IProjectRepository from "./IProjectRepository" export default class CachingProjectDataSource implements IProjectDataSource { private dataSource: IProjectDataSource - private sessionProjectRepository: ISessionProjectRepository + private repository: IProjectRepository constructor( dataSource: IProjectDataSource, - sessionProjectRepository: ISessionProjectRepository + repository: IProjectRepository ) { this.dataSource = dataSource - this.sessionProjectRepository = sessionProjectRepository + this.repository = repository } async getProjects(): Promise { const projects = await this.dataSource.getProjects() - await this.sessionProjectRepository.storeProjects(projects) + await this.repository.set(projects) return projects } } \ No newline at end of file diff --git a/src/features/projects/domain/IProjectRepository.ts b/src/features/projects/domain/IProjectRepository.ts index a123d92e..52a4af69 100644 --- a/src/features/projects/domain/IProjectRepository.ts +++ b/src/features/projects/domain/IProjectRepository.ts @@ -1,6 +1,7 @@ import Project from "./Project" export default interface IProjectRepository { - getProjects(): Promise - storeProjects(projects: Project[]): Promise + get(): Promise + set(projects: Project[]): Promise + delete(): Promise } diff --git a/src/features/projects/domain/ISessionProjectRepository.ts b/src/features/projects/domain/ISessionProjectRepository.ts deleted file mode 100644 index 055b78e7..00000000 --- a/src/features/projects/domain/ISessionProjectRepository.ts +++ /dev/null @@ -1,7 +0,0 @@ -import Project from "./Project" - -export default interface ISessionProjectRepository { - getProjects(): Promise - storeProjects(projects: Project[]): Promise - deleteProjects(): Promise -} diff --git a/src/features/projects/domain/ProjectRepository.ts b/src/features/projects/domain/ProjectRepository.ts new file mode 100644 index 00000000..356c4a53 --- /dev/null +++ b/src/features/projects/domain/ProjectRepository.ts @@ -0,0 +1,37 @@ +import ZodJSONCoder from "@/common/utils/ZodJSONCoder" +import ISession from "@/common/session/ISession" +import IUserDataRepository from "@/common/userData/IUserDataRepository" +import IProjectRepository from "./IProjectRepository" +import Project, { ProjectSchema } from "./Project" + +type Repository = IUserDataRepository + +export default class ProjectRepository implements IProjectRepository { + private readonly session: ISession + private readonly repository: Repository + + constructor(session: ISession, repository: Repository) { + this.session = session + this.repository = repository + } + + async get(): Promise { + const userId = await this.session.getUserId() + const string = await this.repository.get(userId) + if (!string) { + return undefined + } + return ZodJSONCoder.decode(ProjectSchema.array(), string) + } + + async set(projects: Project[]): Promise { + const userId = await this.session.getUserId() + const string = ZodJSONCoder.encode(ProjectSchema.array(), projects) + await this.repository.set(userId, string) + } + + async delete(): Promise { + const userId = await this.session.getUserId() + await this.repository.delete(userId) + } +} diff --git a/src/features/projects/domain/SessionProjectRepository.ts b/src/features/projects/domain/SessionProjectRepository.ts deleted file mode 100644 index 5387a3c5..00000000 --- a/src/features/projects/domain/SessionProjectRepository.ts +++ /dev/null @@ -1,29 +0,0 @@ -import ZodJSONCoder from "@/common/utils/ZodJSONCoder" -import ISessionDataRepository from "@/common/userData/ISessionDataRepository" -import ISessionProjectRepository from "./ISessionProjectRepository" -import Project, { ProjectSchema } from "./Project" - -export default class SessionProjectRepository implements ISessionProjectRepository { - private readonly repository: ISessionDataRepository - - constructor(repository: ISessionDataRepository) { - this.repository = repository - } - - async getProjects(): Promise { - const string = await this.repository.get() - if (!string) { - return undefined - } - return ZodJSONCoder.decode(ProjectSchema.array(), string) - } - - async storeProjects(projects: Project[]): Promise { - const string = ZodJSONCoder.encode(ProjectSchema.array(), projects) - await this.repository.set(string) - } - - async deleteProjects(): Promise { - await this.repository.delete() - } -} diff --git a/src/features/projects/view/ProjectsPage.tsx b/src/features/projects/view/ProjectsPage.tsx index 37c41cfe..d4369eac 100644 --- a/src/features/projects/view/ProjectsPage.tsx +++ b/src/features/projects/view/ProjectsPage.tsx @@ -1,18 +1,18 @@ -import SessionProjectRepository from "../domain/SessionProjectRepository" +import ProjectRepository from "../domain/ProjectRepository" import ClientProjectsPage from "./client/ProjectsPage" export default async function ProjectsPage({ - sessionProjectRepository, + projectRepository, projectId, versionId, specificationId }: { - sessionProjectRepository: SessionProjectRepository + projectRepository: ProjectRepository projectId?: string versionId?: string specificationId?: string }) { - const projects = await sessionProjectRepository.getProjects() + const projects = await projectRepository.get() return ( Date: Tue, 7 Nov 2023 09:08:18 +0100 Subject: [PATCH 05/10] Introduces new ILogOutHandler concept --- __test__/auth/CompositeLogOutHandler.test.ts | 24 +++++++++++ .../auth/ErrorIgnoringLogOutHandler.test.ts | 11 +++++ .../auth/UserDataCleanUpLogOutHandler.test.ts | 16 ++++++++ .../common/authHandler/logoutHandler.test.ts | 41 ------------------- src/app/api/auth/[auth0]/route.ts | 18 ++------ src/common/authHandler/logout.ts | 12 ------ src/composition.ts | 30 ++++++++------ .../domain/logOut/CompositeLogOutHandler.ts | 14 +++++++ .../logOut/ErrorIgnoringLogOutHandler.ts | 15 +++++++ .../auth/domain/logOut/ILogOutHandler.ts | 3 ++ .../logOut/UserDataCleanUpLogOutHandler.ts | 24 +++++++++++ 11 files changed, 129 insertions(+), 79 deletions(-) create mode 100644 __test__/auth/CompositeLogOutHandler.test.ts create mode 100644 __test__/auth/ErrorIgnoringLogOutHandler.test.ts create mode 100644 __test__/auth/UserDataCleanUpLogOutHandler.test.ts delete mode 100644 __test__/common/authHandler/logoutHandler.test.ts delete mode 100644 src/common/authHandler/logout.ts create mode 100644 src/features/auth/domain/logOut/CompositeLogOutHandler.ts create mode 100644 src/features/auth/domain/logOut/ErrorIgnoringLogOutHandler.ts create mode 100644 src/features/auth/domain/logOut/ILogOutHandler.ts create mode 100644 src/features/auth/domain/logOut/UserDataCleanUpLogOutHandler.ts diff --git a/__test__/auth/CompositeLogOutHandler.test.ts b/__test__/auth/CompositeLogOutHandler.test.ts new file mode 100644 index 00000000..9618723c --- /dev/null +++ b/__test__/auth/CompositeLogOutHandler.test.ts @@ -0,0 +1,24 @@ +import CompositeLogOutHandler from "../../src/features/auth/domain/logOut/CompositeLogOutHandler" + +test("It invokes all log out handlers", async () => { + let didCallLogOutHandler1 = false + let didCallLogOutHandler2 = false + let didCallLogOutHandler3 = false + const sut = new CompositeLogOutHandler([{ + async handleLogOut() { + didCallLogOutHandler1 = true + } + }, { + async handleLogOut() { + didCallLogOutHandler2 = true + } + }, { + async handleLogOut() { + didCallLogOutHandler3 = true + } + }]) + await sut.handleLogOut() + expect(didCallLogOutHandler1).toBeTruthy() + expect(didCallLogOutHandler2).toBeTruthy() + expect(didCallLogOutHandler3).toBeTruthy() +}) diff --git a/__test__/auth/ErrorIgnoringLogOutHandler.test.ts b/__test__/auth/ErrorIgnoringLogOutHandler.test.ts new file mode 100644 index 00000000..5feae77c --- /dev/null +++ b/__test__/auth/ErrorIgnoringLogOutHandler.test.ts @@ -0,0 +1,11 @@ +import ErrorIgnoringLogOutHandler from "../../src/features/auth/domain/logOut/ErrorIgnoringLogOutHandler" + +test("It ignores errors", async () => { + const sut = new ErrorIgnoringLogOutHandler({ + async handleLogOut() { + throw new Error("Mock") + } + }) + // Test will fail if the following throws. + await sut.handleLogOut() +}) diff --git a/__test__/auth/UserDataCleanUpLogOutHandler.test.ts b/__test__/auth/UserDataCleanUpLogOutHandler.test.ts new file mode 100644 index 00000000..c8db0461 --- /dev/null +++ b/__test__/auth/UserDataCleanUpLogOutHandler.test.ts @@ -0,0 +1,16 @@ +import UserDataCleanUpLogOutHandler from "../../src/features/auth/domain/logOut/UserDataCleanUpLogOutHandler" + +test("It deletes data for the read user ID", async () => { + let deletedUserId: string | undefined + const sut = new UserDataCleanUpLogOutHandler({ + async getUserId() { + return "foo" + }, + }, { + async delete(userId) { + deletedUserId = userId + } + }) + await sut.handleLogOut() + expect(deletedUserId).toBe("foo") +}) diff --git a/__test__/common/authHandler/logoutHandler.test.ts b/__test__/common/authHandler/logoutHandler.test.ts deleted file mode 100644 index 49bd48e4..00000000 --- a/__test__/common/authHandler/logoutHandler.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -import logoutHandler from "../../../src/common/authHandler/logout" - -test("It deletes the user's auth token", async () => { - let didDeleteAuthToken = false - logoutHandler({ - async getOAuthToken() { - throw new Error("Not implemented") - }, - async storeOAuthToken() {}, - async deleteOAuthToken() { - didDeleteAuthToken = true - } - }, { - async getProjects() { - return [] - }, - async storeProjects() {}, - async deleteProjects() {} - }) - expect(didDeleteAuthToken).toBeTruthy() -}) - -test("It deletes the cached projects", async () => { - let didDeleteProjects = false - logoutHandler({ - async getOAuthToken() { - throw new Error("Not implemented") - }, - async storeOAuthToken() {}, - async deleteOAuthToken() {} - }, { - async getProjects() { - return [] - }, - async storeProjects() {}, - async deleteProjects() { - didDeleteProjects = true - } - }) - expect(didDeleteProjects).toBeTruthy() -}) diff --git a/src/app/api/auth/[auth0]/route.ts b/src/app/api/auth/[auth0]/route.ts index 1beb7e25..4a914a2f 100644 --- a/src/app/api/auth/[auth0]/route.ts +++ b/src/app/api/auth/[auth0]/route.ts @@ -1,19 +1,13 @@ -import { NextRequest, NextResponse } from "next/server" +import { NextResponse } from "next/server" import { handleAuth, handleCallback, handleLogout, AfterCallbackAppRoute, NextAppRouterHandler, - AppRouteHandlerFnContext, AppRouterOnError } from "@auth0/nextjs-auth0" -import { - oAuthTokenTransferer, - sessionOAuthTokenRepository, - sessionProjectRepository, - logoutHandler -} from "@/composition" +import { logOutHandler } from "@/composition" const { SHAPE_DOCS_BASE_URL } = process.env @@ -27,12 +21,8 @@ const onError: AppRouterOnError = async () => { return NextResponse.redirect(url) } -const onLogout: NextAppRouterHandler = async (req: NextRequest, ctx: AppRouteHandlerFnContext) => { - await Promise.all([ - sessionOAuthTokenRepository.deleteOAuthToken().catch(() => null), - sessionProjectRepository.deleteProjects().catch(() => null) - ]) - await logoutHandler() +const onLogout: NextAppRouterHandler = async (req, ctx) => { + await logOutHandler.handleLogOut() return await handleLogout(req, ctx) } diff --git a/src/common/authHandler/logout.ts b/src/common/authHandler/logout.ts deleted file mode 100644 index 9bc80634..00000000 --- a/src/common/authHandler/logout.ts +++ /dev/null @@ -1,12 +0,0 @@ -import ISessionOAuthTokenRepository from "@/features/auth/domain/ISessionOAuthTokenRepository" -import ISessionProjectRepository from "@/features/projects/domain/ISessionProjectRepository" - -export default async function logoutHandler( - sessionOAuthTokenRepository: ISessionOAuthTokenRepository, - sessionProjectRepository: ISessionProjectRepository -) { - await Promise.all([ - sessionOAuthTokenRepository.deleteOAuthToken().catch(() => null), - sessionProjectRepository.deleteProjects().catch(() => null) - ]) -} diff --git a/src/composition.ts b/src/composition.ts index fc248457..656d0326 100644 --- a/src/composition.ts +++ b/src/composition.ts @@ -2,6 +2,8 @@ import AccessTokenRefreshingGitHubClient from "@/common/github/AccessTokenRefres import Auth0RefreshTokenReader from "@/features/auth/data/Auth0RefreshTokenReader" import Auth0Session from "@/common/session/Auth0Session" import CachingProjectDataSource from "@/features/projects/domain/CachingProjectDataSource" +import CompositeLogOutHandler from "@/features/auth/domain/logOut/CompositeLogOutHandler" +import ErrorIgnoringLogOutHandler from "@/features/auth/domain/logOut/ErrorIgnoringLogOutHandler" import GitHubClient from "@/common/github/GitHubClient" import GitHubOAuthTokenRefresher from "@/features/auth/data/GitHubOAuthTokenRefresher" import GitHubOrganizationSessionValidator from "@/common/session/GitHubOrganizationSessionValidator" @@ -19,6 +21,7 @@ import SessionOAuthTokenRepository from "@/features/auth/domain/SessionOAuthToke import SessionValidatingProjectDataSource from "@/features/projects/domain/SessionValidatingProjectDataSource" import OAuthTokenRepository from "@/features/auth/domain/OAuthTokenRepository" import authLogoutHandler from "@/common/authHandler/logout" +import UserDataCleanUpLogOutHandler from "@/features/auth/domain/logOut/UserDataCleanUpLogOutHandler" const { AUTH0_MANAGEMENT_DOMAIN, @@ -77,14 +80,14 @@ export const sessionValidator = new GitHubOrganizationSessionValidator( GITHUB_ORGANIZATION_NAME ) -export const sessionProjectRepository = new SessionProjectRepository( - new SessionDataRepository( - new Auth0Session(), - new KeyValueUserDataRepository( - new RedisKeyValueStore(REDIS_URL), - "projects" - ) - ) +const projectUserDataRepository = new KeyValueUserDataRepository( + new RedisKeyValueStore(REDIS_URL), + "projects" +) + +export const projectRepository = new ProjectRepository( + session, + projectUserDataRepository ) export const projectDataSource = new CachingProjectDataSource( @@ -95,7 +98,7 @@ export const projectDataSource = new CachingProjectDataSource( GITHUB_ORGANIZATION_NAME ) ), - sessionProjectRepository + projectRepository ) export const oAuthTokenTransferer = new OAuthTokenTransferer({ @@ -109,6 +112,9 @@ export const oAuthTokenTransferer = new OAuthTokenTransferer({ oAuthTokenRepository: new OAuthTokenRepository(oAuthTokenRepository) }) -export const logoutHandler = async () => { - await authLogoutHandler(sessionOAuthTokenRepository, sessionProjectRepository) -} +export const logOutHandler = new ErrorIgnoringLogOutHandler( + new CompositeLogOutHandler([ + new UserDataCleanUpLogOutHandler(session, projectUserDataRepository), + new UserDataCleanUpLogOutHandler(session, oAuthTokenRepository), + ]) +) diff --git a/src/features/auth/domain/logOut/CompositeLogOutHandler.ts b/src/features/auth/domain/logOut/CompositeLogOutHandler.ts new file mode 100644 index 00000000..ab69b11a --- /dev/null +++ b/src/features/auth/domain/logOut/CompositeLogOutHandler.ts @@ -0,0 +1,14 @@ +import ILogOutHandler from "./ILogOutHandler" + +export default class CompositeLogOutHandler implements ILogOutHandler { + private readonly handlers: ILogOutHandler[] + + constructor(handlers: ILogOutHandler[]) { + this.handlers = handlers + } + + async handleLogOut(): Promise { + const promises = this.handlers.map(e => e.handleLogOut()) + await Promise.all(promises) + } +} diff --git a/src/features/auth/domain/logOut/ErrorIgnoringLogOutHandler.ts b/src/features/auth/domain/logOut/ErrorIgnoringLogOutHandler.ts new file mode 100644 index 00000000..7db66f58 --- /dev/null +++ b/src/features/auth/domain/logOut/ErrorIgnoringLogOutHandler.ts @@ -0,0 +1,15 @@ +import ILogOutHandler from "./ILogOutHandler" + +export default class ErrorIgnoringLogOutHandler implements ILogOutHandler { + private readonly handler: ILogOutHandler + + constructor(handler: ILogOutHandler) { + this.handler = handler + } + + async handleLogOut(): Promise { + try { + await this.handler.handleLogOut() + } catch {} + } +} diff --git a/src/features/auth/domain/logOut/ILogOutHandler.ts b/src/features/auth/domain/logOut/ILogOutHandler.ts new file mode 100644 index 00000000..88cab6ac --- /dev/null +++ b/src/features/auth/domain/logOut/ILogOutHandler.ts @@ -0,0 +1,3 @@ +export default interface ILogOutHandler { + handleLogOut(): Promise +} diff --git a/src/features/auth/domain/logOut/UserDataCleanUpLogOutHandler.ts b/src/features/auth/domain/logOut/UserDataCleanUpLogOutHandler.ts new file mode 100644 index 00000000..08f724e2 --- /dev/null +++ b/src/features/auth/domain/logOut/UserDataCleanUpLogOutHandler.ts @@ -0,0 +1,24 @@ +import ILogOutHandler from "./ILogOutHandler" + +interface IUserIDReader { + getUserId(): Promise +} + +interface Repository { + delete(userId: string): Promise +} + +export default class UserDataCleanUpLogOutHandler implements ILogOutHandler { + private readonly userIdReader: IUserIDReader + private readonly repository: Repository + + constructor(userIdReader: IUserIDReader, repository: Repository) { + this.userIdReader = userIdReader + this.repository = repository + } + + async handleLogOut(): Promise { + const userId = await this.userIdReader.getUserId() + return await this.repository.delete(userId) + } +} From ba49088d8cf49a62e11e6476e42e4e92419f5d06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Tue, 7 Nov 2023 11:00:48 +0100 Subject: [PATCH 06/10] Adds IAccessTokenService --- .../auth/LockingAccessTokenService.test.ts | 91 ++++++++++++ .../auth/LockingOAuthTokenRefresher.test.ts | 131 ------------------ __test__/auth/OAuthTokenRepository.test.ts | 57 ++++++++ ...yStaleRefreshingAccessTokenService.test.ts | 46 ++++++ .../auth/SessionOAuthTokenRepository.test.ts | 54 -------- .../AccessTokenRefreshingGitHubClient.test.ts | 21 +-- .../AccessTokenRefreshingGitHubClient.ts | 18 +-- .../auth/data/Auth0RefreshTokenReader.ts | 11 +- .../auth/data/GitHubOAuthTokenRefresher.ts | 4 +- .../auth/domain/IAccessTokenRefresher.ts | 3 - .../auth/domain/IOAuthTokenRepository.ts | 7 - .../auth/domain/IRefreshTokenReader.ts | 3 - .../domain/ISessionOAuthTokenRepository.ts | 7 - .../domain/LockingAccessTokenRefresher.ts | 35 ----- .../auth/domain/OAuthTokenTransferer.ts | 23 --- .../auth/domain/SessionAccessTokenReader.ts | 14 -- .../domain/SessionOAuthTokenRepository.ts | 30 ---- .../domain/accessToken/IAccessTokenService.ts | 4 + .../accessToken/LockingAccessTokenService.ts | 27 ++++ .../OnlyStaleRefreshingAccessTokenService.ts | 23 +++ .../{ => oAuthToken}/IOAuthTokenRefresher.ts | 0 .../oAuthToken/IOAuthTokenRepository.ts | 7 + .../domain/{ => oAuthToken}/OAuthToken.ts | 0 .../{ => oAuthToken}/OAuthTokenRepository.ts | 10 +- 24 files changed, 280 insertions(+), 346 deletions(-) create mode 100644 __test__/auth/LockingAccessTokenService.test.ts delete mode 100644 __test__/auth/LockingOAuthTokenRefresher.test.ts create mode 100644 __test__/auth/OAuthTokenRepository.test.ts create mode 100644 __test__/auth/OnlyStaleRefreshingAccessTokenService.test.ts delete mode 100644 __test__/auth/SessionOAuthTokenRepository.test.ts delete mode 100644 src/features/auth/domain/IAccessTokenRefresher.ts delete mode 100644 src/features/auth/domain/IOAuthTokenRepository.ts delete mode 100644 src/features/auth/domain/IRefreshTokenReader.ts delete mode 100644 src/features/auth/domain/ISessionOAuthTokenRepository.ts delete mode 100644 src/features/auth/domain/LockingAccessTokenRefresher.ts delete mode 100644 src/features/auth/domain/OAuthTokenTransferer.ts delete mode 100644 src/features/auth/domain/SessionAccessTokenReader.ts delete mode 100644 src/features/auth/domain/SessionOAuthTokenRepository.ts create mode 100644 src/features/auth/domain/accessToken/IAccessTokenService.ts create mode 100644 src/features/auth/domain/accessToken/LockingAccessTokenService.ts create mode 100644 src/features/auth/domain/accessToken/OnlyStaleRefreshingAccessTokenService.ts rename src/features/auth/domain/{ => oAuthToken}/IOAuthTokenRefresher.ts (100%) create mode 100644 src/features/auth/domain/oAuthToken/IOAuthTokenRepository.ts rename src/features/auth/domain/{ => oAuthToken}/OAuthToken.ts (100%) rename src/features/auth/domain/{ => oAuthToken}/OAuthTokenRepository.ts (73%) 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/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/OAuthTokenTransferer.ts b/src/features/auth/domain/OAuthTokenTransferer.ts deleted file mode 100644 index 27189b39..00000000 --- a/src/features/auth/domain/OAuthTokenTransferer.ts +++ /dev/null @@ -1,23 +0,0 @@ -import IRefreshTokenReader from "./IRefreshTokenReader" -import IOAuthTokenRefresher from "./IOAuthTokenRefresher" -import IOAuthTokenRepository from "./IOAuthTokenRepository" - -type OAuthTokenTransfererConfig = { - readonly refreshTokenReader: IRefreshTokenReader - readonly oAuthTokenRefresher: IOAuthTokenRefresher - readonly oAuthTokenRepository: IOAuthTokenRepository -} - -export default class OAuthTokenTransferer { - private readonly config: OAuthTokenTransfererConfig - - constructor(config: OAuthTokenTransfererConfig) { - this.config = config - } - - async transferAuthTokenForUser(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) - } -} 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/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/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) } } From 91d97f480531c7a2183b22992d12b86e131268e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Tue, 7 Nov 2023 11:27:58 +0100 Subject: [PATCH 07/10] Ignores unused variables starting with underscore --- .eslintrc.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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": [ From b7dc59caa60af38e725e3a6e697921ea64a798b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Tue, 7 Nov 2023 11:35:00 +0100 Subject: [PATCH 08/10] Adds AccessTokenService --- __test__/auth/AccessTokenService.test.ts | 86 +++++++++++++++++++ ...st.ts => InitialOAuthTokenService.test.ts} | 36 ++++---- src/app/api/auth/[auth0]/route.ts | 3 +- src/composition.ts | 69 +++++++-------- .../domain/accessToken/AccessTokenService.ts | 39 +++++++++ .../oAuthToken/InitialOAuthTokenService.ts | 26 ++++++ .../auth/view/SessionOAuthTokenBarrier.tsx | 5 +- 7 files changed, 209 insertions(+), 55 deletions(-) create mode 100644 __test__/auth/AccessTokenService.test.ts rename __test__/auth/{OAuthTokenTransferer.test.ts => InitialOAuthTokenService.test.ts} (71%) create mode 100644 src/features/auth/domain/accessToken/AccessTokenService.ts create mode 100644 src/features/auth/domain/oAuthToken/InitialOAuthTokenService.ts 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/OAuthTokenTransferer.test.ts b/__test__/auth/InitialOAuthTokenService.test.ts similarity index 71% rename from __test__/auth/OAuthTokenTransferer.test.ts rename to __test__/auth/InitialOAuthTokenService.test.ts index 2c8ec21b..307b61f6 100644 --- a/__test__/auth/OAuthTokenTransferer.test.ts +++ b/__test__/auth/InitialOAuthTokenService.test.ts @@ -1,9 +1,9 @@ -import OAuthTokenTransferer from "../../src/features/auth/domain/OAuthTokenTransferer" -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 - const sut = new OAuthTokenTransferer({ + const sut = new InitialOAuthTokenService({ refreshTokenReader: { async getRefreshToken(userId) { fetchedUserId = userId @@ -16,20 +16,20 @@ 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.transferAuthTokenForUser("123") + await sut.fetchInitialAuthTokenForUser("123") expect(fetchedUserId).toBe("123") }) test("It refreshes the fetched refresh token", async () => { let refreshedRefreshToken: string | undefined - const sut = new OAuthTokenTransferer({ + const sut = new InitialOAuthTokenService({ refreshTokenReader: { async getRefreshToken() { return "helloworld" @@ -42,21 +42,21 @@ 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.transferAuthTokenForUser("123") + await sut.fetchInitialAuthTokenForUser("123") expect(refreshedRefreshToken).toBe("helloworld") }) test("It stores the refreshed auth token for the correct user ID", async () => { let storedAuthToken: OAuthToken | undefined let storedUserId: string | undefined - const sut = new OAuthTokenTransferer({ + const sut = new InitialOAuthTokenService({ refreshTokenReader: { async getRefreshToken() { return "helloworld" @@ -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.transferAuthTokenForUser("123") + 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/src/app/api/auth/[auth0]/route.ts b/src/app/api/auth/[auth0]/route.ts index b8e11a5e..98fccfcf 100644 --- a/src/app/api/auth/[auth0]/route.ts +++ b/src/app/api/auth/[auth0]/route.ts @@ -16,7 +16,8 @@ const afterCallback: AfterCallbackAppRoute = async (_req, session) => { return session } -const onError: AppRouterOnError = async () => { +const onError: AppRouterOnError = async (req, error) => { + console.log(error) const url = new URL(SHAPE_DOCS_BASE_URL + "/api/auth/forceLogout") return NextResponse.redirect(url) } diff --git a/src/composition.ts b/src/composition.ts index cb3576dd..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,17 +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/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 { @@ -35,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 }) ) @@ -101,15 +99,18 @@ export const projectDataSource = new CachingProjectDataSource( projectRepository ) -export const oAuthTokenTransferer = new OAuthTokenTransferer({ +export const initialOAuthTokenService = new InitialOAuthTokenService({ refreshTokenReader: new Auth0RefreshTokenReader({ domain: AUTH0_MANAGEMENT_DOMAIN, clientId: AUTH0_MANAGEMENT_CLIENT_ID, 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/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/oAuthToken/InitialOAuthTokenService.ts b/src/features/auth/domain/oAuthToken/InitialOAuthTokenService.ts new file mode 100644 index 00000000..c75bd8e2 --- /dev/null +++ b/src/features/auth/domain/oAuthToken/InitialOAuthTokenService.ts @@ -0,0 +1,26 @@ +import IOAuthTokenRefresher from "./IOAuthTokenRefresher" +import IOAuthTokenRepository from "./IOAuthTokenRepository" + +interface IRefreshTokenReader { + getRefreshToken(userId: string): Promise +} + +type InitialOAuthTokenServiceConfig = { + readonly refreshTokenReader: IRefreshTokenReader + readonly oAuthTokenRefresher: IOAuthTokenRefresher + readonly oAuthTokenRepository: IOAuthTokenRepository +} + +export default class InitialOAuthTokenService { + private readonly config: InitialOAuthTokenServiceConfig + + constructor(config: InitialOAuthTokenServiceConfig) { + this.config = config + } + + async fetchInitialAuthTokenForUser(userId: string): Promise { + const refreshToken = await this.config.refreshTokenReader.getRefreshToken(userId) + const authToken = await this.config.oAuthTokenRefresher.refreshOAuthToken(refreshToken) + this.config.oAuthTokenRepository.set(userId, authToken) + } +} \ No newline at end of file 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") From 1008896fb3b356c65e790e7df1fca1b667a4cb0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Tue, 7 Nov 2023 11:36:15 +0100 Subject: [PATCH 09/10] Removes unused code --- src/common/userData/ISessionDataRepository.ts | 5 ---- src/common/userData/SessionDataRepository.ts | 28 ------------------- 2 files changed, 33 deletions(-) delete mode 100644 src/common/userData/ISessionDataRepository.ts delete mode 100644 src/common/userData/SessionDataRepository.ts 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) - } -} From f6fe1c7e2d3e1b93dae535ae1569e7f0fa4389bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Tue, 7 Nov 2023 11:41:57 +0100 Subject: [PATCH 10/10] Removes debug logging --- src/app/api/auth/[auth0]/route.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/app/api/auth/[auth0]/route.ts b/src/app/api/auth/[auth0]/route.ts index 98fccfcf..b8e11a5e 100644 --- a/src/app/api/auth/[auth0]/route.ts +++ b/src/app/api/auth/[auth0]/route.ts @@ -16,8 +16,7 @@ const afterCallback: AfterCallbackAppRoute = async (_req, session) => { return session } -const onError: AppRouterOnError = async (req, error) => { - console.log(error) +const onError: AppRouterOnError = async () => { const url = new URL(SHAPE_DOCS_BASE_URL + "/api/auth/forceLogout") return NextResponse.redirect(url) }