feat: initial auth rework

This commit is contained in:
Aarnav Tale
2026-03-07 17:10:34 -05:00
parent 6d70497758
commit 0f8e192b5c
39 changed files with 2110 additions and 1684 deletions
+3 -11
View File
@@ -66,19 +66,11 @@ export async function loginAction({ request, context }: Route.LoaderArgs) {
};
}
const expiresDays = Math.round((expiry.getTime() - Date.now()) / 1000 / 60 / 60 / 24);
return redirect("/machines", {
headers: {
"Set-Cookie": await context.sessions.createSession(
{
api_key: apiKey,
user: {
subject: "unknown-non-oauth",
name: `${lookup.prefix}...`,
email: `expires@${expiresDays.toString()}-days`,
},
},
"Set-Cookie": await context.auth.createApiKeySession(
apiKey,
`${lookup.prefix}...`,
expiry.getTime() - Date.now(),
),
},
+2 -3
View File
@@ -10,7 +10,6 @@ import Link from "~/components/Link";
import { useLiveData } from "~/utils/live-data";
import type { Route } from "./+types/page";
import { loginAction } from "./action";
import { OidcConfigErrorNotice, OidcDiscoveryFailedNotice } from "./config-error";
import Logout from "./logout";
@@ -18,14 +17,14 @@ import { OidcErrorNotice } from "./oidc-error";
export async function loader({ request, context }: Route.LoaderArgs) {
try {
await context.sessions.auth(request);
await context.auth.require(request);
return redirect("/machines");
} catch {}
const qp = new URL(request.url).searchParams;
const urlState = qp.get("s") ?? undefined;
const oidcConnector = await context.oidcConnector?.get();
const oidcConnector = await context.oidc?.connector.get();
// MARK: This works because the OIDC connector will always return false
// for `isExclusive` if the OIDC config isn't usable.
+18 -22
View File
@@ -1,29 +1,25 @@
import { type ActionFunctionArgs, redirect } from 'react-router';
import type { LoadContext } from '~/server';
import { type ActionFunctionArgs, redirect } from "react-router";
import type { LoadContext } from "~/server";
export async function loader() {
return redirect('/machines');
return redirect("/machines");
}
export async function action({
request,
context,
}: ActionFunctionArgs<LoadContext>) {
try {
await context.sessions.auth(request);
} catch {
redirect('/login');
}
export async function action({ request, context }: ActionFunctionArgs<LoadContext>) {
try {
await context.auth.require(request);
} catch {
redirect("/login");
}
// When API key is disabled, we need to explicitly redirect
// with a logout state to prevent auto login again.
const url = context.config.oidc?.disable_api_key_login
? '/login?s=logout'
: '/login';
// When API key is disabled, we need to explicitly redirect
// with a logout state to prevent auto login again.
const url = context.config.oidc?.disable_api_key_login ? "/login?s=logout" : "/login";
return redirect(url, {
headers: {
'Set-Cookie': await context.sessions.destroySession(),
},
});
return redirect(url, {
headers: {
"Set-Cookie": await context.auth.destroySession(request),
},
});
}
+20 -41
View File
@@ -1,18 +1,16 @@
import { count, eq } from "drizzle-orm";
import { createHash } from "node:crypto";
import * as oidc from "openid-client";
import { data, redirect } from "react-router";
import { ulid } from "ulidx";
import { users } from "~/server/db/schema";
import { Roles } from "~/server/web/roles";
import { findHeadscaleUserBySubject } from "~/server/web/headscale-identity";
import log from "~/utils/log";
import { createOidcStateCookie } from "~/utils/oidc-state";
import type { Route } from "./+types/oidc-callback";
export async function loader({ request, context }: Route.LoaderArgs) {
const oidcConnector = await context.oidcConnector?.get();
const oidcConnector = await context.oidc?.connector.get();
if (!oidcConnector?.isValid) {
throw data("OIDC is not enabled or misconfigured", { status: 501 });
}
@@ -82,47 +80,28 @@ export async function loader({ request, context }: Route.LoaderArgs) {
})()
: userInfo.picture;
const [{ count: ownerCount }] = await context.db
.select({ count: count() })
.from(users)
.where(eq(users.caps, Roles.owner));
const hasUsers = await context.auth.hasAnyUsers();
const defaultRole = hasUsers ? "member" : "owner";
const userId = await context.auth.findOrCreateUser(claims.sub, defaultRole);
const needsOwner = ownerCount === 0;
if (needsOwner) {
await context.db
.insert(users)
.values({
id: ulid(),
sub: claims.sub,
caps: Roles.owner,
})
.onConflictDoUpdate({
target: users.sub,
set: { caps: Roles.owner },
});
} else {
await context.db
.insert(users)
.values({
id: ulid(),
sub: claims.sub,
caps: Roles.member,
})
.onConflictDoNothing();
try {
const hsApi = context.hsApi.getRuntimeClient(context.oidc!.apiKey);
const hsUsers = await hsApi.getUsers();
const hsUser = findHeadscaleUserBySubject(hsUsers, claims.sub, userInfo.email);
if (hsUser) {
await context.auth.linkHeadscaleUser(userId, hsUser.id);
}
} catch (error) {
log.warn("auth", "Failed to link Headscale user: %s", String(error));
}
return redirect("/", {
headers: {
"Set-Cookie": await context.sessions.createSession({
api_key: oidcConnector.apiKey,
user: {
subject: claims.sub,
username,
name,
email: userInfo.email,
picture,
},
"Set-Cookie": await context.auth.createOidcSession(userId, {
name,
email: userInfo.email,
username,
picture,
}),
},
});
+2 -2
View File
@@ -8,11 +8,11 @@ import type { Route } from "./+types/oidc-start";
export async function loader({ request, context }: Route.LoaderArgs) {
try {
await context.sessions.auth(request);
await context.auth.require(request);
return redirect("/");
} catch {}
const oidcConnector = await context.oidcConnector?.get();
const oidcConnector = await context.oidc?.connector.get();
if (!oidcConnector?.isValid) {
throw data("OIDC is not enabled or misconfigured", { status: 501 });
}
-110
View File
@@ -1,110 +0,0 @@
import { ClockIcon, LogOut, RefreshCw, UserCheck } from "lucide-react";
import { Form, redirect } from "react-router";
import Button from "~/components/Button";
import Card from "~/components/Card";
import { Capabilities } from "~/server/web/roles";
import toast from "~/utils/toast";
import type { Route } from "./+types/pending-approval";
export async function loader({ request, context }: Route.LoaderArgs) {
try {
const session = await context.sessions.auth(request);
// API key users skip this page
if (session.user.subject === "unknown-non-oauth") {
return redirect("/machines");
}
const hasAccess = await context.sessions.check(request, Capabilities.ui_access);
if (hasAccess) {
return redirect("/machines");
}
const url = context.config.headscale.public_url ?? context.config.headscale.url;
return {
user: session.user,
url,
};
} catch {
return redirect("/login", {
headers: {
"Set-Cookie": await context.sessions.destroySession(),
},
});
}
}
export default function PendingApproval({ loaderData }: Route.ComponentProps) {
return (
<div className="flex h-screen w-screen items-center justify-center">
<Card className="m-4 max-w-md sm:m-0">
<div className="mb-4 flex items-center gap-3">
<div className="rounded-full bg-amber-100 p-3 dark:bg-amber-900">
<ClockIcon className="h-8 w-8 text-amber-600 dark:text-amber-400" />
</div>
<div>
<Card.Title className="mb-0 text-xl">Approval Required</Card.Title>
<p className="text-headplane-500 text-sm">
{loaderData.user.email ?? loaderData.user.name}
</p>
</div>
</div>
<Card.Text className="mb-4">
Your account has been created but requires approval from an administrator before you can
access the management console.
</Card.Text>
<div className="bg-headplane-50 dark:bg-headplane-900 mb-4 rounded-lg p-4">
<div className="mb-2 flex items-center gap-2">
<UserCheck className="text-headplane-500 h-5 w-5" />
<p className="font-medium">What happens next?</p>
</div>
<ul className="text-headplane-600 dark:text-headplane-400 list-inside list-disc space-y-1 text-sm">
<li>An administrator will review your account</li>
<li>Once approved, you will receive the appropriate access level</li>
<li>This page will automatically redirect you once approved</li>
</ul>
</div>
<Card.Text className="mb-4 text-sm">
In the meantime, you can still connect your devices to the Tailnet using the command
below:
</Card.Text>
<Button
className="w-full font-mono text-sm"
variant="light"
onPress={async () => {
await navigator.clipboard.writeText(`tailscale up --login-server=${loaderData.url}`);
toast("Copied to clipboard");
}}
>
tailscale up --login-server={loaderData.url}
</Button>
<p className="mt-1 text-center text-xs opacity-50">Click to copy the command</p>
<div className="bg-headplane-100 dark:bg-headplane-800 mb-4 flex items-center justify-center gap-2 rounded-lg p-3 text-sm">
<RefreshCw className="text-headplane-500 h-4 w-4 animate-spin" />
<span className="text-headplane-600 dark:text-headplane-400">
Checking for approval automatically...
</span>
</div>
<Form action="/logout" method="post">
<Button
type="submit"
variant="heavy"
className="flex w-full items-center justify-center gap-2"
>
<LogOut className="h-4 w-4" />
Sign Out
</Button>
</Form>
</Card>
</div>
);
}