diff --git a/.github/workflows/invite-guest.yml b/.github/workflows/invite-guest.yml index e04d7347..b1e4904b 100644 --- a/.github/workflows/invite-guest.yml +++ b/.github/workflows/invite-guest.yml @@ -16,7 +16,7 @@ on: description: E-mail address to send invitation to required: true roles: - description: Comma-separated list of roles + description: Comma-separated list of repositories user needs access to required: true env: OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN_SHAPE_DOCS }} 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..8833cc64 --- /dev/null +++ b/__test__/auth/CachingRepositoryAccessReaderConfig.ts @@ -0,0 +1,94 @@ +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 setExpiring() {}, + 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 setExpiring() {}, + 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 setExpiring() {}, + 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 setExpiring() {}, + 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..0075f9a3 --- /dev/null +++ b/__test__/auth/CachingUserIdentityProviderReader.test.ts @@ -0,0 +1,63 @@ +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 setExpiring() {}, + 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 setExpiring() {}, + 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() {}, + async setExpiring(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/CompositeLogInHandler.test.ts b/__test__/auth/CompositeLogInHandler.test.ts new file mode 100644 index 00000000..c342ffbb --- /dev/null +++ b/__test__/auth/CompositeLogInHandler.test.ts @@ -0,0 +1,24 @@ +import { CompositeLogInHandler } from "../../src/features/auth/domain" + +test("It invokes all log in handlers for user", async () => { + let userId1: string | undefined + let userId2: string | undefined + let userId3: string | undefined + const sut = new CompositeLogInHandler([{ + async handleLogIn(userId) { + userId1 = userId + } + }, { + async handleLogIn(userId) { + userId2 = userId + } + }, { + async handleLogIn(userId) { + userId3 = userId + } + }]) + await sut.handleLogIn("1234") + expect(userId1).toEqual("1234") + expect(userId2).toEqual("1234") + expect(userId3).toEqual("1234") +}) 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..ceeceb4a --- /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 setExpiring() {} + }, + 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 setExpiring() {} + }, + 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/OAuthTokenRepository.test.ts b/__test__/auth/OAuthTokenRepository.test.ts index b5786016..2bcac8a0 100644 --- a/__test__/auth/OAuthTokenRepository.test.ts +++ b/__test__/auth/OAuthTokenRepository.test.ts @@ -11,6 +11,7 @@ test("It reads the auth token for the specified user", async () => { }) }, async set() {}, + async setExpiring() {}, async delete() {} }) await sut.get("1234") @@ -24,7 +25,8 @@ test("It stores the auth token for the specified user", async () => { async get() { return "" }, - async set(userId, data) { + async set() {}, + async setExpiring(userId, data) { storedUserId = userId storedJSON = data }, @@ -48,6 +50,7 @@ test("It deletes the auth token for the specified user", async () => { return "" }, async set() {}, + async setExpiring() {}, async delete(userId) { deletedUserId = userId } diff --git a/__test__/auth/RemoveInvitedFlagLogInHandler.test.ts b/__test__/auth/RemoveInvitedFlagLogInHandler.test.ts new file mode 100644 index 00000000..e4a276cd --- /dev/null +++ b/__test__/auth/RemoveInvitedFlagLogInHandler.test.ts @@ -0,0 +1,18 @@ +import { RemoveInvitedFlagLogInHandler } from "../../src/features/auth/domain" + +test("It removes invited flag from specified user", async () => { + let updatedUserId: string | undefined + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + let updatedMetadata: {[key: string]: any} | undefined + const sut = new RemoveInvitedFlagLogInHandler({ + async updateMetadata(userId, metadata) { + updatedUserId = userId + updatedMetadata = metadata + } + }) + await sut.handleLogIn("1234") + expect(updatedUserId).toEqual("1234") + expect(updatedMetadata).toEqual({ + has_pending_invitation: false + }) +}) 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/__test__/common/userData/KeyValueUserDataRepository.test.ts b/__test__/common/userData/KeyValueUserDataRepository.test.ts index 869403b4..3dc7cb04 100644 --- a/__test__/common/userData/KeyValueUserDataRepository.test.ts +++ b/__test__/common/userData/KeyValueUserDataRepository.test.ts @@ -8,6 +8,7 @@ test("It reads the expected key", async () => { return "" }, async set() {}, + async setExpiring() {}, async delete() {} }, "foo") await sut.get("123") @@ -23,12 +24,31 @@ test("It stores values under the expected key", async () => { async set(key) { storedKey = key }, + async setExpiring() {}, async delete() {} }, "foo") await sut.set("123", "bar") expect(storedKey).toBe("foo[123]") }) +test("It stores values under the expected key with expected time to live", async () => { + let storedKey: string | undefined + let storedTimeToLive: number | undefined + const sut = new KeyValueUserDataRepository({ + async get() { + return "" + }, + async set() {}, + async setExpiring(key, _value, timeToLive) { + storedKey = key + storedTimeToLive = timeToLive + }, + async delete() {} + }, "foo") + await sut.setExpiring("123", "bar", 24 * 3600) + expect(storedKey).toBe("foo[123]") + expect(storedTimeToLive).toBe(24 * 3600) +}) test("It deletes the expected key", async () => { let deletedKey: string | undefined @@ -37,6 +57,7 @@ test("It deletes the expected key", async () => { return "" }, async set() {}, + async setExpiring() {}, async delete(key) { deletedKey = key } 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/auth/[auth0]/route.ts b/src/app/api/auth/[auth0]/route.ts index aeaf8b83..2023b606 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.error(error) const url = new URL(SHAPE_DOCS_BASE_URL + "/api/auth/forceLogout") return NextResponse.redirect(url) } diff --git a/src/app/api/github/blob/[owner]/[repository]/[...path]/route.ts b/src/app/api/blob/[owner]/[repository]/[...path]/route.ts similarity index 79% rename from src/app/api/github/blob/[owner]/[repository]/[...path]/route.ts rename to src/app/api/blob/[owner]/[repository]/[...path]/route.ts index 9c8b595c..2dd2d532 100644 --- a/src/app/api/github/blob/[owner]/[repository]/[...path]/route.ts +++ b/src/app/api/blob/[owner]/[repository]/[...path]/route.ts @@ -17,17 +17,12 @@ export async function GET(req: NextRequest, { params }: { params: GetBlobParams }) const url = new URL(item.downloadURL) const imageRegex = /\.(jpg|jpeg|png|webp|avif|gif)$/; - + const file = await fetch(url).then(r => r.blob()) + const headers = new Headers() if (new RegExp(imageRegex).exec(path)) { - const file = await fetch(url).then(r => r.blob()); - const headers = new Headers(); const cacheExpirationInSeconds = 60 * 60 * 24 * 30 // 30 days - headers.set("Content-Type", "image/*"); - headers.set("Cache-Control", `max-age=${cacheExpirationInSeconds}`); - - return new NextResponse(file, { status: 200, headers }) - } else { - return NextResponse.redirect(url) + headers.set("Cache-Control", `max-age=${cacheExpirationInSeconds}`) } + return new NextResponse(file, { status: 200, headers }) } 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/layout.tsx b/src/app/layout.tsx index 1760b426..48ad9fee 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -3,8 +3,8 @@ import type { Metadata } from "next" import { UserProvider } from "@auth0/nextjs-auth0/client" import { config as fontAwesomeConfig } from "@fortawesome/fontawesome-svg-core" import { CssBaseline } from "@mui/material" -import ThemeRegistry from "@/common/theme/ThemeRegistry" -import ErrorHandler from "@/common/errors/client/ErrorHandler" +import ThemeRegistry from "../common/theme/ThemeRegistry" +import ErrorHandler from "../common/errors/client/ErrorHandler" import "@fortawesome/fontawesome-svg-core/styles.css" fontAwesomeConfig.autoAddCss = false 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/errors/client/ErrorHandler.tsx b/src/common/errors/client/ErrorHandler.tsx index 20ba19a3..61260578 100644 --- a/src/common/errors/client/ErrorHandler.tsx +++ b/src/common/errors/client/ErrorHandler.tsx @@ -1,7 +1,7 @@ "use client" import { SWRConfig } from "swr" -import { FetcherError } from "@/common/utils/fetcher" +import { FetcherError } from "@/common" export default function ErrorHandler({ children diff --git a/src/common/keyValueStore/IKeyValueStore.ts b/src/common/keyValueStore/IKeyValueStore.ts index 8b604f56..470b8558 100644 --- a/src/common/keyValueStore/IKeyValueStore.ts +++ b/src/common/keyValueStore/IKeyValueStore.ts @@ -1,5 +1,10 @@ export default interface IKeyValueStore { get(key: string): Promise set(key: string, data: string | number | Buffer): Promise + setExpiring( + key: string, + data: string | number | Buffer, + timeToLive: number + ): Promise delete(key: string): Promise } diff --git a/src/common/keyValueStore/RedisKeyValueStore.ts b/src/common/keyValueStore/RedisKeyValueStore.ts index 4a552154..8c4c5401 100644 --- a/src/common/keyValueStore/RedisKeyValueStore.ts +++ b/src/common/keyValueStore/RedisKeyValueStore.ts @@ -16,6 +16,14 @@ export default class RedisKeyValueStore implements IKeyValueStore { await this.redis.set(key, data) } + async setExpiring( + key: string, + data: string | number | Buffer, + timeToLive: number + ): Promise { + await this.redis.setex(key, timeToLive, data) + } + async delete(key: string): Promise { await this.redis.del(key) } diff --git a/src/common/mutex/SessionMutexFactory.ts b/src/common/mutex/SessionMutexFactory.ts index 72554464..c84b02de 100644 --- a/src/common/mutex/SessionMutexFactory.ts +++ b/src/common/mutex/SessionMutexFactory.ts @@ -1,7 +1,7 @@ import IKeyedMutexFactory from "./IKeyedMutexFactory" import IMutex from "./IMutex" import IMutexFactory from "./IMutexFactory" -import ISession from "@/common/session/ISession" +import { ISession } from "@/common" export default class SessionMutexFactory implements IMutexFactory { private readonly mutexFactory: IKeyedMutexFactory 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/common/userData/IUserDataRepository.ts b/src/common/userData/IUserDataRepository.ts index cefadae4..8fca5d8e 100644 --- a/src/common/userData/IUserDataRepository.ts +++ b/src/common/userData/IUserDataRepository.ts @@ -1,5 +1,6 @@ export default interface IUserDataRepository { get(userId: string): Promise set(userId: string, value: T): Promise + setExpiring(userId: string, value: T, timeToLive: number): Promise delete(userId: string): Promise } diff --git a/src/common/userData/KeyValueUserDataRepository.ts b/src/common/userData/KeyValueUserDataRepository.ts index 58bd4fa6..b6318099 100644 --- a/src/common/userData/KeyValueUserDataRepository.ts +++ b/src/common/userData/KeyValueUserDataRepository.ts @@ -18,6 +18,10 @@ export default class KeyValueUserDataRepository implements IUserDataRepository { + await this.store.setExpiring(this.getKey(userId), value, timeToLive) + } + async delete(userId: string): Promise { await this.store.delete(this.getKey(userId)) } diff --git a/src/composition.ts b/src/composition.ts index b0fc8bc5..46dce15b 100644 --- a/src/composition.ts +++ b/src/composition.ts @@ -1,37 +1,52 @@ 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, + ForgivingProjectDataSource, + ProjectRepository, + SessionValidatingProjectDataSource +} from "@/features/projects/domain" +import { + GitHubOAuthTokenRefresher, + GitHubInstallationAccessTokenDataSource, + Auth0MetadataUpdater, Auth0RefreshTokenReader, - GitHubOAuthTokenRefresher + Auth0RepositoryAccessReader, + Auth0UserIdentityProviderReader } from "@/features/auth/data" import { AccessTokenService, + CachingRepositoryAccessReaderConfig, + CachingUserIdentityProviderReader, + CompositeLogInHandler, CompositeLogOutHandler, - CredentialsTransferrer, CredentialsTransferringLogInHandler, ErrorIgnoringLogOutHandler, + GuestAccessTokenService, + NullObjectCredentialsTransferrer, + HostAccessTokenService, + HostCredentialsTransferrer, + IsUserGuestReader, LockingAccessTokenService, - OnlyStaleRefreshingAccessTokenService, OAuthTokenRepository, + OnlyStaleRefreshingAccessTokenService, + RemoveInvitedFlagLogInHandler, + 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 +60,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 +} + +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 userIdentityProviderReader = new CachingUserIdentityProviderReader( + userIdentityProviderRepository, + new Auth0UserIdentityProviderReader(auth0ManagementCredentials) +) -export const session = new Auth0Session() +export const session = new Auth0Session({ + isUserGuestReader: new IsUserGuestReader(userIdentityProviderReader) +}) -export const oAuthTokenRepository = new OAuthTokenRepository( +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 +132,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 +157,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), @@ -105,33 +179,43 @@ export const projectRepository = new ProjectRepository( export const projectDataSource = new CachingProjectDataSource( new SessionValidatingProjectDataSource( sessionValidator, - new GitHubProjectDataSource( - userGitHubClient, - GITHUB_ORGANIZATION_NAME - ) + new ForgivingProjectDataSource({ + accessTokenReader: accessTokenService, + projectDataSource: new GitHubProjectDataSource( + userGitHubClient, + GITHUB_ORGANIZATION_NAME + ) + }) ), projectRepository ) -export const logInHandler = new CredentialsTransferringLogInHandler( - new CredentialsTransferrer({ - refreshTokenReader: new Auth0RefreshTokenReader({ - domain: AUTH0_MANAGEMENT_DOMAIN, - clientId: AUTH0_MANAGEMENT_CLIENT_ID, - clientSecret: AUTH0_MANAGEMENT_CLIENT_SECRET, - connection: "github" - }), - oAuthTokenRefresher: new GitHubOAuthTokenRefresher({ - clientId: GITHUB_CLIENT_ID, - clientSecret: GITHUB_CLIENT_SECRET - }), - oAuthTokenRepository: oAuthTokenRepository - }) -) +export const logInHandler = new CompositeLogInHandler([ + new CredentialsTransferringLogInHandler({ + isUserGuestReader: new IsUserGuestReader( + userIdentityProviderReader + ), + guestCredentialsTransferrer: new NullObjectCredentialsTransferrer(), + hostCredentialsTransferrer: new HostCredentialsTransferrer({ + refreshTokenReader: new Auth0RefreshTokenReader({ + ...auth0ManagementCredentials, + connection: "github" + }), + oAuthTokenRefresher: gitHubOAuthTokenRefresher, + oAuthTokenRepository: oAuthTokenRepository + }) + }), + new RemoveInvitedFlagLogInHandler( + new Auth0MetadataUpdater({ ...auth0ManagementCredentials }) + ) +]) 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/Auth0MetadataUpdater.ts b/src/features/auth/data/Auth0MetadataUpdater.ts new file mode 100644 index 00000000..88ffb524 --- /dev/null +++ b/src/features/auth/data/Auth0MetadataUpdater.ts @@ -0,0 +1,28 @@ +import { ManagementClient } from "auth0" + +type Auth0MetadataUpdaterConfig = { + readonly domain: string + readonly clientId: string + readonly clientSecret: string +} + +export default class Auth0MetadataUpdater { + private readonly managementClient: ManagementClient + + constructor(config: Auth0MetadataUpdaterConfig) { + this.managementClient = new ManagementClient({ + domain: config.domain, + clientId: config.clientId, + clientSecret: config.clientSecret + }) + } + + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + async updateMetadata(userId: string, metadata: {[key: string]: any}): Promise { + await this.managementClient.users.update({ + id: userId + }, { + app_metadata: metadata + }) + } +} 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..8090466b 100644 --- a/src/features/auth/data/index.ts +++ b/src/features/auth/data/index.ts @@ -1,2 +1,6 @@ +export { default as Auth0MetadataUpdater } from "./Auth0MetadataUpdater" 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..d2e1a059 --- /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 + setExpiring(userId: string, token: string, timeToLive: number): 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.setExpiring(userId, newAccessToken, 7 * 24 * 3600) + 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/CompositeLogInHandler.ts b/src/features/auth/domain/logIn/CompositeLogInHandler.ts new file mode 100644 index 00000000..169be4c2 --- /dev/null +++ b/src/features/auth/domain/logIn/CompositeLogInHandler.ts @@ -0,0 +1,14 @@ +import ILogInHandler from "./ILogInHandler" + +export default class CompositeLogInHandler implements ILogInHandler { + private readonly handlers: ILogInHandler[] + + constructor(handlers: ILogInHandler[]) { + this.handlers = handlers + } + + async handleLogIn(userId: string): Promise { + const promises = this.handlers.map(e => e.handleLogIn(userId)) + await Promise.all(promises) + } +} 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/logIn/RemoveInvitedFlagLogInHandler.ts b/src/features/auth/domain/logIn/RemoveInvitedFlagLogInHandler.ts new file mode 100644 index 00000000..c1565e9d --- /dev/null +++ b/src/features/auth/domain/logIn/RemoveInvitedFlagLogInHandler.ts @@ -0,0 +1,20 @@ +import ILogInHandler from "./ILogInHandler" + +export interface IMetadataUpdater { + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + updateMetadata(userId: string, metadata: {[key: string]: any}): Promise +} + +export default class RemoveInvitedFlagLogInHandler implements ILogInHandler { + private readonly metadataUpdater: IMetadataUpdater + + constructor(metadataUpdater: IMetadataUpdater) { + this.metadataUpdater = metadataUpdater + } + + async handleLogIn(userId: string): Promise { + await this.metadataUpdater.updateMetadata(userId, { + has_pending_invitation: false + }) + } +} diff --git a/src/features/auth/domain/logIn/index.ts b/src/features/auth/domain/logIn/index.ts index 47d8d206..ec771fa7 100644 --- a/src/features/auth/domain/logIn/index.ts +++ b/src/features/auth/domain/logIn/index.ts @@ -1,2 +1,4 @@ +export { default as CompositeLogInHandler } from "./CompositeLogInHandler" export { default as CredentialsTransferringLogInHandler } from "./CredentialsTransferringLogInHandler" export type { default as ILogInHandler } from "./ILogInHandler" +export { default as RemoveInvitedFlagLogInHandler } from "./RemoveInvitedFlagLogInHandler" diff --git a/src/features/auth/domain/oAuthToken/OAuthTokenRepository.ts b/src/features/auth/domain/oAuthToken/OAuthTokenRepository.ts index a28bc29a..678f83bd 100644 --- a/src/features/auth/domain/oAuthToken/OAuthTokenRepository.ts +++ b/src/features/auth/domain/oAuthToken/OAuthTokenRepository.ts @@ -1,6 +1,4 @@ -import ZodJSONCoder from "../../../../common/utils/ZodJSONCoder" -import IUserDataRepository from "@/common/userData/IUserDataRepository" -import { UnauthorizedError } from "../../../../common/errors" +import { IUserDataRepository, UnauthorizedError, ZodJSONCoder } from "../../../../common" import IOAuthTokenRepository from "./IOAuthTokenRepository" import OAuthToken, { OAuthTokenSchema } from "./OAuthToken" @@ -21,7 +19,7 @@ export default class OAuthTokenRepository implements IOAuthTokenRepository { async set(userId: string, token: OAuthToken): Promise { const string = ZodJSONCoder.encode(OAuthTokenSchema, token) - await this.repository.set(userId, string) + await this.repository.setExpiring(userId, string, 6 * 30 * 24 * 3600) } async delete(userId: string): Promise { diff --git a/src/features/auth/domain/repositoryAccess/CachingRepositoryAccessReaderConfig.ts b/src/features/auth/domain/repositoryAccess/CachingRepositoryAccessReaderConfig.ts new file mode 100644 index 00000000..02405b43 --- /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.setExpiring(userId, str, 7 * 24 * 3600) + } 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..86456036 --- /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.setExpiring(userId, userIdentity.toString(), 7 * 24 * 3600) + 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") - } -} diff --git a/src/features/hooks/data/GitHubPullRequestCommentRepository.ts b/src/features/hooks/data/GitHubPullRequestCommentRepository.ts index 59b60d62..3e074d59 100644 --- a/src/features/hooks/data/GitHubPullRequestCommentRepository.ts +++ b/src/features/hooks/data/GitHubPullRequestCommentRepository.ts @@ -1,4 +1,4 @@ -import IGitHubClient from "@/common/github/IGitHubClient" +import { IGitHubClient } from "@/common" import IPullRequestCommentRepository, { GetPullRequestCommentsOperation, AddPullRequestCommentOperation, diff --git a/src/features/projects/data/GitHubProjectDataSource.ts b/src/features/projects/data/GitHubProjectDataSource.ts index a66a6f27..e055b4e3 100644 --- a/src/features/projects/data/GitHubProjectDataSource.ts +++ b/src/features/projects/data/GitHubProjectDataSource.ts @@ -1,4 +1,4 @@ -import IGitHubClient from "@/common/github/IGitHubClient" +import { IGitHubClient } from "@/common" import { Project, IProjectConfig, @@ -238,6 +238,6 @@ export default class GitHubProjectDataSource implements IProjectDataSource { } private getGitHubBlobURL(owner: string, repository: string, path: string, ref: string): string { - return `/api/github/blob/${owner}/${repository}/${path}?ref=${ref}` + return `/api/blob/${owner}/${repository}/${path}?ref=${ref}` } } diff --git a/src/features/projects/domain/ForgivingProjectDataSource.ts b/src/features/projects/domain/ForgivingProjectDataSource.ts new file mode 100644 index 00000000..82f5c40f --- /dev/null +++ b/src/features/projects/domain/ForgivingProjectDataSource.ts @@ -0,0 +1,33 @@ +import Project from "./Project" +import IProjectDataSource from "./IProjectDataSource" + +interface IAccessTokenReader { + getAccessToken(): Promise +} + +type ForgivingProjectDataSourceConfig = { + readonly accessTokenReader: IAccessTokenReader + readonly projectDataSource: IProjectDataSource +} + +export default class ForgivingProjectDataSource implements IProjectDataSource { + private readonly accessTokenReader: IAccessTokenReader + private readonly projectDataSource: IProjectDataSource + + constructor(config: ForgivingProjectDataSourceConfig) { + this.accessTokenReader = config.accessTokenReader + this.projectDataSource = config.projectDataSource + } + + async getProjects(): Promise { + try { + await this.accessTokenReader.getAccessToken() + } catch { + // If we cannot get an access token, we show an empty list of projects. + // It is common for guest users that we cannot get an access token because they + // have been incorrectly configured to have access to non-existing repositories. + return [] + } + return this.projectDataSource.getProjects() + } +} diff --git a/src/features/projects/domain/ProjectRepository.ts b/src/features/projects/domain/ProjectRepository.ts index 1fb96b0c..c55e9341 100644 --- a/src/features/projects/domain/ProjectRepository.ts +++ b/src/features/projects/domain/ProjectRepository.ts @@ -1,20 +1,24 @@ -import { ZodJSONCoder, ISession, IUserDataRepository } from "../../../common" +import { IUserDataRepository, ZodJSONCoder } from "../../../common" import IProjectRepository from "./IProjectRepository" import Project, { ProjectSchema } from "./Project" +interface IUserIDReader { + getUserId(): Promise +} + type Repository = IUserDataRepository export default class ProjectRepository implements IProjectRepository { - private readonly session: ISession + private readonly userIDReader: IUserIDReader private readonly repository: Repository - constructor(session: ISession, repository: Repository) { - this.session = session + constructor(userIDReader: IUserIDReader, repository: Repository) { + this.userIDReader = userIDReader this.repository = repository } async get(): Promise { - const userId = await this.session.getUserId() + const userId = await this.userIDReader.getUserId() const string = await this.repository.get(userId) if (!string) { return undefined @@ -23,13 +27,13 @@ export default class ProjectRepository implements IProjectRepository { } async set(projects: Project[]): Promise { - const userId = await this.session.getUserId() + const userId = await this.userIDReader.getUserId() const string = ZodJSONCoder.encode(ProjectSchema.array(), projects) - await this.repository.set(userId, string) + await this.repository.setExpiring(userId, string, 30 * 24 * 3600) } async delete(): Promise { - const userId = await this.session.getUserId() + const userId = await this.userIDReader.getUserId() await this.repository.delete(userId) } } diff --git a/src/features/projects/domain/index.ts b/src/features/projects/domain/index.ts index c70203d7..f770e7c7 100644 --- a/src/features/projects/domain/index.ts +++ b/src/features/projects/domain/index.ts @@ -1,4 +1,5 @@ export { default as CachingProjectDataSource } from "./CachingProjectDataSource" +export { default as ForgivingProjectDataSource } from "./ForgivingProjectDataSource" export { default as getSelection } from "./getSelection" export type { default as IProjectConfig } from "./IProjectConfig" export type { default as IProjectDataSource } from "./IProjectDataSource" diff --git a/src/features/projects/view/ProjectsPage.tsx b/src/features/projects/view/ProjectsPage.tsx index d01941f6..5e5118d4 100644 --- a/src/features/projects/view/ProjectsPage.tsx +++ b/src/features/projects/view/ProjectsPage.tsx @@ -1,3 +1,4 @@ +import { session } from "@/composition" import { ProjectRepository } from "../domain" import ClientProjectsPage from "./client/ProjectsPage" @@ -12,9 +13,11 @@ export default async function ProjectsPage({ versionId?: string specificationId?: string }) { + const isGuest = await session.getIsGuest() const projects = await projectRepository.get() return ( - + / - {specification.editURL && + {enableGitHubLinks && specification.editURL && + {text} )