diff --git a/__test__/common/github/AccessTokenRefreshingGitHubClient.test.ts b/__test__/common/github/AccessTokenRefreshingGitHubClient.test.ts index c8c7d21a..7913e1c3 100644 --- a/__test__/common/github/AccessTokenRefreshingGitHubClient.test.ts +++ b/__test__/common/github/AccessTokenRefreshingGitHubClient.test.ts @@ -27,7 +27,10 @@ test("It forwards a GraphQL request", async () => { async getPullRequestComments() { return [] }, - async addCommentToPullRequest() {} + async addCommentToPullRequest() {}, + async getOrganizationMembershipStatus() { + return { state: "active" } + } }) const request: GraphQLQueryRequest = { query: "foo", @@ -58,7 +61,10 @@ test("It forwards a request to get the repository content", async () => { async getPullRequestComments() { return [] }, - async addCommentToPullRequest() {} + async addCommentToPullRequest() {}, + async getOrganizationMembershipStatus() { + return { state: "active" } + } }) const request: GetRepositoryContentRequest = { repositoryOwner: "foo", @@ -91,7 +97,10 @@ test("It forwards a request to get comments to a pull request", async () => { forwardedRequest = request return [] }, - async addCommentToPullRequest() {} + async addCommentToPullRequest() {}, + async getOrganizationMembershipStatus() { + return { state: "active" } + } }) const request: GetPullRequestCommentsRequest = { appInstallationId: 1234, @@ -125,6 +134,9 @@ test("It forwards a request to add a comment to a pull request", async () => { }, async addCommentToPullRequest(request: AddCommentToPullRequestRequest) { forwardedRequest = request + }, + async getOrganizationMembershipStatus() { + return { state: "active" } } }) const request: AddCommentToPullRequestRequest = { @@ -164,7 +176,10 @@ test("It retries with a refreshed access token when receiving HTTP 401", async ( async getPullRequestComments() { return [] }, - async addCommentToPullRequest() {} + async addCommentToPullRequest() {}, + async getOrganizationMembershipStatus() { + return { state: "active" } + } }) const request: GraphQLQueryRequest = { query: "foo", @@ -195,7 +210,10 @@ test("It only retries a request once when receiving HTTP 401", async () => { async getPullRequestComments() { return [] }, - async addCommentToPullRequest() {} + async addCommentToPullRequest() {}, + async getOrganizationMembershipStatus() { + return { state: "active" } + } }) const request: GraphQLQueryRequest = { query: "foo", @@ -229,7 +247,10 @@ test("It does not refresh an access token when the initial request was successfu async getPullRequestComments() { return [] }, - async addCommentToPullRequest() {} + async addCommentToPullRequest() {}, + async getOrganizationMembershipStatus() { + return { state: "active" } + } }) const request: GraphQLQueryRequest = { query: "foo", diff --git a/__test__/common/session/GitHubOrganizationSessionValidator.test.ts b/__test__/common/session/GitHubOrganizationSessionValidator.test.ts new file mode 100644 index 00000000..105039de --- /dev/null +++ b/__test__/common/session/GitHubOrganizationSessionValidator.test.ts @@ -0,0 +1,143 @@ +import { + GetOrganizationMembershipStatusRequest +} from "../../../src/common/github/IGitHubClient" +import GitHubOrganizationSessionValidator from "../../../src/common/session/GitHubOrganizationSessionValidator" + +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__/projects/SessionValidatingProjectDataSource.test.ts b/__test__/projects/SessionValidatingProjectDataSource.test.ts new file mode 100644 index 00000000..f0f96dc7 --- /dev/null +++ b/__test__/projects/SessionValidatingProjectDataSource.test.ts @@ -0,0 +1,46 @@ +import SessionValidatingProjectDataSource from "../../src/features/projects/domain/SessionValidatingProjectDataSource" + +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/api/user/projects/route.ts b/src/app/api/user/projects/route.ts index a0ac3816..799f13f6 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 } from "@/features/auth/domain/AuthError" +import { UnauthorizedError, InvalidSessionError } from "@/common/errors" export async function GET() { try { @@ -8,20 +8,17 @@ export async function GET() { return NextResponse.json({projects}) } catch (error) { if (error instanceof UnauthorizedError) { - return NextResponse.json({ - status: 401, - message: error.message - }, { status: 401 }) + return errorResponse(401, error.message) + } else if (error instanceof InvalidSessionError) { + return errorResponse(403, error.message) } else if (error instanceof Error) { - return NextResponse.json({ - status: 500, - message: error.message - }, { status: 500 }) + return errorResponse(500, error.message) } else { - return NextResponse.json({ - status: 500, - message: "Unknown error" - }, { status: 500 }) + return errorResponse(500, "Unknown error") } } } + +function errorResponse(status: number, message: string): NextResponse { + return NextResponse.json({ status, message }, { status }) +} diff --git a/src/app/invalid-session/page.tsx b/src/app/invalid-session/page.tsx new file mode 100644 index 00000000..ee33fec5 --- /dev/null +++ b/src/app/invalid-session/page.tsx @@ -0,0 +1,5 @@ +import InvalidSessionPage from "@/features/auth/view/InvalidSessionPage" + +export default async function Page() { + return +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 6d89e3d4..1760b426 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -4,7 +4,7 @@ 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/errorHandling/client/ErrorHandler" +import ErrorHandler from "@/common/errors/client/ErrorHandler" import "@fortawesome/fontawesome-svg-core/styles.css" fontAwesomeConfig.autoAddCss = false diff --git a/src/common/errorHandling/client/ErrorHandler.tsx b/src/common/errors/client/ErrorHandler.tsx similarity index 66% rename from src/common/errorHandling/client/ErrorHandler.tsx rename to src/common/errors/client/ErrorHandler.tsx index 83b68d45..20ba19a3 100644 --- a/src/common/errorHandling/client/ErrorHandler.tsx +++ b/src/common/errors/client/ErrorHandler.tsx @@ -9,10 +9,13 @@ export default function ErrorHandler({ children: React.ReactNode }) { const onSWRError = (error: FetcherError) => { + if (typeof window === "undefined") { + return + } if (error.status == 401) { - if (typeof window !== "undefined") { - window.location.href = "/api/auth/logout" - } + 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 new file mode 100644 index 00000000..74acb8d0 --- /dev/null +++ b/src/common/errors/index.ts @@ -0,0 +1,2 @@ +export class UnauthorizedError extends Error {} +export class InvalidSessionError extends Error {} diff --git a/src/common/github/AccessTokenRefreshingGitHubClient.ts b/src/common/github/AccessTokenRefreshingGitHubClient.ts index e9a3a29a..e5e4a9e7 100644 --- a/src/common/github/AccessTokenRefreshingGitHubClient.ts +++ b/src/common/github/AccessTokenRefreshingGitHubClient.ts @@ -4,6 +4,8 @@ import IGitHubClient, { GraphQlQueryResponse, GetRepositoryContentRequest, GetPullRequestCommentsRequest, + GetOrganizationMembershipStatusRequest, + GetOrganizationMembershipStatusRequestResponse, AddCommentToPullRequestRequest, RepositoryContent, PullRequestComment @@ -60,6 +62,14 @@ export default class AccessTokenRefreshingGitHubClient implements IGitHubClient }) } + async getOrganizationMembershipStatus( + request: GetOrganizationMembershipStatusRequest + ): Promise { + return await this.send(async () => { + return await this.gitHubClient.getOrganizationMembershipStatus(request) + }) + } + private async send(fn: () => Promise): Promise { const accessToken = await this.accessTokenReader.getAccessToken() try { diff --git a/src/common/github/GitHubClient.ts b/src/common/github/GitHubClient.ts index 2b03cbe2..cec899e9 100644 --- a/src/common/github/GitHubClient.ts +++ b/src/common/github/GitHubClient.ts @@ -6,6 +6,8 @@ import IGitHubClient, { GetRepositoryContentRequest, GetPullRequestCommentsRequest, AddCommentToPullRequestRequest, + GetOrganizationMembershipStatusRequest, + GetOrganizationMembershipStatusRequestResponse, RepositoryContent, PullRequestComment } from "./IGitHubClient" @@ -90,4 +92,15 @@ export default class GitHubClient implements IGitHubClient { body: request.body }) } + + async getOrganizationMembershipStatus( + request: GetOrganizationMembershipStatusRequest + ): Promise { + const accessToken = await this.accessTokenReader.getAccessToken() + const octokit = new Octokit({ auth: accessToken }) + const response = await octokit.rest.orgs.getMembershipForAuthenticatedUser({ + org: request.organizationName + }) + return { state: response.data.state } + } } diff --git a/src/common/github/IGitHubClient.ts b/src/common/github/IGitHubClient.ts index 9537786e..1b349fc6 100644 --- a/src/common/github/IGitHubClient.ts +++ b/src/common/github/IGitHubClient.ts @@ -40,9 +40,18 @@ export type AddCommentToPullRequestRequest = { readonly body: string } +export type GetOrganizationMembershipStatusRequest = { + readonly organizationName: string +} + +export type GetOrganizationMembershipStatusRequestResponse = { + readonly state: "active" | "pending" +} + export default interface IGitHubClient { graphql(request: GraphQLQueryRequest): Promise getRepositoryContent(request: GetRepositoryContentRequest): Promise getPullRequestComments(request: GetPullRequestCommentsRequest): Promise addCommentToPullRequest(request: AddCommentToPullRequestRequest): Promise + getOrganizationMembershipStatus(request: GetOrganizationMembershipStatusRequest): Promise } diff --git a/src/common/session/Auth0Session.ts b/src/common/session/Auth0Session.ts index 4131b040..0971235b 100644 --- a/src/common/session/Auth0Session.ts +++ b/src/common/session/Auth0Session.ts @@ -1,6 +1,6 @@ import { getSession } from "@auth0/nextjs-auth0" +import { UnauthorizedError } from "@/common/errors" import ISession from "./ISession" -import { UnauthorizedError } from "@/features/auth/domain/AuthError" export default class Auth0Session implements ISession { async getUserId(): Promise { diff --git a/src/common/session/GitHubOrganizationSessionValidator.ts b/src/common/session/GitHubOrganizationSessionValidator.ts new file mode 100644 index 00000000..308ff043 --- /dev/null +++ b/src/common/session/GitHubOrganizationSessionValidator.ts @@ -0,0 +1,34 @@ +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 new file mode 100644 index 00000000..b51cd5e6 --- /dev/null +++ b/src/common/session/ISessionValidator.ts @@ -0,0 +1,3 @@ +export default interface ISessionValidator { + validateSession(): Promise +} diff --git a/src/composition.ts b/src/composition.ts index 37a60313..dcfac50c 100644 --- a/src/composition.ts +++ b/src/composition.ts @@ -4,6 +4,7 @@ import Auth0Session from "@/common/session/Auth0Session" import CachingProjectDataSource from "@/features/projects/domain/CachingProjectDataSource" import GitHubClient from "@/common/github/GitHubClient" import GitHubOAuthTokenRefresher from "@/features/auth/data/GitHubOAuthTokenRefresher" +import GitHubOrganizationSessionValidator from "@/common/session/GitHubOrganizationSessionValidator" import GitHubProjectDataSource from "@/features/projects/data/GitHubProjectDataSource" import InitialOAuthTokenService from "@/features/auth/domain/InitialOAuthTokenService" import KeyValueUserDataRepository from "@/common/userData/KeyValueUserDataRepository" @@ -15,6 +16,7 @@ import SessionDataRepository from "@/common/userData/SessionDataRepository" import SessionMutexFactory from "@/common/mutex/SessionMutexFactory" import SessionOAuthTokenRepository from "@/features/auth/domain/SessionOAuthTokenRepository" import SessionProjectRepository from "@/features/projects/domain/SessionProjectRepository" +import SessionValidatingProjectDataSource from "@/features/projects/domain/SessionValidatingProjectDataSource" import OAuthTokenRepository from "@/features/auth/domain/OAuthTokenRepository" import authLogoutHandler from "@/common/authHandler/logout" @@ -70,6 +72,11 @@ export const gitHubClient = new AccessTokenRefreshingGitHubClient( }) ) +export const sessionValidator = new GitHubOrganizationSessionValidator( + gitHubClient, + GITHUB_ORGANIZATION_NAME +) + export const sessionProjectRepository = new SessionProjectRepository( new SessionDataRepository( new Auth0Session(), @@ -81,9 +88,12 @@ export const sessionProjectRepository = new SessionProjectRepository( ) export const projectDataSource = new CachingProjectDataSource( - new GitHubProjectDataSource( - gitHubClient, - GITHUB_ORGANIZATION_NAME + new SessionValidatingProjectDataSource( + sessionValidator, + new GitHubProjectDataSource( + gitHubClient, + GITHUB_ORGANIZATION_NAME + ) ), sessionProjectRepository ) diff --git a/src/features/auth/data/Auth0RefreshTokenReader.ts b/src/features/auth/data/Auth0RefreshTokenReader.ts index 57bf2cde..79fd6bfb 100644 --- a/src/features/auth/data/Auth0RefreshTokenReader.ts +++ b/src/features/auth/data/Auth0RefreshTokenReader.ts @@ -1,6 +1,6 @@ import { ManagementClient } from "auth0" +import { UnauthorizedError } from "@/common/errors" import IRefreshTokenReader from "../domain/IRefreshTokenReader" -import { UnauthorizedError } from "../domain/AuthError" interface Auth0RefreshTokenReaderConfig { domain: string diff --git a/src/features/auth/data/GitHubOAuthTokenRefresher.ts b/src/features/auth/data/GitHubOAuthTokenRefresher.ts index 68dd12c4..d5e88607 100644 --- a/src/features/auth/data/GitHubOAuthTokenRefresher.ts +++ b/src/features/auth/data/GitHubOAuthTokenRefresher.ts @@ -1,6 +1,6 @@ +import { UnauthorizedError } from "@/common/errors" import OAuthToken from "../domain/OAuthToken" import IOAuthTokenRefresher from "../domain/IOAuthTokenRefresher" -import { UnauthorizedError } from "../domain/AuthError" export interface GitHubOAuthTokenRefresherConfig { readonly clientId: string diff --git a/src/features/auth/domain/AuthError.ts b/src/features/auth/domain/AuthError.ts deleted file mode 100644 index 2e482071..00000000 --- a/src/features/auth/domain/AuthError.ts +++ /dev/null @@ -1 +0,0 @@ -export class UnauthorizedError extends Error {} diff --git a/src/features/auth/domain/OAuthTokenRepository.ts b/src/features/auth/domain/OAuthTokenRepository.ts index d8bdfe17..fd416386 100644 --- a/src/features/auth/domain/OAuthTokenRepository.ts +++ b/src/features/auth/domain/OAuthTokenRepository.ts @@ -1,7 +1,7 @@ import ZodJSONCoder from "@/common/utils/ZodJSONCoder" import IUserDataRepository from "@/common/userData/IUserDataRepository" +import { UnauthorizedError } from "@/common/errors" import IOAuthTokenRepository from "./IOAuthTokenRepository" -import { UnauthorizedError } from "./AuthError" import OAuthToken, { OAuthTokenSchema } from "./OAuthToken" export default class OAuthTokenRepository implements IOAuthTokenRepository { diff --git a/src/features/auth/domain/SessionOAuthTokenRepository.ts b/src/features/auth/domain/SessionOAuthTokenRepository.ts index c7942598..2f5d0905 100644 --- a/src/features/auth/domain/SessionOAuthTokenRepository.ts +++ b/src/features/auth/domain/SessionOAuthTokenRepository.ts @@ -1,7 +1,7 @@ +import { UnauthorizedError } from "../../../common/errors" import ZodJSONCoder from "../../../common/utils/ZodJSONCoder" import ISessionDataRepository from "@/common/userData/ISessionDataRepository" import ISessionOAuthTokenRepository from "./SessionOAuthTokenRepository" -import { UnauthorizedError } from "./AuthError" import OAuthToken, { OAuthTokenSchema } from "./OAuthToken" export default class SessionOAuthTokenRepository implements ISessionOAuthTokenRepository { diff --git a/src/features/auth/view/InvalidSessionPage.tsx b/src/features/auth/view/InvalidSessionPage.tsx new file mode 100644 index 00000000..99292a13 --- /dev/null +++ b/src/features/auth/view/InvalidSessionPage.tsx @@ -0,0 +1,22 @@ +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/client/InvalidSession.tsx b/src/features/auth/view/client/InvalidSession.tsx new file mode 100644 index 00000000..76cf8416 --- /dev/null +++ b/src/features/auth/view/client/InvalidSession.tsx @@ -0,0 +1,32 @@ +"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/projects/domain/SessionValidatingProjectDataSource.ts b/src/features/projects/domain/SessionValidatingProjectDataSource.ts new file mode 100644 index 00000000..942d0859 --- /dev/null +++ b/src/features/projects/domain/SessionValidatingProjectDataSource.ts @@ -0,0 +1,25 @@ +import { InvalidSessionError } from "../../../common/errors" +import ISessionValidator from "@/common/session/ISessionValidator" +import IProjectDataSource from "../domain/IProjectDataSource" +import Project from "../domain/Project" + +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() + } +}