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:
Bryan Helmkamp 2026-03-03 23:46:55 -05:00
parent 7b39b42fda
commit fb47745cc2
6 changed files with 168 additions and 12 deletions

View file

@ -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 };
}

View file

@ -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[];
}

View 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();
});
});
});

View file

@ -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") ?? "",
};

View file

@ -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);

View file

@ -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">