diff --git a/apps/arc-web/app/layouts/app-shell.tsx b/apps/arc-web/app/layouts/app-shell.tsx index 015040747..8bead3473 100644 --- a/apps/arc-web/app/layouts/app-shell.tsx +++ b/apps/arc-web/app/layouts/app-shell.tsx @@ -29,18 +29,21 @@ import type { Route } from "./+types/app-shell"; const DEMO_USER = { userUrl: "", - githubLogin: "demo", + login: "demo", name: "Demo User", email: "demo@example.com", avatarUrl: "https://github.githubassets.com/assets/GitHub-Mark-ea2971cee799.png", }; export async function loader({ request }: Route.LoaderArgs) { - const authDisabled = getAppConfig().web.auth.provider === "insecure_disabled"; - if (!authDisabled && !isGitHubAppConfigured()) { + const { provider } = getAppConfig().web.auth; + if (provider === "insecure_disabled") { + return { user: DEMO_USER }; + } + if (provider === "github" && !isGitHubAppConfigured()) { throw redirect("/setup"); } - const user = authDisabled ? DEMO_USER : await requireUser(request); + const user = await requireUser(request); return { user }; } diff --git a/apps/arc-web/app/lib/config.server.ts b/apps/arc-web/app/lib/config.server.ts index 7be9747c3..2cfe62191 100644 --- a/apps/arc-web/app/lib/config.server.ts +++ b/apps/arc-web/app/lib/config.server.ts @@ -4,7 +4,7 @@ import { join } from "node:path"; import { parse } from "smol-toml"; interface AuthConfig { - provider: "github" | "insecure_disabled"; + provider: "github" | "tailscale" | "insecure_disabled"; allowed_usernames: string[]; } diff --git a/apps/arc-web/app/lib/session.server.test.ts b/apps/arc-web/app/lib/session.server.test.ts new file mode 100644 index 000000000..652260418 --- /dev/null +++ b/apps/arc-web/app/lib/session.server.test.ts @@ -0,0 +1,119 @@ +import { describe, test, expect, beforeEach, mock } from "bun:test"; + +// --- Mocks (must be set up before importing module under test) --- + +let testAuthConfig = { provider: "github" as string, allowed_usernames: [] as string[] }; + +mock.module("./config.server", () => ({ + getAppConfig: () => ({ web: { auth: testAuthConfig } }), + reloadAppConfig: () => {}, + ARC_CONFIG_PATH: "/tmp/test.toml", +})); + +let sessionData: Record = {}; + +mock.module("./session-storage.server", () => ({ + createSqliteSessionStorage: () => ({ + getSession: async () => ({ + get: (key: string) => sessionData[key], + }), + commitSession: async () => "", + destroySession: async () => "", + }), +})); + +process.env.SESSION_SECRET = "test-secret"; + +const { getUser } = await import("./session.server"); + +// --- Tests --- + +describe("getUser", () => { + beforeEach(() => { + sessionData = {}; + testAuthConfig = { provider: "github", allowed_usernames: [] }; + }); + + describe("tailscale provider", () => { + beforeEach(() => { + testAuthConfig = { provider: "tailscale", allowed_usernames: ["user@example.com"] }; + }); + + test("returns user from headers when login is in allowed_usernames", async () => { + const request = new Request("http://localhost", { + headers: { + "Tailscale-User-Login": "user@example.com", + "Tailscale-User-Name": "Test User", + "Tailscale-User-Profile-Pic": "https://example.com/pic.jpg", + }, + }); + + const user = await getUser(request); + + expect(user).toEqual({ + userUrl: "tailscale:user@example.com", + login: "user@example.com", + name: "Test User", + email: "user@example.com", + avatarUrl: "https://example.com/pic.jpg", + }); + }); + + test("returns null when Tailscale-User-Login header is missing", async () => { + const request = new Request("http://localhost"); + + const user = await getUser(request); + + expect(user).toBeNull(); + }); + + test("returns null when login is not in allowed_usernames", async () => { + const request = new Request("http://localhost", { + headers: { + "Tailscale-User-Login": "stranger@example.com", + "Tailscale-User-Name": "Stranger", + }, + }); + + const user = await getUser(request); + + expect(user).toBeNull(); + }); + }); + + describe("github provider", () => { + beforeEach(() => { + testAuthConfig = { provider: "github", allowed_usernames: [] }; + }); + + test("returns user from session", async () => { + sessionData = { + userUrl: "https://github.com/octocat", + login: "octocat", + name: "Octocat", + email: "octocat@github.com", + avatarUrl: "https://github.com/octocat.png", + }; + const request = new Request("http://localhost"); + + const user = await getUser(request); + + expect(user).toEqual({ + userUrl: "https://github.com/octocat", + login: "octocat", + name: "Octocat", + email: "octocat@github.com", + avatarUrl: "https://github.com/octocat.png", + }); + }); + + test("returns null when session is empty", async () => { + sessionData = {}; + const request = new Request("http://localhost"); + + const user = await getUser(request); + + expect(user).toBeNull(); + }); + }); +}); diff --git a/apps/arc-web/app/lib/session.server.ts b/apps/arc-web/app/lib/session.server.ts index 358a11419..fbae025c7 100644 --- a/apps/arc-web/app/lib/session.server.ts +++ b/apps/arc-web/app/lib/session.server.ts @@ -1,11 +1,12 @@ import { redirect } from "react-router"; +import { getAppConfig } from "./config.server"; import { createSqliteSessionStorage } from "./session-storage.server"; interface SessionData { userUrl: string; githubId: number; githubNodeId: string; - githubLogin: string; + login: string; name: string; email: string; avatarUrl: string; @@ -36,13 +37,27 @@ export async function destroySession(session: Awaited +

+ Access via Tailscale +

+

+ This app is protected by Tailscale. Make sure you are connected to your Tailscale network and your account is authorized. +

+ + ); + } + return (