feat: completely overhaul the auth model

* Cookies are now encrypted JWTs (GHSA-wrqq-v7qw-r5w7)
* Authentication is stored in the SQLite database (auto-migrated)
* Session logic is much cleaner
This commit is contained in:
Aarnav Tale
2025-08-19 17:49:32 -04:00
parent 8cb91cd45b
commit d2c4f5eb2b
29 changed files with 628 additions and 505 deletions
+13 -6
View File
@@ -1,16 +1,23 @@
import { eq } from 'drizzle-orm';
import { LoaderFunctionArgs, redirect } from 'react-router';
import { LoadContext } from '~/server';
import { users } from '~/server/db/schema';
export async function loader({
request,
context,
}: LoaderFunctionArgs<LoadContext>) {
const session = await context.sessions.auth(request);
const user = session.get('user');
if (!user) {
try {
const { user } = await context.sessions.auth(request);
await context.db
.update(users)
.set({
onboarded: true,
})
.where(eq(users.sub, user.subject));
return redirect('/machines');
} catch {
return redirect('/login');
}
context.sessions.overrideOnboarding(user.subject, true);
return redirect('/machines');
}
+24 -38
View File
@@ -4,12 +4,7 @@ import { GrApple } from 'react-icons/gr';
import { ImFinder } from 'react-icons/im';
import { MdAndroid } from 'react-icons/md';
import { PiTerminalFill, PiWindowsLogoFill } from 'react-icons/pi';
import {
LoaderFunctionArgs,
NavLink,
redirect,
useLoaderData,
} from 'react-router';
import { LoaderFunctionArgs, NavLink, useLoaderData } from 'react-router';
import Button from '~/components/Button';
import Card from '~/components/Card';
import Link from '~/components/Link';
@@ -27,10 +22,6 @@ export async function loader({
context,
}: LoaderFunctionArgs<LoadContext>) {
const session = await context.sessions.auth(request);
const user = session.get('user');
if (!user) {
return redirect('/login');
}
// Try to determine the OS split between Linux, Windows, macOS, iOS, and Android
// We need to convert this to a known value to return it to the client so we can
@@ -60,11 +51,11 @@ export async function loader({
break;
}
let firstMachine: Machine | undefined = undefined;
let firstMachine: Machine | undefined;
try {
const { nodes } = await context.client.get<{ nodes: Machine[] }>(
'v1/node',
session.get('api_key')!,
session.api_key,
);
const node = nodes.find((n) => {
@@ -79,12 +70,7 @@ export async function loader({
return false;
}
const sessionUser = session.get('user');
if (!sessionUser) {
return false;
}
if (subject !== sessionUser.subject) {
if (subject !== session.user.subject) {
return false;
}
@@ -98,7 +84,7 @@ export async function loader({
}
return {
user,
user: session.user,
osValue,
firstMachine,
};
@@ -126,7 +112,7 @@ export default function Page() {
return (
<div className="fixed w-full h-screen flex items-center px-4">
<div className="w-fit mx-auto grid grid-cols-1 md:grid-cols-2 gap-4 mb-24">
<Card variant="flat" className="max-w-lg">
<Card className="max-w-lg" variant="flat">
<Card.Title className="mb-8">
Welcome!
<br />
@@ -138,9 +124,9 @@ export default function Page() {
</Card.Text>
<Options
className="my-4"
defaultSelectedKey={osValue}
label="Download Selector"
className="my-4"
>
<Options.Item
key="linux"
@@ -183,12 +169,12 @@ export default function Page() {
}
>
<a
href="https://pkgs.tailscale.com/stable/tailscale-setup-latest.exe"
aria-label="Download for Windows"
target="_blank"
href="https://pkgs.tailscale.com/stable/tailscale-setup-latest.exe"
rel="noreferrer"
target="_blank"
>
<Button variant="heavy" className="my-4 w-full">
<Button className="my-4 w-full" variant="heavy">
Download for Windows
</Button>
</a>
@@ -206,12 +192,12 @@ export default function Page() {
}
>
<a
href="https://pkgs.tailscale.com/stable/Tailscale-latest-macos.pkg"
aria-label="Download for macOS"
target="_blank"
href="https://pkgs.tailscale.com/stable/Tailscale-latest-macos.pkg"
rel="noreferrer"
target="_blank"
>
<Button variant="heavy" className="my-4 w-full">
<Button className="my-4 w-full" variant="heavy">
Download for macOS
</Button>
</a>
@@ -238,12 +224,12 @@ export default function Page() {
}
>
<a
href="https://apps.apple.com/us/app/tailscale/id1470499037"
aria-label="Download for iOS"
target="_blank"
href="https://apps.apple.com/us/app/tailscale/id1470499037"
rel="noreferrer"
target="_blank"
>
<Button variant="heavy" className="my-4 w-full">
<Button className="my-4 w-full" variant="heavy">
Download for iOS
</Button>
</a>
@@ -261,12 +247,12 @@ export default function Page() {
}
>
<a
href="https://play.google.com/store/apps/details?id=com.tailscale.ipn"
aria-label="Download for Android"
target="_blank"
href="https://play.google.com/store/apps/details?id=com.tailscale.ipn"
rel="noreferrer"
target="_blank"
>
<Button variant="heavy" className="my-4 w-full">
<Button className="my-4 w-full" variant="heavy">
Download for Android
</Button>
</a>
@@ -287,8 +273,8 @@ export default function Page() {
<div className="border border-headplane-100 dark:border-headplane-800 rounded-xl p-4">
<div className="flex items-start gap-4">
<StatusCircle
isOnline={firstMachine.online}
className="size-6 mt-3"
isOnline={firstMachine.online}
/>
<div>
<p className="font-semibold leading-snug">
@@ -300,7 +286,7 @@ export default function Page() {
<div className="mt-6">
<p className="text-sm font-semibold">IP Addresses</p>
{firstMachine.ipAddresses.map((ip) => (
<p key={ip} className="text-xs font-mono opacity-50">
<p className="text-xs font-mono opacity-50" key={ip}>
{ip}
</p>
))}
@@ -308,8 +294,8 @@ export default function Page() {
</div>
</div>
</div>
<NavLink to="/">
<Button variant="heavy" className="w-full">
<NavLink to="/onboarding/skip">
<Button className="w-full" variant="heavy">
Continue
</Button>
</NavLink>
@@ -335,7 +321,7 @@ export default function Page() {
</div>
)}
</Card>
<NavLink to="/onboarding/skip" className="col-span-2 w-max mx-auto">
<NavLink className="col-span-2 w-max mx-auto" to="/onboarding/skip">
<Button className="flex items-center gap-1">
I already know what I'm doing
<ArrowRight className="p-1" />
+28 -29
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react';
import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';
import { useLoaderData, useSubmit } from 'react-router';
import { useLoaderData } from 'react-router';
import type { LoadContext } from '~/server';
import { Capabilities } from '~/server/web/roles';
import { Machine, User } from '~/types';
@@ -32,11 +32,8 @@ export async function loader({
);
const [machines, apiUsers] = await Promise.all([
context.client.get<{ nodes: Machine[] }>(
'v1/node',
session.get('api_key')!,
),
context.client.get<{ users: User[] }>('v1/user', session.get('api_key')!),
context.client.get<{ nodes: Machine[] }>('v1/node', session.api_key),
context.client.get<{ users: User[] }>('v1/user', session.api_key),
]);
const users = apiUsers.users.map((user) => ({
@@ -44,30 +41,32 @@ export async function loader({
machines: machines.nodes.filter((machine) => machine.user.id === user.id),
}));
const roles = users
.sort((a, b) => a.name.localeCompare(b.name))
.map((user) => {
if (user.provider !== 'oidc') {
return 'no-oidc';
}
if (user.provider === 'oidc' && user.providerId) {
// For some reason, headscale makes providerID a url where the
// last component is the subject, so we need to strip that out
const subject = user.providerId.split('/').pop();
if (!subject) {
return 'invalid-oidc';
const roles = await Promise.all(
users
.sort((a, b) => a.name.localeCompare(b.name))
.map(async (user) => {
if (user.provider !== 'oidc') {
return 'no-oidc';
}
const role = context.sessions.roleForSubject(subject);
return role ?? 'no-role';
}
if (user.provider === 'oidc' && user.providerId) {
// For some reason, headscale makes providerID a url where the
// last component is the subject, so we need to strip that out
const subject = user.providerId.split('/').pop();
if (!subject) {
return 'invalid-oidc';
}
// No role means the user is not registered in Headplane, but they
// are in Headscale. We also need to handle what happens if someone
// logs into the UI and they don't have a Headscale setup.
return 'no-role';
});
const role = await context.sessions.roleForSubject(subject);
return role ?? 'no-role';
}
// No role means the user is not registered in Headplane, but they
// are in Headscale. We also need to handle what happens if someone
// logs into the UI and they don't have a Headscale setup.
return 'no-role';
}),
);
let magic: string | undefined;
if (context.hs.readable()) {
@@ -107,7 +106,7 @@ export default function Page() {
<p className="mb-8 text-md">
Manage the users in your network and their permissions.
</p>
<ManageBanner oidc={data.oidc} isDisabled={!data.writable} />
<ManageBanner isDisabled={!data.writable} oidc={data.oidc} />
<table className="table-auto w-full rounded-lg">
<thead className="text-headplane-600 dark:text-headplane-300">
<tr className="text-left px-0.5">
@@ -128,8 +127,8 @@ export default function Page() {
.map((user) => (
<UserRow
key={user.id}
user={user}
role={data.roles[users.indexOf(user)]}
user={user}
/>
))}
</tbody>
+2 -3
View File
@@ -1,7 +1,6 @@
import { ActionFunctionArgs, Session, data } from 'react-router';
import { ActionFunctionArgs, data } from 'react-router';
import type { LoadContext } from '~/server';
import { Capabilities, Roles } from '~/server/web/roles';
import { AuthSession } from '~/server/web/sessions';
import { User } from '~/types';
import { data400, data403 } from '~/utils/res';
@@ -15,7 +14,7 @@ export async function userAction({
throw data403('You do not have permission to update users');
}
const apiKey = session.get('api_key')!;
const apiKey = session.api_key;
const formData = await request.formData();
const action = formData.get('action_id')?.toString();
if (!action) {