mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-07 08:27:12 +00:00
Add Tailscale authentication provider
Read Tailscale-User-Login/Name/Profile-Pic headers when web.auth.provider is "tailscale", checking login against required allowed_usernames list. Rename githubLogin → login across session/callback/shell for provider neutrality. Update app-shell loader and auth-login page to handle the new provider. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
7b39b42fda
commit
fb47745cc2
6 changed files with 168 additions and 12 deletions
|
|
@ -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 };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
}
|
||||
|
||||
|
|
|
|||
119
apps/arc-web/app/lib/session.server.test.ts
Normal file
119
apps/arc-web/app/lib/session.server.test.ts
Normal file
|
|
@ -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<string, unknown> = {};
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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<ReturnType<typeof getSessi
|
|||
}
|
||||
|
||||
export async function getUser(request: Request) {
|
||||
const { provider, allowed_usernames } = getAppConfig().web.auth;
|
||||
|
||||
if (provider === "tailscale") {
|
||||
const login = request.headers.get("Tailscale-User-Login");
|
||||
if (!login || !allowed_usernames.includes(login)) return null;
|
||||
return {
|
||||
userUrl: `tailscale:${login}`,
|
||||
login,
|
||||
name: request.headers.get("Tailscale-User-Name") ?? login,
|
||||
email: login,
|
||||
avatarUrl: request.headers.get("Tailscale-User-Profile-Pic") ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
const session = await getSession(request);
|
||||
const githubLogin = session.get("githubLogin");
|
||||
if (!githubLogin) return null;
|
||||
const login = session.get("login");
|
||||
if (!login) return null;
|
||||
return {
|
||||
userUrl: session.get("userUrl") ?? "",
|
||||
githubLogin,
|
||||
name: session.get("name") ?? githubLogin,
|
||||
login,
|
||||
name: session.get("name") ?? login,
|
||||
email: session.get("email") ?? "",
|
||||
avatarUrl: session.get("avatarUrl") ?? "",
|
||||
};
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ export async function loader({ request }: Route.LoaderArgs) {
|
|||
session.set("userUrl", `https://github.com/${profile.login}`);
|
||||
session.set("githubId", profile.id);
|
||||
session.set("githubNodeId", profile.node_id);
|
||||
session.set("githubLogin", profile.login);
|
||||
session.set("login", profile.login);
|
||||
session.set("name", profile.name ?? profile.login);
|
||||
session.set("email", primaryEmail);
|
||||
session.set("avatarUrl", profile.avatar_url);
|
||||
|
|
|
|||
|
|
@ -1,8 +1,14 @@
|
|||
import { redirect } from "react-router";
|
||||
import { AuthLayout } from "../components/auth-layout";
|
||||
import { getAppConfig } from "../lib/config.server";
|
||||
import { getGitHubOAuth, generateState } from "../lib/github.server";
|
||||
import type { Route } from "./+types/auth-login";
|
||||
|
||||
export function loader() {
|
||||
const { provider } = getAppConfig().web.auth;
|
||||
return { provider };
|
||||
}
|
||||
|
||||
export function action({ request }: Route.ActionArgs) {
|
||||
const github = getGitHubOAuth();
|
||||
const state = generateState();
|
||||
|
|
@ -15,7 +21,20 @@ export function action({ request }: Route.ActionArgs) {
|
|||
});
|
||||
}
|
||||
|
||||
export default function AuthLogin() {
|
||||
export default function AuthLogin({ loaderData }: Route.ComponentProps) {
|
||||
if (loaderData.provider === "tailscale") {
|
||||
return (
|
||||
<AuthLayout>
|
||||
<h1 className="text-center text-lg font-semibold text-fg">
|
||||
Access via Tailscale
|
||||
</h1>
|
||||
<p className="mt-2 text-center text-sm text-fg-3">
|
||||
This app is protected by Tailscale. Make sure you are connected to your Tailscale network and your account is authorized.
|
||||
</p>
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthLayout>
|
||||
<h1 className="text-center text-lg font-semibold text-fg">
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue