diff --git a/__test__/auth/CachingRepositoryAccessReaderConfig.ts b/__test__/auth/CachingRepositoryAccessReaderConfig.test.ts similarity index 85% rename from __test__/auth/CachingRepositoryAccessReaderConfig.ts rename to __test__/auth/CachingRepositoryAccessReaderConfig.test.ts index 8833cc64..f4b4f2a1 100644 --- a/__test__/auth/CachingRepositoryAccessReaderConfig.ts +++ b/__test__/auth/CachingRepositoryAccessReaderConfig.test.ts @@ -1,9 +1,9 @@ -import { CachingRepositoryAccessReaderConfig } from "../../src/features/auth/domain" +import { CachingRepositoryAccessReader } 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({ + const sut = new CachingRepositoryAccessReader({ repository: { async get() { return null @@ -27,7 +27,7 @@ test("It fetches repository names for user if they are not cached", async () => test("It does not fetch repository names if they are cached", async () => { let didFetchRepositoryNames = false - const sut = new CachingRepositoryAccessReaderConfig({ + const sut = new CachingRepositoryAccessReader({ repository: { async get() { return "[\"foo\"]" @@ -50,16 +50,16 @@ test("It does not fetch repository names if they are cached", async () => { test("It caches fetched repository names for user", async () => { let cachedUserId: string | undefined let cachedRepositoryNames: string | undefined - const sut = new CachingRepositoryAccessReaderConfig({ + const sut = new CachingRepositoryAccessReader({ repository: { async get() { return null }, - async set(userId, value) { + async set() {}, + async setExpiring(userId: string, value: string) { cachedUserId = userId cachedRepositoryNames = value }, - async setExpiring() {}, async delete() {} }, repositoryAccessReader: { @@ -74,7 +74,7 @@ test("It caches fetched repository names for user", async () => { }) test("It decodes cached repository names", async () => { - const sut = new CachingRepositoryAccessReaderConfig({ + const sut = new CachingRepositoryAccessReader({ repository: { async get() { return "[\"foo\",\"bar\"]" diff --git a/__test__/auth/GitHubOrganizationSessionValidator.test.ts b/__test__/auth/GitHubOrganizationSessionValidator.test.ts new file mode 100644 index 00000000..9287ae6c --- /dev/null +++ b/__test__/auth/GitHubOrganizationSessionValidator.test.ts @@ -0,0 +1,83 @@ +import { + GitHubOrganizationSessionValidator, + SessionValidity + } from "../../src/features/auth/domain" + +test("It requests organization membership status for the specified organization", async () => { + let queriedOrganizationName: string | undefined + const sut = new GitHubOrganizationSessionValidator({ + acceptedOrganization: "foo", + organizationMembershipStatusReader: { + async getOrganizationMembershipStatus(request) { + queriedOrganizationName = request.organizationName + return { state: "active" } + } + } + }) + await sut.validateSession() + expect(queriedOrganizationName).toBe("foo") +}) + +test("It considers session valid when membership state is \"active\"", async () => { + const sut = new GitHubOrganizationSessionValidator({ + acceptedOrganization: "foo", + organizationMembershipStatusReader: { + async getOrganizationMembershipStatus() { + return { state: "active" } + } + } + }) + const sessionValidity = await sut.validateSession() + expect(sessionValidity).toEqual(SessionValidity.VALID) +}) + +test("It considers user not to be part of the organization when membership state is \"pending\"", async () => { + const sut = new GitHubOrganizationSessionValidator({ + acceptedOrganization: "foo", + organizationMembershipStatusReader: { + async getOrganizationMembershipStatus() { + return { state: "pending" } + } + } + }) + const sessionValidity = await sut.validateSession() + expect(sessionValidity).toEqual(SessionValidity.OUTSIDE_GITHUB_ORGANIZATION) +}) + +test("It considers user not to be part of the organization when receiving HTTP 404", async () => { + const sut = new GitHubOrganizationSessionValidator({ + acceptedOrganization: "foo", + organizationMembershipStatusReader: { + async getOrganizationMembershipStatus() { + throw { status: 404, message: "User is not member of organization"} + } + } + }) + const sessionValidity = await sut.validateSession() + expect(sessionValidity).toEqual(SessionValidity.OUTSIDE_GITHUB_ORGANIZATION) +}) + +test("It considers organization to have blocked the GitHub app when receiving HTTP 403", async () => { + const sut = new GitHubOrganizationSessionValidator({ + acceptedOrganization: "foo", + organizationMembershipStatusReader: { + async getOrganizationMembershipStatus() { + throw { status: 403, message: "Organization has blocked GitHub app"} + } + } + }) + const sessionValidity = await sut.validateSession() + expect(sessionValidity).toEqual(SessionValidity.GITHUB_APP_BLOCKED) +}) + +test("It forwards error when getting membership status throws unknown error", async () => { + const sut = new GitHubOrganizationSessionValidator({ + acceptedOrganization: "foo", + organizationMembershipStatusReader: { + async getOrganizationMembershipStatus() { + throw { status: 500 } + } + } + }) + await expect(sut.validateSession()).rejects.toEqual({ status: 500 }) +}) diff --git a/__test__/auth/GuestAccessTokenRepository.test.ts b/__test__/auth/GuestAccessTokenRepository.test.ts new file mode 100644 index 00000000..5c089cd3 --- /dev/null +++ b/__test__/auth/GuestAccessTokenRepository.test.ts @@ -0,0 +1,52 @@ +import { GuestAccessTokenRepository } from "../../src/features/auth/domain" + +test("It reads access token for user", async () => { + let readUserId: string | undefined + const sut = new GuestAccessTokenRepository({ + async get(userId) { + readUserId = userId + return "foo" + }, + async setExpiring() {}, + async delete() {} + }) + const accessToken = await sut.get("1234") + expect(readUserId).toBe("1234") + expect(accessToken).toBe("foo") +}) + +test("It stores access token for user", async () => { + let storedUserId: string | undefined + let storedToken: string | undefined + let storedTimeToLive: number | undefined + const sut = new GuestAccessTokenRepository({ + async get() { + return "foo" + }, + async setExpiring(userId, token, timeToLive) { + storedUserId = userId + storedToken = token + storedTimeToLive = timeToLive + }, + async delete(userId) {} + }) + await sut.set("1234", "bar") + expect(storedUserId).toBe("1234") + expect(storedToken).toBe("bar") + expect(storedTimeToLive).toBeGreaterThan(0) +}) + +test("It deletes access token for user", async () => { + let deletedUserId: string | undefined + const sut = new GuestAccessTokenRepository({ + async get() { + return "foo" + }, + async setExpiring() {}, + async delete(userId) { + deletedUserId = userId + } + }) + await sut.delete("1234") + expect(deletedUserId).toBe("1234") +}) diff --git a/__test__/auth/GuestAccessTokenService.test.ts b/__test__/auth/GuestAccessTokenService.test.ts index ceeceb4a..407ac26e 100644 --- a/__test__/auth/GuestAccessTokenService.test.ts +++ b/__test__/auth/GuestAccessTokenService.test.ts @@ -13,7 +13,7 @@ test("It gets the access token for the user", async () => { readUserId = userId return "foo" }, - async setExpiring() {} + async set() {} }, dataSource: { async getAccessToken() { @@ -25,28 +25,3 @@ test("It gets the access token for the user", async () => { 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/HostOnlySessionValidator.test.ts b/__test__/auth/HostOnlySessionValidator.test.ts new file mode 100644 index 00000000..daf60611 --- /dev/null +++ b/__test__/auth/HostOnlySessionValidator.test.ts @@ -0,0 +1,43 @@ +import { + HostOnlySessionValidator, + SessionValidity +} from "../../src/features/auth/domain" + +test("It validates session when user is host", async () => { + let didValidateSession = false + const sut = new HostOnlySessionValidator({ + isGuestReader: { + async getIsGuest() { + return false + } + }, + sessionValidator: { + async validateSession() { + didValidateSession = true + return SessionValidity.VALID + }, + } + }) + await sut.validateSession() + expect(didValidateSession).toBeTruthy() +}) + + +test("It does not validate session when user is guest", async () => { + let didValidateSession = false + const sut = new HostOnlySessionValidator({ + isGuestReader: { + async getIsGuest() { + return true + } + }, + sessionValidator: { + async validateSession() { + didValidateSession = true + return SessionValidity.VALID + }, + } + }) + await sut.validateSession() + expect(didValidateSession).toBeFalsy() +}) diff --git a/__test__/auth/RepositoryRestrictingAccessTokenDataSource.ts b/__test__/auth/RepositoryRestrictingAccessTokenDataSource.test.ts similarity index 100% rename from __test__/auth/RepositoryRestrictingAccessTokenDataSource.ts rename to __test__/auth/RepositoryRestrictingAccessTokenDataSource.test.ts diff --git a/__test__/auth/SessionValidity.test.ts b/__test__/auth/SessionValidity.test.ts new file mode 100644 index 00000000..c411f973 --- /dev/null +++ b/__test__/auth/SessionValidity.test.ts @@ -0,0 +1,28 @@ +import { + mergeSessionValidity, + SessionValidity +} from "../../src/features/auth/domain" + +test("It returns invalid validity when left-hand side validity indicates that the session is invalid", async () => { + const sut = mergeSessionValidity( + SessionValidity.INVALID_ACCESS_TOKEN, + SessionValidity.VALID + ) + expect(sut).toEqual(SessionValidity.INVALID_ACCESS_TOKEN) +}) + +test("It returns invalid validity when right-hand side validity indicates that the session is invalid", async () => { + const sut = mergeSessionValidity( + SessionValidity.VALID, + SessionValidity.INVALID_ACCESS_TOKEN + ) + expect(sut).toEqual(SessionValidity.INVALID_ACCESS_TOKEN) +}) + +test("It returns valid validity when both validities indicate that the session is valid", async () => { + const sut = mergeSessionValidity( + SessionValidity.VALID, + SessionValidity.VALID + ) + expect(sut).toEqual(SessionValidity.VALID) +}) diff --git a/__test__/common/session/GitHubOrganizationSessionValidator.test.ts b/__test__/common/session/GitHubOrganizationSessionValidator.test.ts deleted file mode 100644 index 5e530047..00000000 --- a/__test__/common/session/GitHubOrganizationSessionValidator.test.ts +++ /dev/null @@ -1,143 +0,0 @@ -import { - GetOrganizationMembershipStatusRequest -} from "../../../src/common/github/IGitHubClient" -import { GitHubOrganizationSessionValidator } from "../../../src/common" - -test("It requests organization membership status for the specified organization", async () => { - let queriedOrganizationName: string | undefined - const sut = new GitHubOrganizationSessionValidator( - { - async graphql() { - return {} - }, - async getRepositoryContent() { - return { downloadURL: "https://example.com" } - }, - async getPullRequestComments() { - return [] - }, - async addCommentToPullRequest() {}, - async getOrganizationMembershipStatus(request: GetOrganizationMembershipStatusRequest) { - queriedOrganizationName = request.organizationName - return { state: "active" } - } - }, - "foo" - ) - await sut.validateSession() - expect(queriedOrganizationName).toBe("foo") -}) - -test("It considers session valid when membership state is \"active\"", async () => { - const sut = new GitHubOrganizationSessionValidator( - { - async graphql() { - return {} - }, - async getRepositoryContent() { - return { downloadURL: "https://example.com" } - }, - async getPullRequestComments() { - return [] - }, - async addCommentToPullRequest() {}, - async getOrganizationMembershipStatus() { - return { state: "active" } - } - }, - "foo" - ) - const isSessionValid = await sut.validateSession() - expect(isSessionValid).toBeTruthy() -}) - -test("It considers session invalid when membership state is \"pending\"", async () => { - const sut = new GitHubOrganizationSessionValidator( - { - async graphql() { - return {} - }, - async getRepositoryContent() { - return { downloadURL: "https://example.com" } - }, - async getPullRequestComments() { - return [] - }, - async addCommentToPullRequest() {}, - async getOrganizationMembershipStatus() { - return { state: "pending" } - } - }, - "foo" - ) - const isSessionValid = await sut.validateSession() - expect(isSessionValid).toBeFalsy() -}) - -test("It considers session invalid when receiving HTTP 404, indicating user is not member of the organization", async () => { - const sut = new GitHubOrganizationSessionValidator( - { - async graphql() { - return {} - }, - async getRepositoryContent() { - return { downloadURL: "https://example.com" } - }, - async getPullRequestComments() { - return [] - }, - async addCommentToPullRequest() {}, - async getOrganizationMembershipStatus() { - throw { status: 404, message: "User is not member of organization"} - } - }, - "foo" - ) - const isSessionValid = await sut.validateSession() - expect(isSessionValid).toBeFalsy() -}) - -test("It considers session invalid when receiving HTTP 404, indicating that the organization has blocked the GitHub app", async () => { - const sut = new GitHubOrganizationSessionValidator( - { - async graphql() { - return {} - }, - async getRepositoryContent() { - return { downloadURL: "https://example.com" } - }, - async getPullRequestComments() { - return [] - }, - async addCommentToPullRequest() {}, - async getOrganizationMembershipStatus() { - throw { status: 403, message: "Organization has blocked GitHub app"} - } - }, - "foo" - ) - const isSessionValid = await sut.validateSession() - expect(isSessionValid).toBeFalsy() -}) - -test("It forwards error when getting membership status throws unknown error", async () => { - const sut = new GitHubOrganizationSessionValidator( - { - async graphql() { - return {} - }, - async getRepositoryContent() { - return { downloadURL: "https://example.com" } - }, - async getPullRequestComments() { - return [] - }, - async addCommentToPullRequest() {}, - async getOrganizationMembershipStatus() { - throw { status: 500 } - } - }, - "foo" - ) - await expect(sut.validateSession()).rejects.toEqual({ status: 500 }) -}) \ No newline at end of file diff --git a/__test__/common/session/SessionValidator.test.ts b/__test__/common/session/SessionValidator.test.ts deleted file mode 100644 index 057bd6cd..00000000 --- a/__test__/common/session/SessionValidator.test.ts +++ /dev/null @@ -1,49 +0,0 @@ -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__/projects/SessionValidatingProjectDataSource.test.ts b/__test__/projects/SessionValidatingProjectDataSource.test.ts deleted file mode 100644 index 7050cb78..00000000 --- a/__test__/projects/SessionValidatingProjectDataSource.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { SessionValidatingProjectDataSource } from "../../src/features/projects/domain" - -test("It validates the session", async () => { - let didValidateSession = false - const sut = new SessionValidatingProjectDataSource({ - async validateSession() { - didValidateSession = true - return true - }, - }, { - async getProjects() { - return [] - } - }) - await sut.getProjects() - expect(didValidateSession).toBeTruthy() -}) - -test("It fetches projects when session is valid", async () => { - let didFetchProjects = false - const sut = new SessionValidatingProjectDataSource({ - async validateSession() { - return true - }, - }, { - async getProjects() { - didFetchProjects = true - return [] - } - }) - await sut.getProjects() - expect(didFetchProjects).toBeTruthy() -}) - -test("It throws error when session is invalid", async () => { - const sut = new SessionValidatingProjectDataSource({ - async validateSession() { - return false - }, - }, { - async getProjects() { - return [] - } - }) - expect(sut.getProjects()).rejects.toThrowError() -}) diff --git a/src/app/[...slug]/page.tsx b/src/app/[...slug]/page.tsx index cfcb585e..984a5cc9 100644 --- a/src/app/[...slug]/page.tsx +++ b/src/app/[...slug]/page.tsx @@ -1,5 +1,5 @@ import { getProjectId, getSpecificationId, getVersionId } from "../../common" -import SessionAccessTokenBarrier from "@/features/auth/view/SessionAccessTokenBarrier" +import SessionBarrier from "@/features/auth/view/SessionBarrier" 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 64a96f68..8adfab66 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 { makeAPIErrorResponse, UnauthorizedError } from "../../../../common" import { projectDataSource } from "@/composition" -import { UnauthorizedError, InvalidSessionError } from "../../../../common" export async function GET() { try { @@ -8,17 +8,11 @@ export async function GET() { return NextResponse.json({projects}) } catch (error) { if (error instanceof UnauthorizedError) { - return errorResponse(401, error.message) - } else if (error instanceof InvalidSessionError) { - return errorResponse(403, error.message) + return makeAPIErrorResponse(401, error.message) } else if (error instanceof Error) { - return errorResponse(500, error.message) + return makeAPIErrorResponse(500, error.message) } else { - return errorResponse(500, "Unknown error") + return makeAPIErrorResponse(500, "Unknown error") } } -} - -function errorResponse(status: number, message: string): NextResponse { - return NextResponse.json({ status, message }, { status }) -} +} \ No newline at end of file diff --git a/src/app/api/user/repository-access/route.ts b/src/app/api/user/repository-access/route.ts new file mode 100644 index 00000000..697d4ee0 --- /dev/null +++ b/src/app/api/user/repository-access/route.ts @@ -0,0 +1,23 @@ +import { NextResponse } from "next/server" +import { makeAPIErrorResponse } from "@/common" +import { session, guestRepositoryAccessReader } from "@/composition" + +export async function GET() { + let userId: string + try { + userId = await session.getUserId() + } catch { + return makeAPIErrorResponse(401, "Unauthorized") + } + try { + const repositoryNames = await guestRepositoryAccessReader.getRepositoryNames(userId) + return NextResponse.json({repositories: repositoryNames}) + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + } catch (error: any) { + if (error.message) { + return makeAPIErrorResponse(500, error.message) + } else { + return makeAPIErrorResponse(500, "Unknown error") + } + } +} diff --git a/src/app/api/user/session-validity/route.ts b/src/app/api/user/session-validity/route.ts new file mode 100644 index 00000000..aa5d2e97 --- /dev/null +++ b/src/app/api/user/session-validity/route.ts @@ -0,0 +1,22 @@ +import { NextResponse } from "next/server" +import { makeAPIErrorResponse } from "@/common" +import { session, delayedSessionValidator } from "@/composition" + +export async function GET() { + try { + await session.getUserId() + } catch { + return makeAPIErrorResponse(401, "Unauthorized") + } + try { + const sessionValidity = await delayedSessionValidator.validateSession() + return NextResponse.json({sessionValidity}) + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + } catch (error: any) { + if (error.message) { + return makeAPIErrorResponse(500, error.message) + } else { + return makeAPIErrorResponse(500, "Unknown error") + } + } +} \ No newline at end of file diff --git a/src/app/invalid-session/page.tsx b/src/app/invalid-session/page.tsx deleted file mode 100644 index ee33fec5..00000000 --- a/src/app/invalid-session/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import InvalidSessionPage from "@/features/auth/view/InvalidSessionPage" - -export default async function Page() { - return -} diff --git a/src/app/page.tsx b/src/app/page.tsx index 55087f2f..01dfa211 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,11 +1,11 @@ -import SessionAccessTokenBarrier from "@/features/auth/view/SessionAccessTokenBarrier" +import SessionBarrier from "@/features/auth/view/SessionBarrier" 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 61260578..3626459a 100644 --- a/src/common/errors/client/ErrorHandler.tsx +++ b/src/common/errors/client/ErrorHandler.tsx @@ -14,8 +14,6 @@ export default function ErrorHandler({ } if (error.status == 401) { window.location.href = "/api/auth/logout" - } else if (error.status == 403) { - window.location.href = "/invalid-session" } } return ( diff --git a/src/common/errors/index.ts b/src/common/errors/index.ts index 74acb8d0..8ad94893 100644 --- a/src/common/errors/index.ts +++ b/src/common/errors/index.ts @@ -1,2 +1,2 @@ -export class UnauthorizedError extends Error {} -export class InvalidSessionError extends Error {} +export { default as makeAPIErrorResponse } from "./makeAPIErrorResponse" +export class UnauthorizedError extends Error {} \ No newline at end of file diff --git a/src/common/errors/makeAPIErrorResponse.ts b/src/common/errors/makeAPIErrorResponse.ts new file mode 100644 index 00000000..af62969f --- /dev/null +++ b/src/common/errors/makeAPIErrorResponse.ts @@ -0,0 +1,8 @@ +import { NextResponse } from "next/server" + +export default function makeAPIErrorResponse( + status: number, + message: string +): NextResponse { + return NextResponse.json({ status, message }, { status }) +} diff --git a/src/common/session/AlwaysValidSessionValidator.ts b/src/common/session/AlwaysValidSessionValidator.ts deleted file mode 100644 index 3f55486a..00000000 --- a/src/common/session/AlwaysValidSessionValidator.ts +++ /dev/null @@ -1,7 +0,0 @@ -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/GitHubOrganizationSessionValidator.ts b/src/common/session/GitHubOrganizationSessionValidator.ts deleted file mode 100644 index 308ff043..00000000 --- a/src/common/session/GitHubOrganizationSessionValidator.ts +++ /dev/null @@ -1,34 +0,0 @@ -import IGitHubClient from "../github/IGitHubClient" -import ISessionValidator from "./ISessionValidator" - -export default class GitHubOrganizationSessionValidator implements ISessionValidator { - private readonly gitHubClient: IGitHubClient - private readonly acceptedOrganization: string - - constructor(gitHubClient: IGitHubClient, acceptedOrganization: string) { - this.gitHubClient = gitHubClient - this.acceptedOrganization = acceptedOrganization - } - - async validateSession(): Promise { - try { - const response = await this.gitHubClient.getOrganizationMembershipStatus({ - organizationName: this.acceptedOrganization - }) - return response.state == "active" - /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ - } catch (error: any) { - if (error.status) { - if (error.status == 404) { - return false - } else if (error.status == 403) { - return false - } else { - throw error - } - } else { - throw error - } - } - } -} diff --git a/src/common/session/ISessionValidator.ts b/src/common/session/ISessionValidator.ts deleted file mode 100644 index b51cd5e6..00000000 --- a/src/common/session/ISessionValidator.ts +++ /dev/null @@ -1,3 +0,0 @@ -export default interface ISessionValidator { - validateSession(): Promise -} diff --git a/src/common/session/SessionValidator.ts b/src/common/session/SessionValidator.ts deleted file mode 100644 index 4b3da8c9..00000000 --- a/src/common/session/SessionValidator.ts +++ /dev/null @@ -1,32 +0,0 @@ -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 4d19783f..bf010ed7 100644 --- a/src/common/session/index.ts +++ b/src/common/session/index.ts @@ -1,5 +1 @@ -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 46dce15b..52e5e75d 100644 --- a/src/composition.ts +++ b/src/composition.ts @@ -3,21 +3,16 @@ import RedisKeyedMutexFactory from "@/common/mutex/RedisKeyedMutexFactory" import RedisKeyValueStore from "@/common/keyValueStore/RedisKeyValueStore" import { AccessTokenRefreshingGitHubClient, - AlwaysValidSessionValidator, GitHubClient, - GitHubOrganizationSessionValidator, KeyValueUserDataRepository, - SessionMutexFactory, - SessionValidator + SessionMutexFactory } from "@/common" import { GitHubProjectDataSource } from "@/features/projects/data" import { CachingProjectDataSource, - ForgivingProjectDataSource, - ProjectRepository, - SessionValidatingProjectDataSource + ProjectRepository } from "@/features/projects/domain" import { GitHubOAuthTokenRefresher, @@ -29,16 +24,20 @@ import { } from "@/features/auth/data" import { AccessTokenService, - CachingRepositoryAccessReaderConfig, + AccessTokenSessionValidator, + CachingRepositoryAccessReader, CachingUserIdentityProviderReader, CompositeLogInHandler, CompositeLogOutHandler, CredentialsTransferringLogInHandler, ErrorIgnoringLogOutHandler, + GitHubOrganizationSessionValidator, + GuestAccessTokenRepository, GuestAccessTokenService, - NullObjectCredentialsTransferrer, + GuestCredentialsTransferrer, HostAccessTokenService, HostCredentialsTransferrer, + HostOnlySessionValidator, IsUserGuestReader, LockingAccessTokenService, OAuthTokenRepository, @@ -101,9 +100,11 @@ const gitHubOAuthTokenRefresher = new GitHubOAuthTokenRefresher({ clientSecret: gitHubAppCredentials.clientSecret }) -const accessTokenRepository = new KeyValueUserDataRepository( - new RedisKeyValueStore(REDIS_URL), - "accessToken" +const guestAccessTokenRepository = new GuestAccessTokenRepository( + new KeyValueUserDataRepository( + new RedisKeyValueStore(REDIS_URL), + "accessToken" + ) ) const guestRepositoryAccessRepository = new KeyValueUserDataRepository( @@ -111,13 +112,15 @@ const guestRepositoryAccessRepository = new KeyValueUserDataRepository( "guestRepositoryAccess" ) +export const guestRepositoryAccessReader = new CachingRepositoryAccessReader({ + repository: guestRepositoryAccessRepository, + repositoryAccessReader: new Auth0RepositoryAccessReader({ + ...auth0ManagementCredentials + }) +}) + const guestAccessTokenDataSource = new RepositoryRestrictingAccessTokenDataSource({ - repositoryAccessReader: new CachingRepositoryAccessReaderConfig({ - repository: guestRepositoryAccessRepository, - repositoryAccessReader: new Auth0RepositoryAccessReader({ - ...auth0ManagementCredentials - }) - }), + repositoryAccessReader: guestRepositoryAccessReader, dataSource: new GitHubInstallationAccessTokenDataSource({ ...gitHubAppCredentials, organization: GITHUB_ORGANIZATION_NAME @@ -135,7 +138,7 @@ export const accessTokenService = new LockingAccessTokenService( isGuestReader: session, guestAccessTokenService: new GuestAccessTokenService({ userIdReader: session, - repository: accessTokenRepository, + repository: guestAccessTokenRepository, dataSource: guestAccessTokenDataSource }), hostAccessTokenService: new HostAccessTokenService({ @@ -157,13 +160,15 @@ export const userGitHubClient = new AccessTokenRefreshingGitHubClient( gitHubClient ) -export const sessionValidator = new SessionValidator({ +export const blockingSessionValidator = new AccessTokenSessionValidator({ + accessTokenService: accessTokenService +}) +export const delayedSessionValidator = new HostOnlySessionValidator({ isGuestReader: session, - guestSessionValidator: new AlwaysValidSessionValidator(), - hostSessionValidator: new GitHubOrganizationSessionValidator( - userGitHubClient, - GITHUB_ORGANIZATION_NAME - ) + sessionValidator: new GitHubOrganizationSessionValidator({ + acceptedOrganization: GITHUB_ORGANIZATION_NAME, + organizationMembershipStatusReader: userGitHubClient + }) }) const projectUserDataRepository = new KeyValueUserDataRepository( @@ -177,15 +182,9 @@ export const projectRepository = new ProjectRepository( ) export const projectDataSource = new CachingProjectDataSource( - new SessionValidatingProjectDataSource( - sessionValidator, - new ForgivingProjectDataSource({ - accessTokenReader: accessTokenService, - projectDataSource: new GitHubProjectDataSource( - userGitHubClient, - GITHUB_ORGANIZATION_NAME - ) - }) + new GitHubProjectDataSource( + userGitHubClient, + GITHUB_ORGANIZATION_NAME ), projectRepository ) @@ -195,7 +194,10 @@ export const logInHandler = new CompositeLogInHandler([ isUserGuestReader: new IsUserGuestReader( userIdentityProviderReader ), - guestCredentialsTransferrer: new NullObjectCredentialsTransferrer(), + guestCredentialsTransferrer: new GuestCredentialsTransferrer({ + dataSource: guestAccessTokenDataSource, + repository: guestAccessTokenRepository + }), hostCredentialsTransferrer: new HostCredentialsTransferrer({ refreshTokenReader: new Auth0RefreshTokenReader({ ...auth0ManagementCredentials, @@ -216,6 +218,6 @@ export const logOutHandler = new ErrorIgnoringLogOutHandler( new UserDataCleanUpLogOutHandler(session, userIdentityProviderRepository), new UserDataCleanUpLogOutHandler(session, guestRepositoryAccessRepository), new UserDataCleanUpLogOutHandler(session, oAuthTokenRepository), - new UserDataCleanUpLogOutHandler(session, accessTokenRepository) + new UserDataCleanUpLogOutHandler(session, guestAccessTokenRepository) ]) ) diff --git a/src/features/auth/data/GitHubInstallationAccessTokenDataSource.ts b/src/features/auth/data/GitHubInstallationAccessTokenDataSource.ts index 7a958619..b82c2112 100644 --- a/src/features/auth/data/GitHubInstallationAccessTokenDataSource.ts +++ b/src/features/auth/data/GitHubInstallationAccessTokenDataSource.ts @@ -17,6 +17,9 @@ export default class GitHubInstallationAccessTokenRefresher { } async getAccessToken(repositoryNames: string[]): Promise { + if (repositoryNames.length == 0) { + throw new Error("Must provide at least one repository name when creating a GitHub installation access token.") + } const auth = createAppAuth({ appId: this.config.appId, clientId: this.config.clientId, diff --git a/src/features/auth/domain/accessToken/GuestAccessTokenRepository.ts b/src/features/auth/domain/accessToken/GuestAccessTokenRepository.ts new file mode 100644 index 00000000..7a93cef0 --- /dev/null +++ b/src/features/auth/domain/accessToken/GuestAccessTokenRepository.ts @@ -0,0 +1,25 @@ +export interface IRepository { + get(userId: string): Promise + setExpiring(userId: string, token: string, timeToLive: number): Promise + delete(userId: string): Promise +} + +export default class GuestAccessTokenRepository { + private readonly repository: IRepository + + constructor(repository: IRepository) { + this.repository = repository + } + + async get(userId: string): Promise { + return await this.repository.get(userId) + } + + async set(userId: string, accessToken: string): Promise { + await this.repository.setExpiring(userId, accessToken, 7 * 24 * 3600) + } + + async delete(userId: string): Promise { + await this.repository.delete(userId) + } +} \ No newline at end of file diff --git a/src/features/auth/domain/accessToken/GuestAccessTokenService.ts b/src/features/auth/domain/accessToken/GuestAccessTokenService.ts index d2e1a059..6ac68c0c 100644 --- a/src/features/auth/domain/accessToken/GuestAccessTokenService.ts +++ b/src/features/auth/domain/accessToken/GuestAccessTokenService.ts @@ -1,3 +1,4 @@ +import { UnauthorizedError } from "../../../../common" import IAccessTokenService from "./IAccessTokenService" export interface IUserIDReader { @@ -6,7 +7,7 @@ export interface IUserIDReader { export interface Repository { get(userId: string): Promise - setExpiring(userId: string, token: string, timeToLive: number): Promise + set(userId: string, token: string): Promise } export interface DataSource { @@ -34,20 +35,15 @@ export default class GuestAccessTokenService implements IAccessTokenService { 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() + throw new UnauthorizedError(`No access token stored for user with ID ${userId}.`) } 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) + await this.repository.set(userId, newAccessToken) return newAccessToken } } \ No newline at end of file diff --git a/src/features/auth/domain/accessToken/index.ts b/src/features/auth/domain/accessToken/index.ts index fd3cb104..b575dda2 100644 --- a/src/features/auth/domain/accessToken/index.ts +++ b/src/features/auth/domain/accessToken/index.ts @@ -1,3 +1,4 @@ +export { default as GuestAccessTokenRepository } from "./GuestAccessTokenRepository" export { default as GuestAccessTokenService } from "./GuestAccessTokenService" export { default as HostAccessTokenService } from "./HostAccessTokenService" export { default as LockingAccessTokenService } from "./LockingAccessTokenService" diff --git a/src/features/auth/domain/credentialsTransfer/GuestCredentialsTransferrer.ts b/src/features/auth/domain/credentialsTransfer/GuestCredentialsTransferrer.ts new file mode 100644 index 00000000..05a39f7f --- /dev/null +++ b/src/features/auth/domain/credentialsTransfer/GuestCredentialsTransferrer.ts @@ -0,0 +1,29 @@ +import ICredentialsTransferrer from "./ICredentialsTransferrer" + +export interface IDataSource { + getAccessToken(userId: string): Promise +} + +export interface IRepository { + set(userId: string, token: string): Promise +} + +export type GuestCredentialsTransferrerConfig = { + readonly dataSource: IDataSource + readonly repository: IRepository +} + +export default class GuestCredentialsTransferrer implements ICredentialsTransferrer { + private readonly dataSource: IDataSource + private readonly repository: IRepository + + constructor(config: GuestCredentialsTransferrerConfig) { + this.dataSource = config.dataSource + this.repository = config.repository + } + + async transferCredentials(userId: string): Promise { + const newAccessToken = await this.dataSource.getAccessToken(userId) + await this.repository.set(userId, newAccessToken) + } +} diff --git a/src/features/auth/domain/credentialsTransfer/NullObjectCredentialsTransferrer.ts b/src/features/auth/domain/credentialsTransfer/NullObjectCredentialsTransferrer.ts deleted file mode 100644 index 2b02e044..00000000 --- a/src/features/auth/domain/credentialsTransfer/NullObjectCredentialsTransferrer.ts +++ /dev/null @@ -1,5 +0,0 @@ -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 3961d607..b07aa4af 100644 --- a/src/features/auth/domain/credentialsTransfer/index.ts +++ b/src/features/auth/domain/credentialsTransfer/index.ts @@ -1,2 +1,2 @@ +export { default as GuestCredentialsTransferrer } from "./GuestCredentialsTransferrer" 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 63cbe382..1c1961cb 100644 --- a/src/features/auth/domain/index.ts +++ b/src/features/auth/domain/index.ts @@ -4,4 +4,5 @@ export * from "./logIn" export * from "./logOut" export * from "./oAuthToken" export * from "./repositoryAccess" +export * from "./sessionValidity" export * from "./userIdentityProvider" diff --git a/src/features/auth/domain/logIn/CredentialsTransferringLogInHandler.ts b/src/features/auth/domain/logIn/CredentialsTransferringLogInHandler.ts index ae967eb5..112171ee 100644 --- a/src/features/auth/domain/logIn/CredentialsTransferringLogInHandler.ts +++ b/src/features/auth/domain/logIn/CredentialsTransferringLogInHandler.ts @@ -24,11 +24,19 @@ export default class CredentialsTransferringLogInHandler implements ILogInHandle } async handleLogIn(userId: string): Promise { - const isGuest = await this.isUserGuestReader.getIsUserGuest(userId) - if (isGuest) { - await this.guestCredentialsTransferrer.transferCredentials(userId) - } else { - await this.hostCredentialsTransferrer.transferCredentials(userId) + try { + const isGuest = await this.isUserGuestReader.getIsUserGuest(userId) + if (isGuest) { + await this.guestCredentialsTransferrer.transferCredentials(userId) + } else { + await this.hostCredentialsTransferrer.transferCredentials(userId) + } + } catch { + // It is safe to ignore the error. Transferring credentials is a + // "best-case scenario" that will always succeed unless the user + // is not a member of the GitHub organization or a guest user has + // been incorrectly configured. Either way, we allow the user to + // login an rely on the SessionBarrier to show an error later. } } } diff --git a/src/features/auth/domain/repositoryAccess/CachingRepositoryAccessReaderConfig.ts b/src/features/auth/domain/repositoryAccess/CachingRepositoryAccessReader.ts similarity index 100% rename from src/features/auth/domain/repositoryAccess/CachingRepositoryAccessReaderConfig.ts rename to src/features/auth/domain/repositoryAccess/CachingRepositoryAccessReader.ts diff --git a/src/features/auth/domain/repositoryAccess/index.ts b/src/features/auth/domain/repositoryAccess/index.ts index 3f3e42b7..1224c7b8 100644 --- a/src/features/auth/domain/repositoryAccess/index.ts +++ b/src/features/auth/domain/repositoryAccess/index.ts @@ -1,2 +1,2 @@ -export { default as CachingRepositoryAccessReaderConfig } from "./CachingRepositoryAccessReaderConfig" +export { default as CachingRepositoryAccessReader } from "./CachingRepositoryAccessReader" export { default as RepositoryRestrictingAccessTokenDataSource } from "./RepositoryRestrictingAccessTokenDataSource" diff --git a/src/features/auth/domain/sessionValidity/AccessTokenSessionValidator.ts b/src/features/auth/domain/sessionValidity/AccessTokenSessionValidator.ts new file mode 100644 index 00000000..05ef4dc4 --- /dev/null +++ b/src/features/auth/domain/sessionValidity/AccessTokenSessionValidator.ts @@ -0,0 +1,26 @@ +import SessionValidity from "./SessionValidity" + +interface IAccessTokenService { + getAccessToken(): Promise +} + +type AccessTokenSessionValidatorConfig = { + readonly accessTokenService: IAccessTokenService +} + +export default class AccessTokenSessionValidator { + private readonly accessTokenService: IAccessTokenService + + constructor(config: AccessTokenSessionValidatorConfig) { + this.accessTokenService = config.accessTokenService + } + + async validateSession(): Promise { + try { + await this.accessTokenService.getAccessToken() + return SessionValidity.VALID + } catch { + return SessionValidity.INVALID_ACCESS_TOKEN + } + } +} diff --git a/src/features/auth/domain/sessionValidity/GitHubOrganizationSessionValidator.ts b/src/features/auth/domain/sessionValidity/GitHubOrganizationSessionValidator.ts new file mode 100644 index 00000000..7f8aba6b --- /dev/null +++ b/src/features/auth/domain/sessionValidity/GitHubOrganizationSessionValidator.ts @@ -0,0 +1,52 @@ +import SessionValidity from "./SessionValidity" + +type OrganizationMembershipStatus = { + readonly state: "active" | "pending" +} + +interface IOrganizationMembershipStatusReader { + getOrganizationMembershipStatus( + request: { organizationName: string } + ): Promise +} + +type GitHubOrganizationSessionValidatorConfig = { + readonly acceptedOrganization: string + readonly organizationMembershipStatusReader: IOrganizationMembershipStatusReader +} + +export default class GitHubOrganizationSessionValidator { + private readonly acceptedOrganization: string + private readonly organizationMembershipStatusReader: IOrganizationMembershipStatusReader + + constructor(config: GitHubOrganizationSessionValidatorConfig) { + this.acceptedOrganization = config.acceptedOrganization + this.organizationMembershipStatusReader = config.organizationMembershipStatusReader + } + + async validateSession(): Promise { + try { + const response = await this.organizationMembershipStatusReader.getOrganizationMembershipStatus({ + organizationName: this.acceptedOrganization + }) + if (response.state == "active") { + return SessionValidity.VALID + } else { + return SessionValidity.OUTSIDE_GITHUB_ORGANIZATION + } + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + } catch (error: any) { + if (error.status) { + if (error.status == 404) { + return SessionValidity.OUTSIDE_GITHUB_ORGANIZATION + } else if (error.status == 403) { + return SessionValidity.GITHUB_APP_BLOCKED + } else { + throw error + } + } else { + throw error + } + } + } +} diff --git a/src/features/auth/domain/sessionValidity/HostOnlySessionValidator.ts b/src/features/auth/domain/sessionValidity/HostOnlySessionValidator.ts new file mode 100644 index 00000000..08220a67 --- /dev/null +++ b/src/features/auth/domain/sessionValidity/HostOnlySessionValidator.ts @@ -0,0 +1,33 @@ +import SessionValidity from "./SessionValidity" + +interface IIsGuestReader { + getIsGuest(): Promise +} + +interface ISessionValidator { + validateSession(): Promise +} + +type HostOnlySessionValidatorConfig = { + readonly isGuestReader: IIsGuestReader + readonly sessionValidator: ISessionValidator +} + +export default class HostOnlySessionValidator { + private readonly isGuestReader: IIsGuestReader + private readonly sessionValidator: ISessionValidator + + constructor(config: HostOnlySessionValidatorConfig) { + this.isGuestReader = config.isGuestReader + this.sessionValidator = config.sessionValidator + } + + async validateSession(): Promise { + const isGuest = await this.isGuestReader.getIsGuest() + if (!isGuest) { + return await this.sessionValidator.validateSession() + } else { + return SessionValidity.VALID + } + } +} diff --git a/src/features/auth/domain/sessionValidity/SessionValidity.ts b/src/features/auth/domain/sessionValidity/SessionValidity.ts new file mode 100644 index 00000000..ba461768 --- /dev/null +++ b/src/features/auth/domain/sessionValidity/SessionValidity.ts @@ -0,0 +1,21 @@ +enum SessionValidity { + VALID = "valid", + INVALID_ACCESS_TOKEN = "invalid_access_token", + OUTSIDE_GITHUB_ORGANIZATION = "outside_github_organization", + GITHUB_APP_BLOCKED = "github_app_blocked" +} + +export default SessionValidity + +export function mergeSessionValidity( + lhs: SessionValidity, + rhs: SessionValidity +): SessionValidity { + if (lhs != SessionValidity.VALID) { + return lhs + } else if (rhs != SessionValidity.VALID) { + return rhs + } else { + return SessionValidity.VALID + } +} diff --git a/src/features/auth/domain/sessionValidity/index.ts b/src/features/auth/domain/sessionValidity/index.ts new file mode 100644 index 00000000..de4d2dde --- /dev/null +++ b/src/features/auth/domain/sessionValidity/index.ts @@ -0,0 +1,7 @@ +export { default as AccessTokenSessionValidator } from "./AccessTokenSessionValidator" +export { default as GitHubOrganizationSessionValidator } from "./GitHubOrganizationSessionValidator" +export { default as HostOnlySessionValidator } from "./HostOnlySessionValidator" +export { default as SessionValidity } from "./SessionValidity" +export * from "./SessionValidity" +export { default as useRepositoryAccess } from "./useRepositoryAccess" +export { default as useSessionValidity } from "./useSessionValidity" diff --git a/src/features/auth/domain/sessionValidity/useRepositoryAccess.ts b/src/features/auth/domain/sessionValidity/useRepositoryAccess.ts new file mode 100644 index 00000000..c98d6b03 --- /dev/null +++ b/src/features/auth/domain/sessionValidity/useRepositoryAccess.ts @@ -0,0 +1,18 @@ +"use client" + +import useSWR from "swr" +import { fetcher } from "../../../../common" + +type RepositoriesContainer = { repositories: string[] } + +export default function useRepositoryAccess() { + const { data, error, isLoading } = useSWR( + "/api/user/repository-access", + fetcher + ) + return { + repositories: data?.repositories || [], + isLoading, + error + } +} diff --git a/src/features/auth/domain/sessionValidity/useSessionValidity.ts b/src/features/auth/domain/sessionValidity/useSessionValidity.ts new file mode 100644 index 00000000..ce5d62ec --- /dev/null +++ b/src/features/auth/domain/sessionValidity/useSessionValidity.ts @@ -0,0 +1,19 @@ +"use client" + +import useSWR from "swr" +import { fetcher } from "../../../../common" +import SessionValidity from "./SessionValidity" + +type SessionValidityContainer = { sessionValidity: SessionValidity } + +export default function useSessionValidity() { + const { data, error, isLoading } = useSWR( + "/api/user/session-validity", + fetcher + ) + return { + sessionValidity: data?.sessionValidity || SessionValidity.VALID, + isLoading, + error + } +} diff --git a/src/features/auth/view/InvalidSessionPage.tsx b/src/features/auth/view/InvalidSessionPage.tsx deleted file mode 100644 index 99292a13..00000000 --- a/src/features/auth/view/InvalidSessionPage.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { redirect } from "next/navigation" -import { sessionValidator } from "@/composition" -import InvalidSession from "./client/InvalidSession" - -const { - NEXT_PUBLIC_SHAPE_DOCS_TITLE, - GITHUB_ORGANIZATION_NAME -} = process.env - -export default async function InvalidSessionPage() { - const isSessionValid = await sessionValidator.validateSession() - if (isSessionValid) { - // User ended up here by mistake so lets send them to the front page. - redirect("/") - } - return ( - - ) -} diff --git a/src/features/auth/view/SessionAccessTokenBarrier.tsx b/src/features/auth/view/SessionAccessTokenBarrier.tsx deleted file mode 100644 index 58996582..00000000 --- a/src/features/auth/view/SessionAccessTokenBarrier.tsx +++ /dev/null @@ -1,19 +0,0 @@ -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/SessionBarrier.tsx b/src/features/auth/view/SessionBarrier.tsx new file mode 100644 index 00000000..15211ce9 --- /dev/null +++ b/src/features/auth/view/SessionBarrier.tsx @@ -0,0 +1,35 @@ +import { ReactNode } from "react" +import { session, blockingSessionValidator } from "@/composition" +import ClientSessionBarrier from "./client/SessionBarrier" + +const { + NEXT_PUBLIC_SHAPE_DOCS_TITLE, + GITHUB_ORGANIZATION_NAME +} = process.env + +export default async function SessionBarrier({ + children +}: { + children: ReactNode +}) { + const getIsGuest = async () => { + try { + return await session.getIsGuest() + } catch { + // We assume it's a guest. + return true + } + } + const isGuest = await getIsGuest() + const sessionValidity = await blockingSessionValidator.validateSession() + return ( + + {children} + + ) +} diff --git a/src/features/auth/view/client/GuestAccessTokenInvalidPage.tsx b/src/features/auth/view/client/GuestAccessTokenInvalidPage.tsx new file mode 100644 index 00000000..65daa3a0 --- /dev/null +++ b/src/features/auth/view/client/GuestAccessTokenInvalidPage.tsx @@ -0,0 +1,54 @@ +"use client" + +import InvalidSessionPage from "./InvalidSessionPage" +import LoadingIndicator from "@/common/loading/DelayedLoadingIndicator" +import { useRepositoryAccess } from "../../domain" + +export default function GuestAccessTokenInvalidPage({ + organizationName +}: { + organizationName: string +}) { + const {repositories, isLoading, error} = useRepositoryAccess() + if (isLoading) { + return ( + + + + ) + } + if (error) { + return ( + + It was not possible to obtain access to the projects on the {organizationName} organization on GitHub. + + ) + } + if (repositories.length == 0) { + return ( + + Your account does not have access to any projects. + + ) + } + const repositoryNamesHTML = makeRepositoryNamesHTML(repositories) + const html = `It was not possible to obtain access to ${repositoryNamesHTML}.` + return ( + +
+ + ) +} + +function makeRepositoryNamesHTML(repositories: string[]): string { + const copiedRepositories = [...repositories] + if (copiedRepositories.length == 1) { + return `${copiedRepositories[0]}` + } else { + const last = copiedRepositories.pop() + return copiedRepositories + .map(e => `${e}`) + .join(", ") + + `, and ${last}` + } +} \ No newline at end of file diff --git a/src/features/auth/view/client/InvalidSession.tsx b/src/features/auth/view/client/InvalidSession.tsx deleted file mode 100644 index 76cf8416..00000000 --- a/src/features/auth/view/client/InvalidSession.tsx +++ /dev/null @@ -1,32 +0,0 @@ -"use client" - -import { Button, Stack, Typography } from "@mui/material" - -export default function InvalidSession({ - siteName, - organizationName -}: { - siteName: string - organizationName: string -}) { - const navigateToFrontPage = () => { - if (typeof window !== "undefined") { - window.location.href = "/api/auth/logout" - } - } - return ( - - - - Your account does not have access to {siteName} - - - Access to {siteName} requires that your account is an active member of the {organizationName} organization on GitHub. - - - - - ) -} diff --git a/src/features/auth/view/client/InvalidSessionPage.tsx b/src/features/auth/view/client/InvalidSessionPage.tsx new file mode 100644 index 00000000..8a796a46 --- /dev/null +++ b/src/features/auth/view/client/InvalidSessionPage.tsx @@ -0,0 +1,45 @@ +"use client" + +import { ReactNode } from "react" +import { Stack, Typography } from "@mui/material" +import SidebarContainer from "@/features/sidebar/view/client/SidebarContainer" +import useSidebarOpen from "@/common/state/useSidebarOpen" + +export default function InvalidSessionPage({ + title, + children +}: { + title?: ReactNode + children?: ReactNode +}) { + const [isSidebarOpen, setSidebarOpen] = useSidebarOpen() + return ( + + + + {title && + + {title} + + } + + {children} + + + + + ) +} diff --git a/src/features/auth/view/client/SessionBarrier.tsx b/src/features/auth/view/client/SessionBarrier.tsx new file mode 100644 index 00000000..3913e2fa --- /dev/null +++ b/src/features/auth/view/client/SessionBarrier.tsx @@ -0,0 +1,51 @@ +"use client" + +import { ReactNode } from "react" +import GuestAccessTokenInvalidPage from "./GuestAccessTokenInvalidPage" +import InvalidSessionPage from "./InvalidSessionPage" +import { + SessionValidity, + mergeSessionValidity, + useSessionValidity +} from "../../domain" + +export default function SessionBarrier({ + isGuest, + siteName, + organizationName, + sessionValidity: fastSessionValidity, + children +}: { + isGuest: boolean + siteName: string + organizationName: string + sessionValidity: SessionValidity + children: ReactNode +}) { + const { sessionValidity: delayedSessionValidity } = useSessionValidity() + const sessionValidity = mergeSessionValidity( + fastSessionValidity, + delayedSessionValidity + ) + switch (sessionValidity) { + case SessionValidity.VALID: + return <>{children} + case SessionValidity.INVALID_ACCESS_TOKEN: + if (isGuest) { + return + } else { + return ( + + It was not possible to obtain access to the projects on the {organizationName} organization on GitHub. + + ) + } + case SessionValidity.OUTSIDE_GITHUB_ORGANIZATION: + case SessionValidity.GITHUB_APP_BLOCKED: + return ( + + Access to {siteName} requires that your account is an active member of the {organizationName} organization on GitHub. + + ) + } +} diff --git a/src/features/projects/domain/ForgivingProjectDataSource.ts b/src/features/projects/domain/ForgivingProjectDataSource.ts deleted file mode 100644 index 82f5c40f..00000000 --- a/src/features/projects/domain/ForgivingProjectDataSource.ts +++ /dev/null @@ -1,33 +0,0 @@ -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/SessionValidatingProjectDataSource.ts b/src/features/projects/domain/SessionValidatingProjectDataSource.ts deleted file mode 100644 index 0cb8c8c4..00000000 --- a/src/features/projects/domain/SessionValidatingProjectDataSource.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { ISessionValidator, InvalidSessionError } from "../../../common" -import { IProjectDataSource ,Project } from "../domain" - -export default class SessionValidatingProjectDataSource implements IProjectDataSource { - private readonly sessionValidator: ISessionValidator - private readonly projectDataSource: IProjectDataSource - - constructor( - sessionValidator: ISessionValidator, - projectDataSource: IProjectDataSource - ) { - this.sessionValidator = sessionValidator - this.projectDataSource = projectDataSource - } - - async getProjects(): Promise { - const isValid = await this.sessionValidator.validateSession() - if (!isValid) { - throw new InvalidSessionError() - } - return await this.projectDataSource.getProjects() - } -} diff --git a/src/features/projects/domain/index.ts b/src/features/projects/domain/index.ts index f770e7c7..3d8006dc 100644 --- a/src/features/projects/domain/index.ts +++ b/src/features/projects/domain/index.ts @@ -1,5 +1,4 @@ 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" @@ -8,6 +7,5 @@ export type { default as Project } from "./Project" export { default as ProjectConfigParser } from "./ProjectConfigParser" export { default as projectNavigator } from "./projectNavigator" export { default as ProjectRepository } from "./ProjectRepository" -export { default as SessionValidatingProjectDataSource } from "./SessionValidatingProjectDataSource" export { default as updateWindowTitle } from "./updateWindowTitle" export type { default as Version } from "./Version"