diff --git a/__test__/auth/AccessTokenService.test.ts b/__test__/auth/AccessTokenService.test.ts index 4ad0edbf..9c0024c5 100644 --- a/__test__/auth/AccessTokenService.test.ts +++ b/__test__/auth/AccessTokenService.test.ts @@ -1,86 +1,121 @@ import { AccessTokenService } from "../../src/features/auth/domain" -import { OAuthToken } from "../../src/features/auth/domain" -test("It gets the access token for the user", async () => { - let readUserID: string | undefined +test("It reads the access token for a guest user", async () => { + let didReadAccessToken = false const sut = new AccessTokenService({ - userIdReader: { - async getUserId() { - return "1234" + isGuestReader: { + async getIsGuest() { + return true } }, - repository: { - async get(userId) { - readUserID = userId - return { accessToken: "foo", refreshToken: "bar" } + guestAccessTokenService: { + async getAccessToken() { + didReadAccessToken = true + return "oldAccessToken" }, - async set() {}, - async delete() {}, + async refreshAccessToken() { + return "newAccessToken" + } }, - refresher: { - async refreshOAuthToken() { - return { accessToken: "foo", refreshToken: "bar" } + hostAccessTokenService: { + async getAccessToken() { + return "oldAccessToken" + }, + async refreshAccessToken() { + return "newAccessToken" } } }) - const accessToken = await sut.getAccessToken() - expect(readUserID).toBe("1234") - expect(accessToken).toBe("foo") + await sut.getAccessToken() + expect(didReadAccessToken).toBeTruthy() }) -test("It refreshes OAuth using stored refresh token", async () => { - let usedRefreshToken: string | undefined +test("It refreshes the access token for a guest user", async () => { + let didRefreshAccessToken = false const sut = new AccessTokenService({ - userIdReader: { - async getUserId() { - return "1234" + isGuestReader: { + async getIsGuest() { + return true } }, - repository: { - async get() { - return { accessToken: "oldAccessToken", refreshToken: "oldRefreshToken" } + guestAccessTokenService: { + async getAccessToken() { + return "oldAccessToken" }, - async set() {}, - async delete() {}, + async refreshAccessToken() { + didRefreshAccessToken = true + return "newAccessToken" + } }, - refresher: { - async refreshOAuthToken(refreshToken) { - usedRefreshToken = refreshToken - return { accessToken: "newAccessToken", refreshToken: "newRefreshToken" } + hostAccessTokenService: { + async getAccessToken() { + return "oldAccessToken" + }, + async refreshAccessToken() { + return "newAccessToken" } } }) await sut.refreshAccessToken("oldAccessToken") - expect(usedRefreshToken).toBe("oldRefreshToken") + expect(didRefreshAccessToken).toBeTruthy() }) -test("It stores the new OAuth token for the user", async () => { - let storedUserId: string | undefined - let storedOAuthToken: OAuthToken | undefined +test("It reads the access token for a host user", async () => { + let didReadAccessToken = false const sut = new AccessTokenService({ - userIdReader: { - async getUserId() { - return "1234" + isGuestReader: { + async getIsGuest() { + return false } }, - repository: { - async get() { - return { accessToken: "oldAccessToken", refreshToken: "oldRefreshToken" } + guestAccessTokenService: { + async getAccessToken() { + return "oldAccessToken" }, - async set(userId, oAuthToken) { - storedUserId = userId - storedOAuthToken = oAuthToken + async refreshAccessToken() { + return "newAccessToken" + } + }, + hostAccessTokenService: { + async getAccessToken() { + didReadAccessToken = true + return "oldAccessToken" + }, + async refreshAccessToken() { + return "newAccessToken" + } + } + }) + await sut.getAccessToken() + expect(didReadAccessToken).toBeTruthy() +}) + +test("It refreshes the access token for a host user", async () => { + let didRefreshAccessToken = false + const sut = new AccessTokenService({ + isGuestReader: { + async getIsGuest() { + return false + } + }, + guestAccessTokenService: { + async getAccessToken() { + return "oldAccessToken" }, - async delete() {}, + async refreshAccessToken() { + return "newAccessToken" + } }, - refresher: { - async refreshOAuthToken() { - return { accessToken: "newAccessToken", refreshToken: "newRefreshToken" } + hostAccessTokenService: { + async getAccessToken() { + return "oldAccessToken" + }, + async refreshAccessToken() { + didRefreshAccessToken = true + return "newAccessToken" } } }) - await sut.refreshAccessToken("foo") - expect(storedUserId).toBe("1234") - expect(storedOAuthToken?.accessToken).toBe("newAccessToken") - expect(storedOAuthToken?.refreshToken).toBe("newRefreshToken") + await sut.refreshAccessToken("oldAccessToken") + expect(didRefreshAccessToken).toBeTruthy() }) diff --git a/__test__/auth/CachingRepositoryAccessReaderConfig.ts b/__test__/auth/CachingRepositoryAccessReaderConfig.ts new file mode 100644 index 00000000..3ee2aa6a --- /dev/null +++ b/__test__/auth/CachingRepositoryAccessReaderConfig.ts @@ -0,0 +1,90 @@ +import { CachingRepositoryAccessReaderConfig } from "../../src/features/auth/domain" + +test("It fetches repository names for user if they are not cached", async () => { + let didFetchRepositoryNames = false + let requestedUserId: string | undefined + const sut = new CachingRepositoryAccessReaderConfig({ + repository: { + async get() { + return null + }, + async set() {}, + async delete() {} + }, + repositoryAccessReader: { + async getRepositoryNames(userId: string) { + didFetchRepositoryNames = true + requestedUserId = userId + return [] + } + } + }) + await sut.getRepositoryNames("1234") + expect(didFetchRepositoryNames).toBeTruthy() + expect(requestedUserId).toEqual("1234") +}) + +test("It does not fetch repository names if they are cached", async () => { + let didFetchRepositoryNames = false + const sut = new CachingRepositoryAccessReaderConfig({ + repository: { + async get() { + return "[\"foo\"]" + }, + async set() {}, + async delete() {} + }, + repositoryAccessReader: { + async getRepositoryNames() { + didFetchRepositoryNames = true + return [] + } + } + }) + await sut.getRepositoryNames("1234") + expect(didFetchRepositoryNames).toBeFalsy() +}) + +test("It caches fetched repository names for user", async () => { + let cachedUserId: string | undefined + let cachedRepositoryNames: string | undefined + const sut = new CachingRepositoryAccessReaderConfig({ + repository: { + async get() { + return null + }, + async set(userId, value) { + cachedUserId = userId + cachedRepositoryNames = value + }, + async delete() {} + }, + repositoryAccessReader: { + async getRepositoryNames() { + return ["foo"] + } + } + }) + await sut.getRepositoryNames("1234") + expect(cachedUserId).toEqual("1234") + expect(cachedRepositoryNames).toEqual("[\"foo\"]") +}) + +test("It decodes cached repository names", async () => { + const sut = new CachingRepositoryAccessReaderConfig({ + repository: { + async get() { + return "[\"foo\",\"bar\"]" + }, + async set() {}, + async delete() {} + }, + repositoryAccessReader: { + async getRepositoryNames() { + return [] + } + } + }) + const repositoryNames = await sut.getRepositoryNames("1234") + expect(repositoryNames).toEqual(["foo", "bar"]) +}) diff --git a/__test__/auth/CachingUserIdentityProviderReader.test.ts b/__test__/auth/CachingUserIdentityProviderReader.test.ts new file mode 100644 index 00000000..60515704 --- /dev/null +++ b/__test__/auth/CachingUserIdentityProviderReader.test.ts @@ -0,0 +1,60 @@ +import { CachingUserIdentityProviderReader } from "../../src/features/auth/domain" +import { UserIdentityProvider } from "../../src/features/auth/domain" + +test("It fetches user identity provider if it is not cached", async () => { + let didFetchUserIdentityProvider = false + const sut = new CachingUserIdentityProviderReader({ + async get() { + return null + }, + async set() {}, + async delete() {} + }, { + async getUserIdentityProvider() { + didFetchUserIdentityProvider = true + return UserIdentityProvider.GITHUB + }, + }) + await sut.getUserIdentityProvider("foo") + expect(didFetchUserIdentityProvider).toBeTruthy() +}) + +test("It does not fetch user identity provider if it is cached", async () => { + let didFetchUserIdentityProvider = false + const sut = new CachingUserIdentityProviderReader({ + async get() { + return UserIdentityProvider.GITHUB + }, + async set() {}, + async delete() {} + }, { + async getUserIdentityProvider() { + didFetchUserIdentityProvider = true + return UserIdentityProvider.GITHUB + }, + }) + await sut.getUserIdentityProvider("foo") + expect(didFetchUserIdentityProvider).toBeFalsy() +}) + +test("It caches fetched user identity provider for user", async () => { + let cachedUserId: string | undefined + let cachedUserIdentityProvider: string | undefined + const sut = new CachingUserIdentityProviderReader({ + async get() { + return null + }, + async set(userId, userIdentityProvider) { + cachedUserId = userId + cachedUserIdentityProvider = userIdentityProvider + }, + async delete() {} + }, { + async getUserIdentityProvider() { + return UserIdentityProvider.USERNAME_PASSWORD + }, + }) + await sut.getUserIdentityProvider("1234") + expect(cachedUserId).toBe("1234") + expect(cachedUserIdentityProvider).toBe(UserIdentityProvider.USERNAME_PASSWORD.toString()) +}) diff --git a/__test__/auth/CredentialsTransferringLogInHandler.test.ts b/__test__/auth/CredentialsTransferringLogInHandler.test.ts index 43f2d956..f1061b40 100644 --- a/__test__/auth/CredentialsTransferringLogInHandler.test.ts +++ b/__test__/auth/CredentialsTransferringLogInHandler.test.ts @@ -1,12 +1,43 @@ import { CredentialsTransferringLogInHandler } from "../../src/features/auth/domain" -test("It transfers credentials", async () => { - let didTransferCredentials = false +test("It transfers credentials for guest", async () => { + let didTransferGuestCredentials = false const sut = new CredentialsTransferringLogInHandler({ - async transferCredentials() { - didTransferCredentials = true + isUserGuestReader: { + async getIsUserGuest() { + return true + } + }, + guestCredentialsTransferrer: { + async transferCredentials() { + didTransferGuestCredentials = true + } + }, + hostCredentialsTransferrer: { + async transferCredentials() {} } }) await sut.handleLogIn("1234") - expect(didTransferCredentials).toBeTruthy() + expect(didTransferGuestCredentials).toBeTruthy() +}) + +test("It transfers credentials for host", async () => { + let didTransferHostCredentials = false + const sut = new CredentialsTransferringLogInHandler({ + isUserGuestReader: { + async getIsUserGuest() { + return false + } + }, + guestCredentialsTransferrer: { + async transferCredentials() {} + }, + hostCredentialsTransferrer: { + async transferCredentials() { + didTransferHostCredentials = true + } + } + }) + await sut.handleLogIn("1234") + expect(didTransferHostCredentials).toBeTruthy() }) diff --git a/__test__/auth/GuestAccessTokenService.test.ts b/__test__/auth/GuestAccessTokenService.test.ts new file mode 100644 index 00000000..708a630d --- /dev/null +++ b/__test__/auth/GuestAccessTokenService.test.ts @@ -0,0 +1,52 @@ +import { GuestAccessTokenService } from "../../src/features/auth/domain" + +test("It gets the access token for the user", async () => { + let readUserId: string | undefined + const sut = new GuestAccessTokenService({ + userIdReader: { + async getUserId() { + return "1234" + } + }, + repository: { + async get(userId) { + readUserId = userId + return "foo" + }, + async set() {} + }, + dataSource: { + async getAccessToken() { + return "foo" + } + } + }) + const accessToken = await sut.getAccessToken() + expect(readUserId).toBe("1234") + expect(accessToken).toBe("foo") +}) + +test("It refreshes access token on demand when there is no cached access token", async () => { + let didRefreshAccessToken = false + const sut = new GuestAccessTokenService({ + userIdReader: { + async getUserId() { + return "1234" + } + }, + repository: { + async get() { + return null + }, + async set() {} + }, + dataSource: { + async getAccessToken() { + didRefreshAccessToken = true + return "foo" + } + } + }) + await sut.getAccessToken() + expect(didRefreshAccessToken).toBeTruthy() +}) diff --git a/__test__/auth/HostAccessTokenService.test.ts b/__test__/auth/HostAccessTokenService.test.ts new file mode 100644 index 00000000..79fcaf72 --- /dev/null +++ b/__test__/auth/HostAccessTokenService.test.ts @@ -0,0 +1,86 @@ +import { HostAccessTokenService } from "../../src/features/auth/domain" +import { OAuthToken } from "../../src/features/auth/domain" + +test("It gets the access token for the user", async () => { + let readUserID: string | undefined + const sut = new HostAccessTokenService({ + 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 HostAccessTokenService({ + 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 HostAccessTokenService({ + 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/CredentialsTransferrer.test.ts b/__test__/auth/HostCredentialsTransferrer.test.ts similarity index 90% rename from __test__/auth/CredentialsTransferrer.test.ts rename to __test__/auth/HostCredentialsTransferrer.test.ts index 4a405803..a5988aa8 100644 --- a/__test__/auth/CredentialsTransferrer.test.ts +++ b/__test__/auth/HostCredentialsTransferrer.test.ts @@ -1,9 +1,9 @@ -import { CredentialsTransferrer } from "../../src/features/auth/domain" +import { HostCredentialsTransferrer } from "../../src/features/auth/domain" import { OAuthToken } from "../../src/features/auth/domain" test("It fetches refresh token for specified user", async () => { let fetchedUserId: string | undefined - const sut = new CredentialsTransferrer({ + const sut = new HostCredentialsTransferrer({ refreshTokenReader: { async getRefreshToken(userId) { fetchedUserId = userId @@ -29,7 +29,7 @@ test("It fetches refresh token for specified user", async () => { test("It refreshes the fetched refresh token", async () => { let refreshedRefreshToken: string | undefined - const sut = new CredentialsTransferrer({ + const sut = new HostCredentialsTransferrer({ refreshTokenReader: { async getRefreshToken() { return "helloworld" @@ -56,7 +56,7 @@ test("It refreshes the fetched refresh token", async () => { test("It stores the refreshed auth token for the user", async () => { let storedAuthToken: OAuthToken | undefined let storedUserId: string | undefined - const sut = new CredentialsTransferrer({ + const sut = new HostCredentialsTransferrer({ refreshTokenReader: { async getRefreshToken() { return "helloworld" diff --git a/__test__/auth/IsUserGuestReader.test.ts b/__test__/auth/IsUserGuestReader.test.ts new file mode 100644 index 00000000..2d604818 --- /dev/null +++ b/__test__/auth/IsUserGuestReader.test.ts @@ -0,0 +1,22 @@ +import { IsUserGuestReader } from "../../src/features/auth/domain" +import { UserIdentityProvider } from "../../src/features/auth/domain" + +test("It does not consider a user to be a guest if they are logged in with GitHub", async () => { + const sut = new IsUserGuestReader({ + async getUserIdentityProvider() { + return UserIdentityProvider.GITHUB + } + }) + const isGuest = await sut.getIsUserGuest("foo") + expect(isGuest).toBeFalsy() +}) + +test("It considers user a to be a guest if they are logged in with username and password", async () => { + const sut = new IsUserGuestReader({ + async getUserIdentityProvider() { + return UserIdentityProvider.USERNAME_PASSWORD + } + }) + const isGuest = await sut.getIsUserGuest("foo") + expect(isGuest).toBeTruthy() +}) diff --git a/__test__/auth/RepositoryRestrictingAccessTokenDataSource.ts b/__test__/auth/RepositoryRestrictingAccessTokenDataSource.ts new file mode 100644 index 00000000..c4a26be7 --- /dev/null +++ b/__test__/auth/RepositoryRestrictingAccessTokenDataSource.ts @@ -0,0 +1,20 @@ +import { RepositoryRestrictingAccessTokenDataSource } from "../../src/features/auth/domain" + +test("It limits access to the fetched repositories", async () => { + let restrictingRepositoryNames: string[] | undefined + const sut = new RepositoryRestrictingAccessTokenDataSource({ + repositoryAccessReader: { + async getRepositoryNames() { + return ["foo", "bar"] + } + }, + dataSource: { + async getAccessToken(repositoryNames) { + restrictingRepositoryNames = repositoryNames + return "secret" + }, + } + }) + await sut.getAccessToken("1234") + expect(restrictingRepositoryNames).toEqual(["foo", "bar"]) +}) diff --git a/__test__/common/session/SessionValidator.test.ts b/__test__/common/session/SessionValidator.test.ts new file mode 100644 index 00000000..057bd6cd --- /dev/null +++ b/__test__/common/session/SessionValidator.test.ts @@ -0,0 +1,49 @@ +import { SessionValidator } from "../../../src/common" + +test("It validates a host user", async () => { + let didValidateHostUser = false + const sut = new SessionValidator({ + isGuestReader: { + async getIsGuest() { + return false + } + }, + guestSessionValidator: { + async validateSession() { + return true + }, + }, + hostSessionValidator: { + async validateSession() { + didValidateHostUser = true + return true + }, + } + }) + await sut.validateSession() + expect(didValidateHostUser).toBeTruthy() +}) + +test("It validates a guest user", async () => { + let didValidateGuestUser = false + const sut = new SessionValidator({ + isGuestReader: { + async getIsGuest() { + return true + } + }, + guestSessionValidator: { + async validateSession() { + didValidateGuestUser = true + return true + }, + }, + hostSessionValidator: { + async validateSession() { + return true + }, + } + }) + await sut.validateSession() + expect(didValidateGuestUser).toBeTruthy() +}) diff --git a/src/app/[...slug]/page.tsx b/src/app/[...slug]/page.tsx index 2193779a..cfcb585e 100644 --- a/src/app/[...slug]/page.tsx +++ b/src/app/[...slug]/page.tsx @@ -1,5 +1,5 @@ -import { getProjectId, getSpecificationId, getVersionId } from "@/common/utils/url" -import SessionOAuthTokenBarrier from "@/features/auth/view/SessionOAuthTokenBarrier" +import { getProjectId, getSpecificationId, getVersionId } from "../../common" +import SessionAccessTokenBarrier from "@/features/auth/view/SessionAccessTokenBarrier" import ProjectsPage from "@/features/projects/view/ProjectsPage" import { projectRepository } from "@/composition" @@ -8,14 +8,14 @@ type PageParams = { slug: string | string[] } export default async function Page({ params }: { params: PageParams }) { const url = getURL(params) return ( - + - + ) } diff --git a/src/app/api/user/projects/route.ts b/src/app/api/user/projects/route.ts index 799f13f6..64a96f68 100644 --- a/src/app/api/user/projects/route.ts +++ b/src/app/api/user/projects/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from "next/server" import { projectDataSource } from "@/composition" -import { UnauthorizedError, InvalidSessionError } from "@/common/errors" +import { UnauthorizedError, InvalidSessionError } from "../../../../common" export async function GET() { try { diff --git a/src/app/page.tsx b/src/app/page.tsx index 9fcd36ad..55087f2f 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,11 +1,11 @@ -import SessionOAuthTokenBarrier from "@/features/auth/view/SessionOAuthTokenBarrier" +import SessionAccessTokenBarrier from "@/features/auth/view/SessionAccessTokenBarrier" import ProjectsPage from "@/features/projects/view/ProjectsPage" import { projectRepository } from "@/composition" export default async function Page() { return ( - + - + ) } diff --git a/src/common/session/AlwaysValidSessionValidator.ts b/src/common/session/AlwaysValidSessionValidator.ts new file mode 100644 index 00000000..3f55486a --- /dev/null +++ b/src/common/session/AlwaysValidSessionValidator.ts @@ -0,0 +1,7 @@ +import ISessionValidator from "./ISessionValidator" + +export default class AlwaysValidSessionValidator implements ISessionValidator { + async validateSession(): Promise { + return true + } +} \ No newline at end of file diff --git a/src/common/session/Auth0Session.ts b/src/common/session/Auth0Session.ts index 4db56f72..10c9e02c 100644 --- a/src/common/session/Auth0Session.ts +++ b/src/common/session/Auth0Session.ts @@ -1,8 +1,19 @@ import { getSession } from "@auth0/nextjs-auth0" -import { UnauthorizedError } from "../../common/errors" +import { UnauthorizedError } from "../../common" import ISession from "./ISession" +import IIsUserGuestReader from "@/features/auth/domain/userIdentityProvider/IsUserGuestReader" + +export type Auth0SessionConfig = { + readonly isUserGuestReader: IIsUserGuestReader +} export default class Auth0Session implements ISession { + private readonly isUserGuestReader: IIsUserGuestReader + + constructor(config: Auth0SessionConfig) { + this.isUserGuestReader = config.isUserGuestReader + } + async getUserId(): Promise { const session = await getSession() if (!session) { @@ -10,4 +21,9 @@ export default class Auth0Session implements ISession { } return session.user.sub } -} \ No newline at end of file + + async getIsGuest(): Promise { + const userId = await this.getUserId() + return await this.isUserGuestReader.getIsUserGuest(userId) + } +} diff --git a/src/common/session/ISession.ts b/src/common/session/ISession.ts index 6481c29c..2496223f 100644 --- a/src/common/session/ISession.ts +++ b/src/common/session/ISession.ts @@ -1,4 +1,4 @@ export default interface ISession { getUserId(): Promise + getIsGuest(): Promise } - diff --git a/src/common/session/SessionValidator.ts b/src/common/session/SessionValidator.ts new file mode 100644 index 00000000..4b3da8c9 --- /dev/null +++ b/src/common/session/SessionValidator.ts @@ -0,0 +1,32 @@ +import ISessionValidator from "./ISessionValidator" + +interface IIsGuestReader { + getIsGuest(): Promise +} + +type SessionValidatorConfig = { + readonly isGuestReader: IIsGuestReader + readonly guestSessionValidator: ISessionValidator + readonly hostSessionValidator: ISessionValidator +} + +export default class SessionValidator implements ISessionValidator { + private readonly isGuestReader: IIsGuestReader + private readonly guestSessionValidator: ISessionValidator + private readonly hostSessionValidator: ISessionValidator + + constructor(config: SessionValidatorConfig) { + this.isGuestReader = config.isGuestReader + this.guestSessionValidator = config.guestSessionValidator + this.hostSessionValidator = config.hostSessionValidator + } + + async validateSession(): Promise { + const isGuest = await this.isGuestReader.getIsGuest() + if (isGuest) { + return await this.guestSessionValidator.validateSession() + } else { + return await this.hostSessionValidator.validateSession() + } + } +} \ No newline at end of file diff --git a/src/common/session/index.ts b/src/common/session/index.ts index 5e920e71..4d19783f 100644 --- a/src/common/session/index.ts +++ b/src/common/session/index.ts @@ -1,3 +1,5 @@ +export { default as AlwaysValidSessionValidator } from "./AlwaysValidSessionValidator" export { default as GitHubOrganizationSessionValidator } from "./GitHubOrganizationSessionValidator" export type { default as ISession } from "./ISession" export type { default as ISessionValidator } from "./ISessionValidator" +export { default as SessionValidator } from "./SessionValidator" diff --git a/src/composition.ts b/src/composition.ts index b0fc8bc5..af8645e5 100644 --- a/src/composition.ts +++ b/src/composition.ts @@ -1,37 +1,48 @@ import Auth0Session from "@/common/session/Auth0Session" import RedisKeyedMutexFactory from "@/common/mutex/RedisKeyedMutexFactory" import RedisKeyValueStore from "@/common/keyValueStore/RedisKeyValueStore" - import { AccessTokenRefreshingGitHubClient, + AlwaysValidSessionValidator, GitHubClient, - GitHubOrganizationSessionValidator, + GitHubOrganizationSessionValidator, KeyValueUserDataRepository, - SessionMutexFactory + SessionMutexFactory, + SessionValidator } from "@/common" import { + GitHubProjectDataSource +} from "@/features/projects/data" +import { + CachingProjectDataSource, + ProjectRepository, + SessionValidatingProjectDataSource +} from "@/features/projects/domain" +import { + GitHubOAuthTokenRefresher, + GitHubInstallationAccessTokenDataSource, Auth0RefreshTokenReader, - GitHubOAuthTokenRefresher + Auth0RepositoryAccessReader, + Auth0UserIdentityProviderReader } from "@/features/auth/data" import { AccessTokenService, + CachingRepositoryAccessReaderConfig, + CachingUserIdentityProviderReader, CompositeLogOutHandler, - CredentialsTransferrer, CredentialsTransferringLogInHandler, ErrorIgnoringLogOutHandler, + GuestAccessTokenService, + NullObjectCredentialsTransferrer, + HostAccessTokenService, + HostCredentialsTransferrer, + IsUserGuestReader, LockingAccessTokenService, - OnlyStaleRefreshingAccessTokenService, OAuthTokenRepository, + OnlyStaleRefreshingAccessTokenService, + RepositoryRestrictingAccessTokenDataSource, UserDataCleanUpLogOutHandler } from "@/features/auth/domain" -import { - GitHubProjectDataSource -} from "@/features/projects/data" -import { - CachingProjectDataSource, - ProjectRepository, - SessionValidatingProjectDataSource -} from "@/features/projects/domain" const { AUTH0_MANAGEMENT_DOMAIN, @@ -45,18 +56,71 @@ const { REDIS_URL } = process.env -const gitHubPrivateKey = Buffer.from(GITHUB_PRIVATE_KEY_BASE_64, "base64").toString("utf-8") +const auth0ManagementCredentials = { + domain: AUTH0_MANAGEMENT_DOMAIN, + clientId: AUTH0_MANAGEMENT_CLIENT_ID, + clientSecret: AUTH0_MANAGEMENT_CLIENT_SECRET +} -export const session = new Auth0Session() +const gitHubAppCredentials = { + appId: GITHUB_APP_ID, + clientId: GITHUB_CLIENT_ID, + clientSecret: GITHUB_CLIENT_SECRET, + privateKey: Buffer + .from(GITHUB_PRIVATE_KEY_BASE_64, "base64") + .toString("utf-8") +} + +const userIdentityProviderRepository = new KeyValueUserDataRepository( + new RedisKeyValueStore(REDIS_URL), + "userIdentityProvider" +) -export const oAuthTokenRepository = new OAuthTokenRepository( +export const userIdentityProviderReader = new CachingUserIdentityProviderReader( + userIdentityProviderRepository, + new Auth0UserIdentityProviderReader(auth0ManagementCredentials) +) + +export const session = new Auth0Session({ + isUserGuestReader: new IsUserGuestReader(userIdentityProviderReader) +}) + +const oAuthTokenRepository = new OAuthTokenRepository( new KeyValueUserDataRepository( new RedisKeyValueStore(REDIS_URL), "authToken" ) ) -const accessTokenService = new LockingAccessTokenService( +const gitHubOAuthTokenRefresher = new GitHubOAuthTokenRefresher({ + clientId: gitHubAppCredentials.clientId, + clientSecret: gitHubAppCredentials.clientSecret +}) + +const accessTokenRepository = new KeyValueUserDataRepository( + new RedisKeyValueStore(REDIS_URL), + "accessToken" +) + +const guestRepositoryAccessRepository = new KeyValueUserDataRepository( + new RedisKeyValueStore(REDIS_URL), + "guestRepositoryAccess" +) + +const guestAccessTokenDataSource = new RepositoryRestrictingAccessTokenDataSource({ + repositoryAccessReader: new CachingRepositoryAccessReaderConfig({ + repository: guestRepositoryAccessRepository, + repositoryAccessReader: new Auth0RepositoryAccessReader({ + ...auth0ManagementCredentials + }) + }), + dataSource: new GitHubInstallationAccessTokenDataSource({ + ...gitHubAppCredentials, + organization: GITHUB_ORGANIZATION_NAME + }) +}) + +export const accessTokenService = new LockingAccessTokenService( new SessionMutexFactory( new RedisKeyedMutexFactory(REDIS_URL), session, @@ -64,21 +128,23 @@ const accessTokenService = new LockingAccessTokenService( ), new OnlyStaleRefreshingAccessTokenService( new AccessTokenService({ - userIdReader: session, - repository: oAuthTokenRepository, - refresher: new GitHubOAuthTokenRefresher({ - clientId: GITHUB_CLIENT_ID, - clientSecret: GITHUB_CLIENT_SECRET + isGuestReader: session, + guestAccessTokenService: new GuestAccessTokenService({ + userIdReader: session, + repository: accessTokenRepository, + dataSource: guestAccessTokenDataSource + }), + hostAccessTokenService: new HostAccessTokenService({ + userIdReader: session, + repository: oAuthTokenRepository, + refresher: gitHubOAuthTokenRefresher }) }) ) ) export const gitHubClient = new GitHubClient({ - appId: GITHUB_APP_ID, - clientId: GITHUB_CLIENT_ID, - clientSecret: GITHUB_CLIENT_SECRET, - privateKey: gitHubPrivateKey, + ...gitHubAppCredentials, accessTokenReader: accessTokenService }) @@ -87,10 +153,14 @@ export const userGitHubClient = new AccessTokenRefreshingGitHubClient( gitHubClient ) -export const sessionValidator = new GitHubOrganizationSessionValidator( - userGitHubClient, - GITHUB_ORGANIZATION_NAME -) +export const sessionValidator = new SessionValidator({ + isGuestReader: session, + guestSessionValidator: new AlwaysValidSessionValidator(), + hostSessionValidator: new GitHubOrganizationSessionValidator( + userGitHubClient, + GITHUB_ORGANIZATION_NAME + ) +}) const projectUserDataRepository = new KeyValueUserDataRepository( new RedisKeyValueStore(REDIS_URL), @@ -113,25 +183,27 @@ export const projectDataSource = new CachingProjectDataSource( projectRepository ) -export const logInHandler = new CredentialsTransferringLogInHandler( - new CredentialsTransferrer({ +export const logInHandler = new CredentialsTransferringLogInHandler({ + isUserGuestReader: new IsUserGuestReader( + userIdentityProviderReader + ), + guestCredentialsTransferrer: new NullObjectCredentialsTransferrer(), + hostCredentialsTransferrer: new HostCredentialsTransferrer({ refreshTokenReader: new Auth0RefreshTokenReader({ - domain: AUTH0_MANAGEMENT_DOMAIN, - clientId: AUTH0_MANAGEMENT_CLIENT_ID, - clientSecret: AUTH0_MANAGEMENT_CLIENT_SECRET, + ...auth0ManagementCredentials, connection: "github" }), - oAuthTokenRefresher: new GitHubOAuthTokenRefresher({ - clientId: GITHUB_CLIENT_ID, - clientSecret: GITHUB_CLIENT_SECRET - }), + oAuthTokenRefresher: gitHubOAuthTokenRefresher, oAuthTokenRepository: oAuthTokenRepository }) -) +}) export const logOutHandler = new ErrorIgnoringLogOutHandler( new CompositeLogOutHandler([ new UserDataCleanUpLogOutHandler(session, projectUserDataRepository), - new UserDataCleanUpLogOutHandler(session, oAuthTokenRepository) + new UserDataCleanUpLogOutHandler(session, userIdentityProviderRepository), + new UserDataCleanUpLogOutHandler(session, guestRepositoryAccessRepository), + new UserDataCleanUpLogOutHandler(session, oAuthTokenRepository), + new UserDataCleanUpLogOutHandler(session, accessTokenRepository) ]) ) diff --git a/src/features/auth/data/Auth0RefreshTokenReader.ts b/src/features/auth/data/Auth0RefreshTokenReader.ts index 642aebc2..cafe3edc 100644 --- a/src/features/auth/data/Auth0RefreshTokenReader.ts +++ b/src/features/auth/data/Auth0RefreshTokenReader.ts @@ -1,5 +1,5 @@ import { ManagementClient } from "auth0" -import { UnauthorizedError } from "@/common/errors" +import { UnauthorizedError } from "@/common" interface Auth0RefreshTokenReaderConfig { readonly domain: string diff --git a/src/features/auth/data/Auth0RepositoryAccessReader.ts b/src/features/auth/data/Auth0RepositoryAccessReader.ts new file mode 100644 index 00000000..1d923d61 --- /dev/null +++ b/src/features/auth/data/Auth0RepositoryAccessReader.ts @@ -0,0 +1,24 @@ +import { ManagementClient } from "auth0" + +type Auth0RepositoryAccessReaderConfig = { + readonly domain: string + readonly clientId: string + readonly clientSecret: string +} + +export default class Auth0RepositoryAccessReader { + private readonly managementClient: ManagementClient + + constructor(config: Auth0RepositoryAccessReaderConfig) { + this.managementClient = new ManagementClient({ + domain: config.domain, + clientId: config.clientId, + clientSecret: config.clientSecret + }) + } + + async getRepositoryNames(userId: string): Promise { + const response = await this.managementClient.users.getRoles({ id: userId }) + return response.data.map(e => e.name) + } +} diff --git a/src/features/auth/data/Auth0UserIdentityProviderReader.ts b/src/features/auth/data/Auth0UserIdentityProviderReader.ts new file mode 100644 index 00000000..dfd56b98 --- /dev/null +++ b/src/features/auth/data/Auth0UserIdentityProviderReader.ts @@ -0,0 +1,40 @@ +import { ManagementClient } from "auth0" +import UserIdentityProvider from "../domain/userIdentityProvider/UserIdentityProvider" +import IUserIdentityProviderReader from "../domain/userIdentityProvider/IUserIdentityProviderReader" +import { UnauthorizedError } from "@/common" + +interface Auth0UserIdentityProviderReaderConfig { + readonly domain: string + readonly clientId: string + readonly clientSecret: string +} + +export default class Auth0UserIdentityProviderReader implements IUserIdentityProviderReader { + private readonly managementClient: ManagementClient + + constructor(config: Auth0UserIdentityProviderReaderConfig) { + this.managementClient = new ManagementClient({ + domain: config.domain, + clientId: config.clientId, + clientSecret: config.clientSecret + }) + } + + async getUserIdentityProvider(userId: string): Promise { + const response = await this.managementClient.users.get({ id: userId }) + const identities = response.data.identities + const gitHubIdentity = identities.find(e => { + return e.connection.toLowerCase() === "github" + }) + const usernamePasswordIdentity = identities.find(e => { + return e.connection.toLowerCase() === "username-password-authentication" + }) + if (gitHubIdentity) { + return UserIdentityProvider.GITHUB + } else if (usernamePasswordIdentity) { + return UserIdentityProvider.USERNAME_PASSWORD + } else { + throw new UnauthorizedError() + } + } +} diff --git a/src/features/auth/data/GitHubInstallationAccessTokenDataSource.ts b/src/features/auth/data/GitHubInstallationAccessTokenDataSource.ts new file mode 100644 index 00000000..7a958619 --- /dev/null +++ b/src/features/auth/data/GitHubInstallationAccessTokenDataSource.ts @@ -0,0 +1,50 @@ +import { Octokit } from "octokit" +import { createAppAuth } from "@octokit/auth-app" + +type GitHubInstallationAccessTokenRefresherConfig = { + readonly appId: string + readonly clientId: string + readonly clientSecret: string + readonly privateKey: string + readonly organization: string +} + +export default class GitHubInstallationAccessTokenRefresher { + private readonly config: GitHubInstallationAccessTokenRefresherConfig + + constructor(config: GitHubInstallationAccessTokenRefresherConfig) { + this.config = config + } + + async getAccessToken(repositoryNames: string[]): Promise { + const auth = createAppAuth({ + appId: this.config.appId, + clientId: this.config.clientId, + clientSecret: this.config.clientSecret, + privateKey: this.config.privateKey + }) + const appAuth = await auth({ type: "app" }) + const octokit = new Octokit({ auth: appAuth.token }) + const response = await octokit.rest.apps.getOrgInstallation({ + org: this.config.organization + }) + const installation = response.data + try { + const installationAuth = await auth({ + type: "installation", + installationId: installation.id, + repositoryNames: repositoryNames + }) + return installationAuth.token + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + } catch (error: any) { + if (error.status && error.status == 422) { + // One or more of the repositories do not exist. We log the error + // and create an access token with access to know repositories. + console.error("Cannot log in user as one or more repositories do not exist: " + repositoryNames.join(", ")) + console.error(error) + } + throw error + } + } +} diff --git a/src/features/auth/data/index.ts b/src/features/auth/data/index.ts index 91e3b14e..5db47a9f 100644 --- a/src/features/auth/data/index.ts +++ b/src/features/auth/data/index.ts @@ -1,2 +1,5 @@ export { default as Auth0RefreshTokenReader } from "./Auth0RefreshTokenReader" +export { default as Auth0RepositoryAccessReader } from "./Auth0RepositoryAccessReader" +export { default as Auth0UserIdentityProviderReader } from "./Auth0UserIdentityProviderReader" +export { default as GitHubInstallationAccessTokenDataSource } from "./GitHubInstallationAccessTokenDataSource" export { default as GitHubOAuthTokenRefresher } from "./GitHubOAuthTokenRefresher" diff --git a/src/features/auth/domain/accessToken/AccessTokenService.ts b/src/features/auth/domain/accessToken/AccessTokenService.ts index 729f40c3..095aef31 100644 --- a/src/features/auth/domain/accessToken/AccessTokenService.ts +++ b/src/features/auth/domain/accessToken/AccessTokenService.ts @@ -1,39 +1,42 @@ import IAccessTokenService from "./IAccessTokenService" -import IOAuthTokenRepository from "../oAuthToken/IOAuthTokenRepository" -import IOAuthTokenRefresher from "../oAuthToken/IOAuthTokenRefresher" -export interface IUserIDReader { - getUserId(): Promise +export interface IIsGuestReader { + getIsGuest(): Promise } -type AccessTokenServiceConfig = { - readonly userIdReader: IUserIDReader - readonly repository: IOAuthTokenRepository - readonly refresher: IOAuthTokenRefresher +interface AccessTokenServiceConfig { + readonly isGuestReader: IIsGuestReader + readonly guestAccessTokenService: IAccessTokenService + readonly hostAccessTokenService: IAccessTokenService } export default class AccessTokenService implements IAccessTokenService { - private readonly userIdReader: IUserIDReader - private readonly repository: IOAuthTokenRepository - private readonly refresher: IOAuthTokenRefresher + private readonly isGuestReader: IIsGuestReader + private readonly guestAccessTokenService: IAccessTokenService + private readonly hostAccessTokenService: IAccessTokenService constructor(config: AccessTokenServiceConfig) { - this.userIdReader = config.userIdReader - this.repository = config.repository - this.refresher = config.refresher + this.isGuestReader = config.isGuestReader + this.guestAccessTokenService = config.guestAccessTokenService + this.hostAccessTokenService = config.hostAccessTokenService } async getAccessToken(): Promise { - const userId = await this.userIdReader.getUserId() - const oAuthToken = await this.repository.get(userId) - return oAuthToken.accessToken + const service = await this.getService() + return await service.getAccessToken() } - 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 + async refreshAccessToken(accessToken: string): Promise { + const service = await this.getService() + return await service.refreshAccessToken(accessToken) } -} + + private async getService() { + const isGuest = await this.isGuestReader.getIsGuest() + if (isGuest) { + return this.guestAccessTokenService + } else { + return this.hostAccessTokenService + } + } +} \ No newline at end of file diff --git a/src/features/auth/domain/accessToken/GuestAccessTokenService.ts b/src/features/auth/domain/accessToken/GuestAccessTokenService.ts new file mode 100644 index 00000000..a30c26f3 --- /dev/null +++ b/src/features/auth/domain/accessToken/GuestAccessTokenService.ts @@ -0,0 +1,53 @@ +import IAccessTokenService from "./IAccessTokenService" + +export interface IUserIDReader { + getUserId(): Promise +} + +export interface Repository { + get(userId: string): Promise + set(userId: string, token: string): Promise +} + +export interface DataSource { + getAccessToken(userId: string): Promise +} + +export type GuestAccessTokenServiceConfig = { + readonly userIdReader: IUserIDReader + readonly repository: Repository + readonly dataSource: DataSource +} + +export default class GuestAccessTokenService implements IAccessTokenService { + private readonly userIdReader: IUserIDReader + private readonly repository: Repository + private readonly dataSource: DataSource + + constructor(config: GuestAccessTokenServiceConfig) { + this.userIdReader = config.userIdReader + this.repository = config.repository + this.dataSource = config.dataSource + } + + async getAccessToken(): Promise { + const userId = await this.userIdReader.getUserId() + const accessToken = await this.repository.get(userId) + if (!accessToken) { + // We fetch the access token for guests on demand. + return await this.getNewAccessToken() + } + return accessToken + } + + async refreshAccessToken(_accessToken: string): Promise { + return await this.getNewAccessToken() + } + + private async getNewAccessToken(): Promise { + const userId = await this.userIdReader.getUserId() + const newAccessToken = await this.dataSource.getAccessToken(userId) + await this.repository.set(userId, newAccessToken) + return newAccessToken + } +} \ No newline at end of file diff --git a/src/features/auth/domain/accessToken/HostAccessTokenService.ts b/src/features/auth/domain/accessToken/HostAccessTokenService.ts new file mode 100644 index 00000000..7b400082 --- /dev/null +++ b/src/features/auth/domain/accessToken/HostAccessTokenService.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 HostAccessTokenServiceConfig = { + readonly userIdReader: IUserIDReader + readonly repository: IOAuthTokenRepository + readonly refresher: IOAuthTokenRefresher +} + +export default class HostAccessTokenService implements IAccessTokenService { + private readonly userIdReader: IUserIDReader + private readonly repository: IOAuthTokenRepository + private readonly refresher: IOAuthTokenRefresher + + constructor(config: HostAccessTokenServiceConfig) { + 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 + } +} \ No newline at end of file diff --git a/src/features/auth/domain/accessToken/LockingAccessTokenService.ts b/src/features/auth/domain/accessToken/LockingAccessTokenService.ts index cda75ecf..5717bdcc 100644 --- a/src/features/auth/domain/accessToken/LockingAccessTokenService.ts +++ b/src/features/auth/domain/accessToken/LockingAccessTokenService.ts @@ -1,4 +1,4 @@ -import IMutexFactory from "@/common/mutex/IMutexFactory" +import { IMutexFactory } from "@/common" import IAccessTokenService from "./IAccessTokenService" import withMutex from "../../../../common/mutex/withMutex" diff --git a/src/features/auth/domain/accessToken/index.ts b/src/features/auth/domain/accessToken/index.ts index f23b4d05..fd3cb104 100644 --- a/src/features/auth/domain/accessToken/index.ts +++ b/src/features/auth/domain/accessToken/index.ts @@ -1,4 +1,5 @@ -export { default as AccessTokenService } from "./AccessTokenService" -export type { default as IAccessTokenService } from "./IAccessTokenService" +export { default as GuestAccessTokenService } from "./GuestAccessTokenService" +export { default as HostAccessTokenService } from "./HostAccessTokenService" export { default as LockingAccessTokenService } from "./LockingAccessTokenService" export { default as OnlyStaleRefreshingAccessTokenService } from "./OnlyStaleRefreshingAccessTokenService" +export { default as AccessTokenService } from "./AccessTokenService" diff --git a/src/features/auth/domain/credentialsTransfer/CredentialsTransferrer.ts b/src/features/auth/domain/credentialsTransfer/HostCredentialsTransferrer.ts similarity index 85% rename from src/features/auth/domain/credentialsTransfer/CredentialsTransferrer.ts rename to src/features/auth/domain/credentialsTransfer/HostCredentialsTransferrer.ts index c061a2cb..d66a0534 100644 --- a/src/features/auth/domain/credentialsTransfer/CredentialsTransferrer.ts +++ b/src/features/auth/domain/credentialsTransfer/HostCredentialsTransferrer.ts @@ -6,18 +6,18 @@ export interface IRefreshTokenReader { getRefreshToken(userId: string): Promise } -type CredentialsTransferrerConfig = { +type HostCredentialsTransferrerConfig = { readonly refreshTokenReader: IRefreshTokenReader readonly oAuthTokenRefresher: IOAuthTokenRefresher readonly oAuthTokenRepository: IOAuthTokenRepository } -export default class CredentialsTransferrer implements ICredentialsTransferrer { +export default class HostCredentialsTransferrer implements ICredentialsTransferrer { private readonly refreshTokenReader: IRefreshTokenReader private readonly oAuthTokenRefresher: IOAuthTokenRefresher private readonly oAuthTokenRepository: IOAuthTokenRepository - constructor(config: CredentialsTransferrerConfig) { + constructor(config: HostCredentialsTransferrerConfig) { this.refreshTokenReader = config.refreshTokenReader this.oAuthTokenRefresher = config.oAuthTokenRefresher this.oAuthTokenRepository = config.oAuthTokenRepository diff --git a/src/features/auth/domain/credentialsTransfer/NullObjectCredentialsTransferrer.ts b/src/features/auth/domain/credentialsTransfer/NullObjectCredentialsTransferrer.ts new file mode 100644 index 00000000..2b02e044 --- /dev/null +++ b/src/features/auth/domain/credentialsTransfer/NullObjectCredentialsTransferrer.ts @@ -0,0 +1,5 @@ +import ICredentialsTransferrer from "./ICredentialsTransferrer" + +export default class NullObjectCredentialsTransferrer implements ICredentialsTransferrer { + async transferCredentials(_userId: string): Promise {} +} diff --git a/src/features/auth/domain/credentialsTransfer/index.ts b/src/features/auth/domain/credentialsTransfer/index.ts index d59f555e..3961d607 100644 --- a/src/features/auth/domain/credentialsTransfer/index.ts +++ b/src/features/auth/domain/credentialsTransfer/index.ts @@ -1,2 +1,2 @@ -export { default as CredentialsTransferrer } from "./CredentialsTransferrer" -export type { default as ICredentialsTransferrer } from "./ICredentialsTransferrer" +export { default as HostCredentialsTransferrer } from "./HostCredentialsTransferrer" +export { default as NullObjectCredentialsTransferrer } from "./NullObjectCredentialsTransferrer" diff --git a/src/features/auth/domain/index.ts b/src/features/auth/domain/index.ts index bf537f9c..63cbe382 100644 --- a/src/features/auth/domain/index.ts +++ b/src/features/auth/domain/index.ts @@ -3,3 +3,5 @@ export * from "./credentialsTransfer" export * from "./logIn" export * from "./logOut" export * from "./oAuthToken" +export * from "./repositoryAccess" +export * from "./userIdentityProvider" diff --git a/src/features/auth/domain/logIn/CredentialsTransferringLogInHandler.ts b/src/features/auth/domain/logIn/CredentialsTransferringLogInHandler.ts index 618b2713..ae967eb5 100644 --- a/src/features/auth/domain/logIn/CredentialsTransferringLogInHandler.ts +++ b/src/features/auth/domain/logIn/CredentialsTransferringLogInHandler.ts @@ -1,18 +1,34 @@ import ICredentialsTransferrer from "../credentialsTransfer/ICredentialsTransferrer" +import IIsUserGuestReader from "../userIdentityProvider/IIsUserGuestReader" import ILogInHandler from "./ILogInHandler" export interface IRefreshTokenReader { getRefreshToken(userId: string): Promise } +type CredentialsTransferringLogInHandlerConfig = { + readonly isUserGuestReader: IIsUserGuestReader + readonly guestCredentialsTransferrer: ICredentialsTransferrer + readonly hostCredentialsTransferrer: ICredentialsTransferrer +} + export default class CredentialsTransferringLogInHandler implements ILogInHandler { - private readonly credentialsTransferrer: ICredentialsTransferrer + private readonly isUserGuestReader: IIsUserGuestReader + private readonly guestCredentialsTransferrer: ICredentialsTransferrer + private readonly hostCredentialsTransferrer: ICredentialsTransferrer - constructor(credentialsTransferrer: ICredentialsTransferrer) { - this.credentialsTransferrer = credentialsTransferrer + constructor(config: CredentialsTransferringLogInHandlerConfig) { + this.isUserGuestReader = config.isUserGuestReader + this.guestCredentialsTransferrer = config.guestCredentialsTransferrer + this.hostCredentialsTransferrer = config.hostCredentialsTransferrer } async handleLogIn(userId: string): Promise { - await this.credentialsTransferrer.transferCredentials(userId) + const isGuest = await this.isUserGuestReader.getIsUserGuest(userId) + if (isGuest) { + await this.guestCredentialsTransferrer.transferCredentials(userId) + } else { + await this.hostCredentialsTransferrer.transferCredentials(userId) + } } } diff --git a/src/features/auth/domain/repositoryAccess/CachingRepositoryAccessReaderConfig.ts b/src/features/auth/domain/repositoryAccess/CachingRepositoryAccessReaderConfig.ts new file mode 100644 index 00000000..15e4a717 --- /dev/null +++ b/src/features/auth/domain/repositoryAccess/CachingRepositoryAccessReaderConfig.ts @@ -0,0 +1,57 @@ +import { z } from "zod" +import { ZodJSONCoder, IUserDataRepository } from "../../../../common" + +export const RepositoryNamesContainerSchema = z.string().array() + +interface IRepositoryAccessReader { + getRepositoryNames(userId: string): Promise +} + +type CachingRepositoryAccessReaderConfig = { + readonly repository: IRepositoryNameRepository + readonly repositoryAccessReader: IRepositoryAccessReader +} + +type IRepositoryNameRepository = IUserDataRepository + +export default class CachingRepositoryAccessReader { + private readonly repository: IRepositoryNameRepository + private readonly repositoryAccessReader: IRepositoryAccessReader + + constructor(config: CachingRepositoryAccessReaderConfig) { + this.repository = config.repository + this.repositoryAccessReader = config.repositoryAccessReader + } + + async getRepositoryNames(userId: string): Promise { + const cachedValue = await this.getCachedRepositoryNames(userId) + if (cachedValue) { + return cachedValue + } + return await this.refreshRepositoryNames(userId) + } + + private async getCachedRepositoryNames(userId: string): Promise { + const str = await this.repository.get(userId) + if (!str) { + return null + } + try { + return ZodJSONCoder.decode(RepositoryNamesContainerSchema, str) + } catch (error: unknown) { + console.error(error) + return null + } + } + + private async refreshRepositoryNames(userId: string): Promise { + const repositoryNames = await this.repositoryAccessReader.getRepositoryNames(userId) + try { + const str = ZodJSONCoder.encode(RepositoryNamesContainerSchema, repositoryNames) + await this.repository.set(userId, str) + } catch (error: unknown) { + console.error(error) + } + return repositoryNames + } +} diff --git a/src/features/auth/domain/repositoryAccess/RepositoryRestrictingAccessTokenDataSource.ts b/src/features/auth/domain/repositoryAccess/RepositoryRestrictingAccessTokenDataSource.ts new file mode 100644 index 00000000..0bb4b4d2 --- /dev/null +++ b/src/features/auth/domain/repositoryAccess/RepositoryRestrictingAccessTokenDataSource.ts @@ -0,0 +1,27 @@ +interface IRepositoryAccessReader { + getRepositoryNames(userId: string): Promise +} + +interface IAccessTokenDataSource { + getAccessToken(repositoryNames: string[]): Promise +} + +type RepositoryRestrictingAccessTokenDataSourceConfig = { + readonly repositoryAccessReader: IRepositoryAccessReader + readonly dataSource: IAccessTokenDataSource +} + +export default class RepositoryRestrictingAccessTokenDataSource { + private readonly repositoryAccessReader: IRepositoryAccessReader + private readonly dataSource: IAccessTokenDataSource + + constructor(config: RepositoryRestrictingAccessTokenDataSourceConfig) { + this.repositoryAccessReader = config.repositoryAccessReader + this.dataSource = config.dataSource + } + + async getAccessToken(userId: string): Promise { + const repositoryNames = await this.repositoryAccessReader.getRepositoryNames(userId) + return await this.dataSource.getAccessToken(repositoryNames) + } +} diff --git a/src/features/auth/domain/repositoryAccess/index.ts b/src/features/auth/domain/repositoryAccess/index.ts new file mode 100644 index 00000000..3f3e42b7 --- /dev/null +++ b/src/features/auth/domain/repositoryAccess/index.ts @@ -0,0 +1,2 @@ +export { default as CachingRepositoryAccessReaderConfig } from "./CachingRepositoryAccessReaderConfig" +export { default as RepositoryRestrictingAccessTokenDataSource } from "./RepositoryRestrictingAccessTokenDataSource" diff --git a/src/features/auth/domain/userIdentityProvider/CachingUserIdentityProviderReader.ts b/src/features/auth/domain/userIdentityProvider/CachingUserIdentityProviderReader.ts new file mode 100644 index 00000000..9d1fff8c --- /dev/null +++ b/src/features/auth/domain/userIdentityProvider/CachingUserIdentityProviderReader.ts @@ -0,0 +1,26 @@ +import { IUserDataRepository } from "@/common" +import IUserIdentityProviderReader from "./IUserIdentityProviderReader" +import UserIdentityProvider from "./UserIdentityProvider" + +type Repository = IUserDataRepository + +export default class CachingUserIdentityProviderReader implements IUserIdentityProviderReader { + private readonly repository: Repository + private readonly reader: IUserIdentityProviderReader + + constructor(repository: Repository, reader: IUserIdentityProviderReader) { + this.repository = repository + this.reader = reader + } + + async getUserIdentityProvider(userId: string): Promise { + const cachedValue = await this.repository.get(userId) + if (cachedValue) { + return cachedValue as UserIdentityProvider + } else { + const userIdentity = await this.reader.getUserIdentityProvider(userId) + await this.repository.set(userId, userIdentity.toString()) + return userIdentity + } + } +} diff --git a/src/features/auth/domain/userIdentityProvider/IIsUserGuestReader.ts b/src/features/auth/domain/userIdentityProvider/IIsUserGuestReader.ts new file mode 100644 index 00000000..da34aa0d --- /dev/null +++ b/src/features/auth/domain/userIdentityProvider/IIsUserGuestReader.ts @@ -0,0 +1,3 @@ +export default interface IIsUserGuestReader { + getIsUserGuest(userId: string): Promise +} \ No newline at end of file diff --git a/src/features/auth/domain/userIdentityProvider/IUserIdentityProviderReader.ts b/src/features/auth/domain/userIdentityProvider/IUserIdentityProviderReader.ts new file mode 100644 index 00000000..7b70ffec --- /dev/null +++ b/src/features/auth/domain/userIdentityProvider/IUserIdentityProviderReader.ts @@ -0,0 +1,5 @@ +import UserIdentityProvider from "./UserIdentityProvider" + +export default interface IUserIdentityProviderReader { + getUserIdentityProvider(userId: string): Promise +} diff --git a/src/features/auth/domain/userIdentityProvider/IsUserGuestReader.ts b/src/features/auth/domain/userIdentityProvider/IsUserGuestReader.ts new file mode 100644 index 00000000..d2e00648 --- /dev/null +++ b/src/features/auth/domain/userIdentityProvider/IsUserGuestReader.ts @@ -0,0 +1,16 @@ +import IIsUserGuestReader from "./IIsUserGuestReader" +import IUserIdentityProviderReader from "./IUserIdentityProviderReader" +import UserIdentityProvider from "./UserIdentityProvider" + +export default class IsUserGuestReader implements IIsUserGuestReader { + private readonly userIdentityProviderReader: IUserIdentityProviderReader + + constructor(userIdentityProviderReader: IUserIdentityProviderReader) { + this.userIdentityProviderReader = userIdentityProviderReader + } + + async getIsUserGuest(userId: string): Promise { + const userIdentityProvider = await this.userIdentityProviderReader.getUserIdentityProvider(userId) + return userIdentityProvider == UserIdentityProvider.USERNAME_PASSWORD + } +} \ No newline at end of file diff --git a/src/features/auth/domain/userIdentityProvider/UserIdentityProvider.ts b/src/features/auth/domain/userIdentityProvider/UserIdentityProvider.ts new file mode 100644 index 00000000..394cafa4 --- /dev/null +++ b/src/features/auth/domain/userIdentityProvider/UserIdentityProvider.ts @@ -0,0 +1,6 @@ +enum UserIdentityProvider { + GITHUB = "github", + USERNAME_PASSWORD = "username_password" +} + +export default UserIdentityProvider diff --git a/src/features/auth/domain/userIdentityProvider/index.ts b/src/features/auth/domain/userIdentityProvider/index.ts new file mode 100644 index 00000000..74b56b57 --- /dev/null +++ b/src/features/auth/domain/userIdentityProvider/index.ts @@ -0,0 +1,3 @@ +export { default as CachingUserIdentityProviderReader } from "./CachingUserIdentityProviderReader" +export { default as IsUserGuestReader } from "./IsUserGuestReader" +export { default as UserIdentityProvider } from "./UserIdentityProvider" diff --git a/src/features/auth/view/SessionAccessTokenBarrier.tsx b/src/features/auth/view/SessionAccessTokenBarrier.tsx new file mode 100644 index 00000000..58996582 --- /dev/null +++ b/src/features/auth/view/SessionAccessTokenBarrier.tsx @@ -0,0 +1,19 @@ +import { ReactNode } from "react" +import { redirect } from "next/navigation" +import { session, accessTokenService } from "@/composition" + +export default async function SessionAccessTokenBarrier({ + children +}: { + children: ReactNode +}) { + try { + const isGuest = await session.getIsGuest() + if (!isGuest) { + await accessTokenService.getAccessToken() + } + return <>{children} + } catch { + redirect("/api/auth/logout") + } +} diff --git a/src/features/auth/view/SessionOAuthTokenBarrier.tsx b/src/features/auth/view/SessionOAuthTokenBarrier.tsx deleted file mode 100644 index 158ec381..00000000 --- a/src/features/auth/view/SessionOAuthTokenBarrier.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { ReactNode } from "react" -import { redirect } from "next/navigation" -import { session, oAuthTokenRepository } from "@/composition" - -export default async function SessionOAuthTokenBarrier({ - children -}: { - children: ReactNode -}) { - try { - const userId = await session.getUserId() - await oAuthTokenRepository.get(userId) - return <>{children} - } catch { - redirect("/api/auth/logout") - } -}