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
+2
View File
@@ -5,6 +5,7 @@
- Begin using a new SQLite database file in `/var/lib/headplane/hp_persist.db`.
- The database is created automatically if it does not exist.
- It currently stores SSH connection details and HostInfo for the agent.
- User information is automatically migrated from the previous database.
- The docker container now runs in a distroless image (closes [#255](https://github.com/tale/headplane/issues/255)).
- A debug version of the container that runs as root and has a shell is available as `ghcr.io/tale/headplane:<version>-shell`.
- Removing a Split DNS record will no longer make the split domain unresolvable by clients (closes [#231](https://github.com/tale/headplane/issues/231)).
@@ -19,6 +20,7 @@
- See the full reference in the [docs](https://github.com/tale/headplane/blob/main/docs/Configuration.md#sensitive-values)
- The nix overlay build is fixed for the SSH module (via [#282](https://github.com/tale/headplane/pull/282))
- Switch our build processes to use TypeScript Go and Rolldown Vite for better build and type-check performance.
- Cookies are now encrypted JWTs, preserving API key secrets (*GHSA-wrqq-v7qw-r5w7*)
### 0.6.0 (May 25, 2025)
- Headplane 0.6.0 now requires **Headscale 0.26.0** or newer.
+5 -7
View File
@@ -18,13 +18,13 @@ export async function loader({
// TODO: Notify in the logs or the UI that OIDC auth key is wrong if enabled
if (healthy) {
try {
await context.client.get('v1/apikey', session.get('api_key')!);
await context.client.get('v1/apikey', session.api_key);
} catch (error) {
if (error instanceof ResponseError) {
log.debug('api', 'API Key validation failed %o', error);
return redirect('/login', {
headers: {
'Set-Cookie': await context.sessions.destroy(session),
'Set-Cookie': await context.sessions.destroySession(),
},
});
}
@@ -38,11 +38,9 @@ export async function loader({
export default function Layout() {
return (
<>
<main className="container mx-auto overscroll-contain mt-4 mb-24">
<Outlet />
</main>
</>
<main className="container mx-auto overscroll-contain mt-4 mb-24">
<Outlet />
</main>
);
}
+19 -70
View File
@@ -1,3 +1,4 @@
import { eq } from 'drizzle-orm';
import { CircleCheckIcon } from 'lucide-react';
import {
LoaderFunctionArgs,
@@ -10,9 +11,8 @@ import Card from '~/components/Card';
import Footer from '~/components/Footer';
import Header from '~/components/Header';
import type { LoadContext } from '~/server';
import { users } from '~/server/db/schema';
import { Capabilities } from '~/server/web/roles';
import { User } from '~/types';
import log from '~/utils/log';
import toast from '~/utils/toast';
// This loads the bare minimum for the application to function
@@ -23,72 +23,18 @@ export async function loader({
}: LoaderFunctionArgs<LoadContext>) {
try {
const session = await context.sessions.auth(request);
if (!session.has('api_key')) {
// There is a session, but it's not valid
return redirect('/login', {
headers: {
'Set-Cookie': await context.sessions.destroy(session),
},
});
}
if (
context.oidc &&
session.user.subject !== 'unknown-non-oauth' &&
!request.url.endsWith('/onboarding')
) {
const [user] = await context.db
.select()
.from(users)
.where(eq(users.sub, session.user.subject))
.limit(1);
// Onboarding is only a feature of the OIDC flow
if (context.oidc && !request.url.endsWith('/onboarding')) {
let onboarded = false;
const sessionUser = session.get('user');
if (sessionUser) {
if (context.sessions.onboardForSubject(sessionUser.subject)) {
// Assume onboarded
onboarded = true;
} else {
try {
const { users } = await context.client.get<{ users: User[] }>(
'v1/user',
session.get('api_key')!,
);
if (users.length === 0) {
onboarded = false;
}
const user = users.find((u) => {
if (u.provider !== 'oidc') {
return false;
}
// For some reason, headscale makes providerID a url where the
// last component is the subject, so we need to strip that out
const subject = u.providerId?.split('/').pop();
if (!subject) {
return false;
}
const sessionUser = session.get('user');
if (!sessionUser) {
return false;
}
if (context.sessions.onboardForSubject(sessionUser.subject)) {
// Assume onboarded
return true;
}
return subject === sessionUser.subject;
});
if (user) {
onboarded = true;
}
} catch (e) {
// If we cannot lookup users, just assume our user is onboarded
log.debug('api', 'Failed to lookup users %o', e);
onboarded = true;
}
}
}
if (!onboarded) {
if (!user?.onboarded) {
return redirect('/onboarding');
}
}
@@ -99,7 +45,7 @@ export async function loader({
url: context.config.headscale.public_url ?? context.config.headscale.url,
configAvailable: context.hs.readable(),
debug: context.config.debug,
user: session.get('user'),
user: session.user,
uiAccess: check,
access: {
ui: await context.sessions.check(request, Capabilities.ui_access),
@@ -119,8 +65,11 @@ export async function loader({
healthy: await context.client.healthcheck(),
};
} catch {
// No session, so we can just return
return redirect('/login');
return redirect('/login', {
headers: {
'Set-Cookie': await context.sessions.destroySession(),
},
});
}
}
+1 -1
View File
@@ -31,7 +31,7 @@ export async function aclAction({
const { policy, updatedAt } = await context.client.put<{
policy: string;
updatedAt: string;
}>('v1/policy', session.get('api_key')!, {
}>('v1/policy', session.api_key, {
policy: policyData,
});
+1 -1
View File
@@ -34,7 +34,7 @@ export async function aclLoader({
const { policy, updatedAt } = await context.client.get<{
policy: string;
updatedAt: string | null;
}>('v1/policy', session.get('api_key')!);
}>('v1/policy', session.api_key);
// Successfully loaded the policy, mark it as readable
// If `updatedAt` is null, it means the policy is in file mode.
+11 -13
View File
@@ -60,25 +60,23 @@ export async function loginAction({
};
}
// Set the session
const session = await context.sessions.getOrCreate(request);
const expiresDays = Math.round(
(expiry.getTime() - Date.now()) / 1000 / 60 / 60 / 24,
);
session.set('state', 'auth');
session.set('api_key', apiKey);
session.set('user', {
subject: 'unknown-non-oauth',
name: `${lookup.prefix}...`,
email: `expires@${expiresDays.toString()}-days`,
});
return redirect('/machines', {
headers: {
'Set-Cookie': await context.sessions.commit(session, {
maxAge: expiry.getTime() - Date.now(),
}),
'Set-Cookie': await context.sessions.createSession(
{
api_key: apiKey,
user: {
subject: 'unknown-non-oauth',
name: `${lookup.prefix}...`,
email: `expires@${expiresDays.toString()}-days`,
},
},
expiry.getTime() - Date.now(),
),
},
});
} catch (error) {
+7 -9
View File
@@ -1,10 +1,10 @@
import { useEffect } from 'react';
import {
ActionFunctionArgs,
data,
Form,
LoaderFunctionArgs,
Link as RemixLink,
data,
redirect,
useActionData,
useLoaderData,
@@ -24,10 +24,8 @@ export async function loader({
context,
}: LoaderFunctionArgs<LoadContext>) {
try {
const session = await context.sessions.auth(request);
if (session.has('api_key')) {
return redirect('/machines');
}
await context.sessions.auth(request);
return redirect('/machines');
} catch {}
const qp = new URL(request.url).searchParams;
@@ -104,26 +102,26 @@ export default function Page() {
terminal.
</Card.Text>
<Input
className="mt-8 mb-2"
isRequired
labelHidden
label="API Key"
labelHidden
name="api_key"
placeholder="API Key"
type="password"
className="mt-8 mb-2"
/>
{formData?.success === false ? (
<Card.Text className="text-sm mb-2 text-red-600 dark:text-red-300">
{formData.message}
</Card.Text>
) : undefined}
<Button className="w-full" variant="heavy" type="submit">
<Button className="w-full" type="submit" variant="heavy">
Sign In
</Button>
</Form>
{oidc ? (
<RemixLink to="/oidc/start">
<Button variant="light" className="w-full mt-2">
<Button className="w-full mt-2" variant="light">
Single Sign-On
</Button>
</RemixLink>
+5 -4
View File
@@ -9,9 +9,10 @@ export async function action({
request,
context,
}: ActionFunctionArgs<LoadContext>) {
const session = await context.sessions.auth(request);
if (!session.has('api_key')) {
return redirect('/login');
try {
await context.sessions.auth(request);
} catch {
redirect('/login');
}
// When API key is disabled, we need to explicitly redirect
@@ -22,7 +23,7 @@ export async function action({
return redirect(url, {
headers: {
'Set-Cookie': await context.sessions.destroy(session),
'Set-Cookie': await context.sessions.destroySession(),
},
});
}
+46 -17
View File
@@ -1,9 +1,19 @@
import { type LoaderFunctionArgs, Session, redirect } from 'react-router';
import { count } from 'drizzle-orm';
import { createCookie, type LoaderFunctionArgs, redirect } from 'react-router';
import { ulid } from 'ulidx';
import type { LoadContext } from '~/server';
import type { AuthSession, OidcFlowSession } from '~/server/web/sessions';
import { users } from '~/server/db/schema';
import { Roles } from '~/server/web/roles';
import { finishAuthFlow, formatError } from '~/utils/oidc';
import { send } from '~/utils/res';
interface OidcFlowSession {
state: string;
nonce: string;
code_verifier: string;
redirect_uri: string;
}
export async function loader({
request,
context,
@@ -18,13 +28,21 @@ export async function loader({
return redirect('/login');
}
const session = await context.sessions.getOrCreate<OidcFlowSession>(request);
if (session.get('state') !== 'flow') {
return redirect('/login'); // Haven't started an OIDC flow
const cookie = createCookie('__oidc_auth_flow', {
httpOnly: true,
maxAge: 300, // 5 minutes
});
const data: OidcFlowSession | null = await cookie.parse(
request.headers.get('Cookie'),
);
if (data === null) {
console.warn('OIDC flow session not found');
return redirect('/login');
}
const payload = session.get('oidc')!;
const { code_verifier, state, nonce, redirect_uri } = payload;
const { code_verifier, state, nonce, redirect_uri } = data;
if (!code_verifier || !state || !nonce || !redirect_uri) {
return send({ error: 'Missing OIDC state' }, { status: 400 });
}
@@ -43,19 +61,30 @@ export async function loader({
try {
const user = await finishAuthFlow(context.oidc, flowOptions);
session.unset('oidc');
const userSession = session as Session<AuthSession>;
// TODO: This is breaking, to stop the "over-generation" of API
// keys because they are currently non-deletable in the headscale
// database. Look at this in the future once we have a solution
// or we have permissioned API keys.
userSession.set('user', user);
userSession.set('api_key', context.config.oidc?.headscale_api_key!);
userSession.set('state', 'auth');
const [{ count: userCount }] = await context.db
.select({ count: count() })
.from(users);
await context.db
.insert(users)
.values({
id: ulid(),
sub: user.subject,
caps: userCount === 0 ? Roles.owner : Roles.member,
})
.onConflictDoNothing();
return redirect('/machines', {
headers: {
'Set-Cookie': await context.sessions.commit(userSession),
'Set-Cookie': await context.sessions.createSession({
// TODO: This is breaking, to stop the "over-generation" of API
// keys because they are currently non-deletable in the headscale
// database. Look at this in the future once we have a solution
// or we have permissioned API keys.
api_key: context.config.oidc?.headscale_api_key!,
user,
}),
},
});
} catch (error) {
+17 -17
View File
@@ -1,42 +1,42 @@
import { type LoaderFunctionArgs, Session, redirect } from 'react-router';
import { createCookie, type LoaderFunctionArgs, redirect } from 'react-router';
import type { LoadContext } from '~/server';
import { AuthSession, OidcFlowSession } from '~/server/web/sessions';
import { beginAuthFlow, getRedirectUri } from '~/utils/oidc';
export async function loader({
request,
context,
}: LoaderFunctionArgs<LoadContext>) {
const session = await context.sessions.getOrCreate<OidcFlowSession>(request);
if ((session as Session<AuthSession>).has('api_key')) {
try {
await context.sessions.auth(request);
return redirect('/machines');
}
} catch {}
if (!context.oidc) {
if (!context.oidc || !context.config.oidc) {
throw new Error('OIDC is not enabled');
}
const cookie = createCookie('__oidc_auth_flow', {
httpOnly: true,
maxAge: 300, // 5 minutes
});
const redirectUri =
context.config.oidc?.redirect_uri ?? getRedirectUri(request);
const data = await beginAuthFlow(
context.oidc,
redirectUri,
// We can't get here without the OIDC config being defined
context.config.oidc!.token_endpoint_auth_method,
context.config.oidc.token_endpoint_auth_method,
);
session.set('state', 'flow');
session.set('oidc', {
state: data.state,
nonce: data.nonce,
code_verifier: data.codeVerifier,
redirect_uri: redirectUri,
});
return redirect(data.url, {
status: 302,
headers: {
'Set-Cookie': await context.sessions.commit(session),
'Set-Cookie': await cookie.serialize({
state: data.state,
nonce: data.nonce,
code_verifier: data.codeVerifier,
redirect_uri: redirectUri,
}),
},
});
}
+2 -2
View File
@@ -14,7 +14,7 @@ export async function machineAction({
);
const formData = await request.formData();
const apiKey = session.get('api_key')!;
const apiKey = session.api_key;
const action = formData.get('action_id')?.toString();
if (!action) {
@@ -55,7 +55,7 @@ export async function machineAction({
}
if (
node.user.providerId?.split('/').pop() !== session.get('user')!.subject &&
node.user.providerId?.split('/').pop() !== session.user.subject &&
!check
) {
throw data('You do not have permission to act on this machine', {
+21 -21
View File
@@ -39,9 +39,9 @@ export async function loader({
const [machine, { users }] = await Promise.all([
context.client.get<{ node: Machine }>(
`v1/node/${params.id}`,
session.get('api_key')!,
session.api_key,
),
context.client.get<{ users: User[] }>('v1/user', session.get('api_key')!),
context.client.get<{ users: User[] }>('v1/user', session.api_key),
]);
const lookup = await context.agents?.lookup([machine.node.nodeKey]);
@@ -77,7 +77,7 @@ export default function Page() {
return (
<div>
<p className="mb-8 text-md">
<RemixLink to="/machines" className="font-medium">
<RemixLink className="font-medium" to="/machines">
All Machines
</RemixLink>
<span className="mx-2">/</span>
@@ -91,9 +91,9 @@ export default function Page() {
>
<span className="flex items-baseline gap-x-4 text-sm">
<h1 className="text-2xl font-medium">{node.givenName}</h1>
<StatusCircle isOnline={node.online} className="w-4 h-4" />
<StatusCircle className="w-4 h-4" isOnline={node.online} />
</span>
<MenuOptions isFullButton node={node} users={users} magic={magic} />
<MenuOptions isFullButton magic={magic} node={node} users={users} />
</div>
<div className="flex gap-1 mb-4">
<div className="border-r border-headplane-100 dark:border-headplane-800 p-2 pr-4">
@@ -123,14 +123,14 @@ export default function Page() {
</div>
</div>
</div>
<Routes node={node} isOpen={showRouting} setIsOpen={setShowRouting} />
<Routes isOpen={showRouting} node={node} setIsOpen={setShowRouting} />
<h2 className="text-xl font-medium mt-8">Subnets & Routing</h2>
<div className="flex items-center justify-between mb-4">
<p>
Subnets let you expose physical network routes onto Tailscale.{' '}
<Link
to="https://tailscale.com/kb/1019/subnets"
name="Tailscale Subnets Documentation"
to="https://tailscale.com/kb/1019/subnets"
>
Learn More
</Link>
@@ -138,11 +138,11 @@ export default function Page() {
<Button onPress={() => setShowRouting(true)}>Review</Button>
</div>
<Card
variant="flat"
className={cn(
'w-full max-w-full grid sm:grid-cols-2',
'md:grid-cols-4 gap-8 mr-2 text-sm mb-8',
)}
variant="flat"
>
<div>
<span className="text-headplane-600 dark:text-headplane-300 flex items-center gap-x-1">
@@ -166,11 +166,11 @@ export default function Page() {
)}
</div>
<Button
onPress={() => setShowRouting(true)}
className={cn(
'px-1.5 py-0.5 rounded-md mt-1.5',
'text-blue-500 dark:text-blue-400',
)}
onPress={() => setShowRouting(true)}
>
Edit
</Button>
@@ -198,11 +198,11 @@ export default function Page() {
)}
</div>
<Button
onPress={() => setShowRouting(true)}
className={cn(
'px-1.5 py-0.5 rounded-md mt-1.5',
'text-blue-500 dark:text-blue-400',
)}
onPress={() => setShowRouting(true)}
>
Edit
</Button>
@@ -233,11 +233,11 @@ export default function Page() {
)}
</div>
<Button
onPress={() => setShowRouting(true)}
className={cn(
'px-1.5 py-0.5 rounded-md mt-1.5',
'text-blue-500 dark:text-blue-400',
)}
onPress={() => setShowRouting(true)}
>
Edit
</Button>
@@ -249,15 +249,15 @@ export default function Page() {
issues.
</p>
<Card
variant="flat"
className="w-full max-w-full grid grid-cols-1 lg:grid-cols-2 gap-y-2 sm:gap-x-12"
variant="flat"
>
<div className="flex flex-col gap-1">
<Attribute name="Creator" value={node.user.name || node.user.email} />
<Attribute name="Machine name" value={node.givenName} />
<Attribute
tooltip="OS hostname is published by the machines operating system and is used as the default name for the machine."
name="OS hostname"
tooltip="OS hostname is published by the machines operating system and is used as the default name for the machine."
value={node.name}
/>
{stats ? (
@@ -267,14 +267,14 @@ export default function Page() {
</>
) : undefined}
<Attribute
tooltip="ID for this machine. Used in the Headscale API."
name="ID"
tooltip="ID for this machine. Used in the Headscale API."
value={node.id}
/>
<Attribute
isCopyable
tooltip="Public key which uniquely identifies this machine."
name="Node key"
tooltip="Public key which uniquely identifies this machine."
value={node.nodeKey}
/>
<Attribute
@@ -311,27 +311,27 @@ export default function Page() {
</p>
<Attribute
isCopyable
tooltip="This machines IPv4 address within your tailnet (your private Tailscale network)."
name="Tailscale IPv4"
tooltip="This machines IPv4 address within your tailnet (your private Tailscale network)."
value={getIpv4Address(node.ipAddresses)}
/>
<Attribute
isCopyable
tooltip="This machines IPv6 address within your tailnet (your private Tailscale network). Connections within your tailnet support IPv6 even if your ISP does not."
name="Tailscale IPv6"
tooltip="This machines IPv6 address within your tailnet (your private Tailscale network). Connections within your tailnet support IPv6 even if your ISP does not."
value={getIpv6Address(node.ipAddresses)}
/>
<Attribute
isCopyable
tooltip="Users of your tailnet can use this DNS short name to access this machine."
name="Short domain"
tooltip="Users of your tailnet can use this DNS short name to access this machine."
value={node.givenName}
/>
{magic ? (
<Attribute
isCopyable
tooltip="Users of your tailnet can use this DNS name to access this machine."
name="Full domain"
tooltip="Users of your tailnet can use this DNS name to access this machine."
value={`${node.givenName}.${magic}`}
/>
) : undefined}
@@ -341,13 +341,13 @@ export default function Page() {
Client Connectivity
</p>
<Attribute
tooltip="Whether the machine is behind a difficult NAT that varies the machines IP address depending on the destination."
name="Varies"
tooltip="Whether the machine is behind a difficult NAT that varies the machines IP address depending on the destination."
value={stats.NetInfo?.MappingVariesByDestIP ? 'Yes' : 'No'}
/>
<Attribute
tooltip="Whether the machine needs to traverse NATs with hairpinning."
name="Hairpinning"
tooltip="Whether the machine needs to traverse NATs with hairpinning."
value={stats.NetInfo?.HairPinning ? 'Yes' : 'No'}
/>
<Attribute
+10 -13
View File
@@ -18,7 +18,7 @@ export async function loader({
context,
}: LoaderFunctionArgs<LoadContext>) {
const session = await context.sessions.auth(request);
const user = session.get('user');
const user = session.user;
if (!user) {
throw new Error('Missing user session. Please log in again.');
}
@@ -41,11 +41,8 @@ export async function loader({
);
const [{ nodes }, { users }] = 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),
]);
let magic: string | undefined;
@@ -90,18 +87,18 @@ export default function Page() {
<p>
Manage the devices connected to your Tailnet.{' '}
<Link
to="https://tailscale.com/kb/1372/manage-devices"
name="Tailscale Manage Devices Documentation"
to="https://tailscale.com/kb/1372/manage-devices"
>
Learn more
</Link>
</p>
</div>
<NewMachine
disabledKeys={data.preAuth ? [] : ['pre-auth']}
isDisabled={!data.writable}
server={data.publicServer ?? data.server}
users={data.users}
isDisabled={!data.writable}
disabledKeys={data.preAuth ? [] : ['pre-auth']}
/>
</div>
<table className="table-auto w-full rounded-lg">
@@ -141,16 +138,16 @@ export default function Page() {
>
{data.populatedNodes.map((machine) => (
<MachineRow
key={machine.id}
node={machine}
users={data.users}
magic={data.magic}
isAgent={data.agent ? data.agent === machine.nodeKey : undefined}
isDisabled={
data.writable
? false // If the user has write permissions, they can edit all machines
: machine.user.providerId?.split('/').pop() !== data.subject
}
key={machine.id}
magic={data.magic}
node={machine}
users={data.users}
/>
))}
</tbody>
+1 -1
View File
@@ -20,7 +20,7 @@ export async function authKeysAction({
}
const formData = await request.formData();
const apiKey = session.get('api_key')!;
const apiKey = session.api_key;
const action = formData.get('action_id')?.toString();
if (!action) {
throw data('Missing `action_id` in the form data.', {
+11 -10
View File
@@ -1,8 +1,7 @@
import { FileKey2 } from 'lucide-react';
import { useMemo, useState } from 'react';
import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';
import { useLoaderData } from 'react-router';
import { Link as RemixLink } from 'react-router';
import { Link as RemixLink, useLoaderData } from 'react-router';
import Code from '~/components/Code';
import Link from '~/components/Link';
import Notice from '~/components/Notice';
@@ -23,7 +22,7 @@ export async function loader({
const session = await context.sessions.auth(request);
const { users } = await context.client.get<{ users: User[] }>(
'v1/user',
session.get('api_key')!,
session.api_key,
);
const preAuthKeys = await Promise.all(
@@ -36,7 +35,7 @@ export async function loader({
try {
const { preAuthKeys } = await context.client.get<{
preAuthKeys: PreAuthKey[];
}>(`v1/preauthkey?${qp.toString()}`, session.get('api_key')!);
}>(`v1/preauthkey?${qp.toString()}`, session.api_key);
return {
success: true,
user,
@@ -139,13 +138,15 @@ export default function Page() {
return key.reusable;
}
return false;
});
}, [keys, selectedUser, status]);
return (
<div className="flex flex-col md:w-2/3">
<p className="mb-8 text-md">
<RemixLink to="/settings" className="font-medium">
<RemixLink className="font-medium" to="/settings">
Settings
</RemixLink>
<span className="mx-2">/</span> Pre-Auth Keys
@@ -176,8 +177,8 @@ export default function Page() {
devices to your Tailnet. To learn more about using pre-authentication
keys, visit the{' '}
<Link
to="https://tailscale.com/kb/1085/auth-keys/"
name="Tailscale Auth Keys documentation"
to="https://tailscale.com/kb/1085/auth-keys/"
>
Tailscale documentation
</Link>
@@ -185,14 +186,14 @@ export default function Page() {
<AddAuthKey users={users} />
<div className="flex items-center gap-4 mt-4">
<Select
label="User"
placeholder="Select a user"
className="w-full"
defaultSelectedKey="__headplane_all"
isDisabled={isDisabled}
label="User"
onSelectionChange={(value) =>
setSelectedUser(value?.toString() ?? '')
}
placeholder="Select a user"
>
{[
<Select.Item key="__headplane_all">All</Select.Item>,
@@ -202,14 +203,14 @@ export default function Page() {
]}
</Select>
<Select
label="Status"
placeholder="Select a status"
className="w-full"
defaultSelectedKey="active"
isDisabled={isDisabled}
label="Status"
onSelectionChange={(value) =>
setStatus((value?.toString() ?? 'active') as Status)
}
placeholder="Select a status"
>
<Select.Item key="all">All</Select.Item>
<Select.Item key="active">Active</Select.Item>
+15 -19
View File
@@ -1,24 +1,24 @@
/** biome-ignore-all lint/correctness/noNestedComponentDefinitions: Wtf? */
import { faker } from '@faker-js/faker';
import { eq } from 'drizzle-orm';
import { Loader2 } from 'lucide-react';
import { useEffect, useState } from 'react';
import {
ActionFunctionArgs,
data,
LoaderFunctionArgs,
ShouldRevalidateFunction,
data,
useLoaderData,
useSubmit,
} from 'react-router';
import wasm from '~/hp_ssh.wasm?url';
import { LoadContext } from '~/server';
import { EphemeralNodeInsert, ephemeralNodes } from '~/server/db/schema';
import { Machine, PreAuthKey, User } from '~/types';
import { useLiveData } from '~/utils/live-data';
import XTerm from './xterm.client';
import { eq } from 'drizzle-orm';
import wasm from '~/hp_ssh.wasm?url';
import { EphemeralNodeInsert, ephemeralNodes } from '~/server/db/schema';
import '~/wasm_exec';
import UserPrompt from './user-prompt';
import XTerm from './xterm.client';
export const shouldRevalidate: ShouldRevalidateFunction = () => {
return false;
@@ -36,16 +36,12 @@ export async function loader({
}
const session = await context.sessions.auth(request);
const user = session.get('user');
if (!user) {
throw data('Unauthorized', 401);
}
if (user.subject === 'unknown-non-oauth') {
if (session.user.subject === 'unknown-non-oauth') {
throw data('Only OAuth users are allowed to use WebSSH', 403);
}
const { users } = await context.client.get<{ users: User[] }>(
'v1/user',
session.get('api_key')!,
session.api_key,
);
// MARK: This assumes that a user has authenticated with Headscale first
@@ -57,19 +53,19 @@ export async function loader({
if (!subject) {
return false;
}
return subject === user.subject;
return subject === session.user.subject;
});
if (!lookup) {
throw data(
`User with subject ${user.subject} not found within Headscale`,
`User with subject ${session.user.subject} not found within Headscale`,
404,
);
}
const { preAuthKey } = await context.client.post<{ preAuthKey: PreAuthKey }>(
'v1/preauthkey',
session.get('api_key')!,
session.api_key,
{
user: lookup.id,
reusable: false,
@@ -122,7 +118,7 @@ export async function loader({
const { nodes } = await context.client.get<{ nodes: Machine[] }>(
'v1/node',
session.get('api_key')!,
session.api_key,
);
// node.name is the hostname, given_name is the set name
@@ -133,7 +129,7 @@ export async function loader({
// Last thing is keeping track of the ephemeral node in the database
// because Headscale doesn't automatically delete ephemeral nodes???
const [ephemeralNode] = await context.db
const [_ephemeralNode] = await context.db
.insert(ephemeralNodes)
.values({
auth_key: preAuthKey.key,
@@ -176,7 +172,7 @@ export async function action({
request,
context,
}: ActionFunctionArgs<LoadContext>) {
const session = await context.sessions.auth(request);
const _session = await context.sessions.auth(request);
if (!context.agents?.agentID()) {
throw data(
'WebSSH is only available with the Headplane agent integration',
@@ -274,9 +270,9 @@ export default function Page() {
) : (
<div className="flex flex-col h-screen">
<XTerm
hostname={sshDetails.hostname}
ipn={ipn}
username={sshDetails.username}
hostname={sshDetails.hostname}
/>
</div>
)}
+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) {
+2 -5
View File
@@ -22,7 +22,7 @@ export async function pruneEphemeralNodes({
const { nodes } = await context.client.get<{ nodes: Machine[] }>(
'v1/node',
session.get('api_key')!,
session.api_key,
);
const toPrune = nodes.filter((node) => {
@@ -42,10 +42,7 @@ export async function pruneEphemeralNodes({
const promises = toPrune.map((node) => {
return async () => {
log.debug('api', `Pruning node ${node.name}`);
await context.client.delete(
`v1/node/${node.id}`,
session.get('api_key')!,
);
await context.client.delete(`v1/node/${node.id}`, session.api_key);
await context.db
.delete(ephemeralNodes)
+10
View File
@@ -19,3 +19,13 @@ export const hostInfo = sqliteTable('host_info', {
export type HostInfoRecord = typeof hostInfo.$inferSelect;
export type HostInfoInsert = typeof hostInfo.$inferInsert;
export const users = sqliteTable('users', {
id: text('id').primaryKey(),
sub: text('sub').notNull().unique(),
caps: integer('caps').notNull().default(0),
onboarded: integer('onboarded', { mode: 'boolean' }).notNull().default(false),
});
export type User = typeof users.$inferSelect;
export type UserInsert = typeof users.$inferInsert;
+9 -7
View File
@@ -49,15 +49,17 @@ const appLoadContext = {
),
// TODO: Better cookie options in config
sessions: await createSessionStorage(
{
name: '_hp_session',
maxAge: 60 * 60 * 24, // 24 hours
sessions: await createSessionStorage({
secret: config.server.cookie_secret,
db,
oidcUsersFile: config.oidc?.user_storage_file,
cookie: {
name: '_hp_auth',
secure: config.server.cookie_secure,
secrets: [config.server.cookie_secret],
maxAge: 60 * 60 * 24, // 24 hours
// domain: config.server.cookie_domain,
},
config.oidc?.user_storage_file,
),
}),
client: await createApiClient(
config.headscale.url,
+199 -206
View File
@@ -1,13 +1,13 @@
import { open, readFile } from 'node:fs/promises';
import { createHash } from 'node:crypto';
import { open, readFile, rm } from 'node:fs/promises';
import { resolve } from 'node:path';
import { exit } from 'node:process';
import {
CookieSerializeOptions,
Session,
SessionStorage,
createCookieSessionStorage,
} from 'react-router';
import { eq } from 'drizzle-orm';
import { LibSQLDatabase } from 'drizzle-orm/libsql/driver';
import { EncryptJWT, jwtDecrypt } from 'jose';
import { createCookie } from 'react-router';
import { ulid } from 'ulidx';
import log from '~/utils/log';
import { users } from '../db/schema';
import { Capabilities, Roles } from './roles';
export interface AuthSession {
@@ -22,6 +22,17 @@ export interface AuthSession {
};
}
interface JWTSession {
api_key: string;
user: {
subject: string;
name: string;
email?: string;
username?: string;
picture?: string;
};
}
export interface OidcFlowSession {
state: 'flow';
oidc: {
@@ -32,254 +43,207 @@ export interface OidcFlowSession {
};
}
type JoinedSession = AuthSession | OidcFlowSession;
interface Error {
error: string;
}
interface CookieOptions {
name: string;
secure: boolean;
maxAge: number;
secrets: string[];
domain?: string;
interface AuthSessionOptions {
secret: string;
db: LibSQLDatabase;
oidcUsersFile?: string;
cookie: {
name: string;
secure: boolean;
maxAge: number;
domain?: string;
};
}
class Sessionizer {
private storage: SessionStorage<JoinedSession, Error>;
private caps: Record<string, { c: Capabilities; oo?: boolean }>;
private capsPath?: string;
private options: AuthSessionOptions;
constructor(
options: CookieOptions,
caps: Record<string, { c: Capabilities; oo?: boolean }>,
capsPath?: string,
) {
this.caps = caps;
this.capsPath = capsPath;
this.storage = createCookieSessionStorage({
cookie: {
...options,
httpOnly: true,
path: __PREFIX__, // Only match on the prefix
sameSite: 'lax', // TODO: Strictify with Domain,
},
});
constructor(options: AuthSessionOptions) {
this.options = options;
}
// This throws on the assumption that auth is already checked correctly
// on something that wraps the route calling auth. The top-level routes
// that call this are wrapped with try/catch to handle the error.
async auth(request: Request) {
const cookie = request.headers.get('cookie');
const session = await this.storage.getSession(cookie);
const type = session.get('state');
if (!type) {
throw new Error('Session state not found');
}
if (type !== 'auth') {
throw new Error('Session is not authenticated');
}
return session as Session<AuthSession, Error>;
return decodeSession(request, this.options);
}
roleForSubject(subject: string): keyof typeof Roles | undefined {
const role = this.caps[subject]?.c;
if (!role) {
async createSession(
payload: JWTSession,
maxAge = this.options.cookie.maxAge,
) {
// TODO: What the hell is this garbage
return createSession(payload, {
...this.options,
cookie: {
...this.options.cookie,
maxAge,
},
});
}
async destroySession() {
return destroySession(this.options);
}
async roleForSubject(
subject: string,
): Promise<keyof typeof Roles | undefined> {
const [user] = await this.options.db
.select()
.from(users)
.where(eq(users.sub, subject))
.limit(1);
if (!user) {
return;
}
// We need this in string form based on Object.keys of the roles
for (const [key, value] of Object.entries(Roles)) {
if (value === role) {
if (value === user.caps) {
return key as keyof typeof Roles;
}
}
}
onboardForSubject(subject: string) {
return this.caps[subject]?.oo ?? false;
}
// Given an OR of capabilities, check if the session has the required
// capabilities. If not, return false. Can throw since it calls auth()
async check(request: Request, capabilities: Capabilities) {
const session = await this.auth(request);
const { subject } = session.get('user') ?? {};
if (!subject) {
// This is the subject we set on API key based sessions. API keys
// inherently imply admin access so we return true for all checks.
if (session.user.subject === 'unknown-non-oauth') {
return true;
}
const [user] = await this.options.db
.select()
.from(users)
.where(eq(users.sub, session.user.subject))
.limit(1);
if (!user) {
return false;
}
// This is the subject we set on API key based sessions. API keys
// inherently imply admin access so we return true for all checks.
if (subject === 'unknown-non-oauth') {
return true;
}
// If the role does not exist, then this is a new subject that we have
// not seen before. Since this is new, we set access to the lowest
// level by default which is the member role.
//
// This also allows us to avoid configuring preventing sign ups with
// OIDC, since the default sign up logic gives member which does not
// have access to the UI whatsoever.
const role = this.caps[subject];
if (!role) {
const memberRole = await this.registerSubject(subject);
return (capabilities & memberRole.c) === capabilities;
}
return (capabilities & role.c) === capabilities;
}
async checkSubject(subject: string, capabilities: Capabilities) {
// This is the subject we set on API key based sessions. API keys
// inherently imply admin access so we return true for all checks.
if (subject === 'unknown-non-oauth') {
return true;
}
// If the role does not exist, then this is a new subject that we have
// not seen before. Since this is new, we set access to the lowest
// level by default which is the member role.
//
// This also allows us to avoid configuring preventing sign ups with
// OIDC, since the default sign up logic gives member which does not
// have access to the UI whatsoever.
const role = this.caps[subject];
if (!role) {
const memberRole = await this.registerSubject(subject);
return (capabilities & memberRole.c) === capabilities;
}
return (capabilities & role.c) === capabilities;
}
// This code is very simple, if the user does not exist in the database
// file then we register it with the lowest level of access. If the user
// database is empty, the first user to sign in will be given the owner
// role.
private async registerSubject(subject: string) {
if (this.caps[subject]) {
return this.caps[subject];
}
if (Object.keys(this.caps).length === 0) {
log.debug('auth', 'First user registered as owner: %s', subject);
this.caps[subject] = { c: Roles.owner };
await this.flushUserDatabase();
return this.caps[subject];
}
log.debug('auth', 'New user registered as member: %s', subject);
this.caps[subject] = { c: Roles.member };
await this.flushUserDatabase();
return this.caps[subject];
}
private async flushUserDatabase() {
if (!this.capsPath) {
return;
}
const data = Object.entries(this.caps).map(([u, { c, oo }]) => ({
u,
c,
oo,
}));
try {
const handle = await open(this.capsPath, 'w');
await handle.write(JSON.stringify(data));
await handle.close();
} catch (error) {
log.error('config', 'Error writing user database file: %s', error);
}
return (capabilities & user.caps) === capabilities;
}
// Updates the capabilities and roles of a subject
async reassignSubject(subject: string, role: keyof typeof Roles) {
// Check if we are owner
if (this.roleForSubject(subject) === 'owner') {
const subjectRole = await this.roleForSubject(subject);
if (subjectRole === 'owner') {
return false;
}
this.caps[subject] = {
...this.caps[subject], // Preserve the existing capabilities if any
c: Roles[role],
};
await this.options.db
.update(users)
.set({
caps: Roles[role],
})
.where(eq(users.sub, subject));
await this.flushUserDatabase();
return true;
}
// Overrides the onboarding status for a subject
async overrideOnboarding(subject: string, onboarding: boolean) {
this.caps[subject] = {
...this.caps[subject], // Preserve the existing capabilities if any
oo: onboarding,
};
await this.flushUserDatabase();
}
getOrCreate<T extends JoinedSession = AuthSession>(request: Request) {
return this.storage.getSession(request.headers.get('cookie')) as Promise<
Session<T, Error>
>;
}
destroy(session: Session) {
return this.storage.destroySession(session);
}
commit(session: Session, options?: CookieSerializeOptions) {
return this.storage.commitSession(session, options);
}
}
export async function createSessionStorage(
options: CookieOptions,
usersPath?: string,
) {
const map: Record<
string,
{
c: number;
oo?: boolean;
}
> = {};
if (usersPath) {
// We need to load our users from the file (default to empty map)
// We then translate each user into a capability object using the helper
// method defined in the roles.ts file
const data = await loadUserFile(usersPath);
log.debug('config', 'Loaded %d users from database', data.length);
async function createSession(payload: JWTSession, options: AuthSessionOptions) {
const secret = createHash('sha256').update(options.secret, 'utf8').digest();
const jwt = await new EncryptJWT({
...payload,
})
.setProtectedHeader({ alg: 'dir', enc: 'A256GCM', typ: 'JWT' })
.setIssuedAt()
.setExpirationTime('1d')
.setIssuer('urn:tale:headplane')
.setAudience('urn:tale:headplane')
.setJti(ulid())
.encrypt(secret);
for (const user of data) {
map[user.u] = {
c: user.c,
oo: user.oo,
};
}
}
const cookie = createCookie(options.cookie.name, {
...options.cookie,
path: __PREFIX__,
});
return new Sessionizer(options, map, usersPath);
return cookie.serialize(jwt);
}
async function loadUserFile(path: string) {
async function decodeSession(request: Request, options: AuthSessionOptions) {
const cookieHeader = request.headers.get('cookie');
if (cookieHeader === null) {
throw new Error('No session cookie found');
}
const cookie = createCookie(options.cookie.name, {
...options.cookie,
path: __PREFIX__,
});
const cookieValue = (await cookie.parse(cookieHeader)) as string | null;
if (cookieValue === null) {
throw new Error('Session cookie is empty');
}
const secret = createHash('sha256').update(options.secret, 'utf8').digest();
const { payload } = await jwtDecrypt(cookieValue, secret, {
issuer: 'urn:tale:headplane',
audience: 'urn:tale:headplane',
});
// Safe since we encode the session directly into the JWT
return payload as unknown as JWTSession;
}
async function destroySession(options: AuthSessionOptions) {
const cookie = createCookie(options.cookie.name, {
...options.cookie,
path: __PREFIX__,
});
return cookie.serialize('', {
expires: new Date(0),
});
}
export async function createSessionStorage(options: AuthSessionOptions) {
if (options.oidcUsersFile) {
await migrateUserDatabase(options.oidcUsersFile, options.db);
}
return new Sessionizer(options);
}
async function migrateUserDatabase(path: string, db: LibSQLDatabase) {
log.info('config', 'Migrating old user database from %s', path);
const realPath = resolve(path);
try {
const handle = await open(realPath, 'a+');
log.info('config', 'Using user database file at %s', realPath);
await handle.close();
} catch (error) {
log.info('config', 'User database file not accessible at %s', realPath);
log.warn('config', 'Failed to migrate old user database at %s', realPath);
log.warn(
'config',
'This is not an error, but existing users will not be migrated',
);
log.warn('config', 'Unable to open user database file: %s', error);
log.debug('config', 'Error details: %s', error);
exit(1);
return;
}
log.info('config', 'Found old user database file at %s', realPath);
log.info('config', 'Migrating user database to the new SQL database');
let migratableUsers: {
u: string;
c: number;
oo?: boolean;
}[];
try {
const data = await readFile(realPath, 'utf8');
const users = JSON.parse(data.trim()) as {
@@ -288,8 +252,7 @@ async function loadUserFile(path: string) {
oo?: boolean;
}[];
// Never trust user input
return users.filter(
migratableUsers = users.filter(
(user) => user.u !== undefined && user.c !== undefined,
) as {
u: string;
@@ -297,8 +260,38 @@ async function loadUserFile(path: string) {
oo?: boolean;
}[];
} catch (error) {
log.debug('config', 'Error reading user database file: %s', error);
log.debug('config', 'Using empty user database');
return [];
log.warn('config', 'Error reading old user database file: %s', error);
log.warn('config', 'Not migrating any users');
return;
}
if (migratableUsers.length === 0) {
log.info('config', 'No users found in the old database to migrate');
return;
}
log.info(
'config',
'Migrating %d users from the old database',
migratableUsers.length,
);
const updated = await db
.insert(users)
.values(
migratableUsers.map((user) => ({
id: ulid(),
sub: user.u,
caps: user.c,
onboarded: user.oo ?? false,
})),
)
.onConflictDoNothing({
target: users.sub,
})
.returning();
log.info('config', 'Migrated %d users successfully', updated.length);
log.info('config', 'Removed old user database file %s', realPath);
await rm(realPath, { force: true });
}
+8
View File
@@ -0,0 +1,8 @@
CREATE TABLE `users` (
`id` text PRIMARY KEY NOT NULL,
`sub` text NOT NULL,
`caps` integer DEFAULT 0 NOT NULL,
`onboarded` integer DEFAULT false NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `users_sub_unique` ON `users` (`sub`);
+119
View File
@@ -0,0 +1,119 @@
{
"version": "6",
"dialect": "sqlite",
"id": "2c18fbcb-d5f5-47c0-962d-54121cbb2e71",
"prevId": "16f780a3-a6e7-4810-94bb-fad5c6446ab4",
"tables": {
"ephemeral_nodes": {
"name": "ephemeral_nodes",
"columns": {
"auth_key": {
"name": "auth_key",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"node_key": {
"name": "node_key",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"host_info": {
"name": "host_info",
"columns": {
"host_id": {
"name": "host_id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"payload": {
"name": "payload",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"users": {
"name": "users",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"sub": {
"name": "sub",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"caps": {
"name": "caps",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"onboarded": {
"name": "onboarded",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
}
},
"indexes": {
"users_sub_unique": {
"name": "users_sub_unique",
"columns": ["sub"],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}
+7
View File
@@ -15,6 +15,13 @@
"when": 1755554742267,
"tag": "0001_naive_lilith",
"breakpoints": true
},
{
"idx": 2,
"version": "6",
"when": 1755617607599,
"tag": "0002_square_bloodstorm",
"breakpoints": true
}
]
}
+10 -2
View File
@@ -45,6 +45,7 @@
"dotenv": "^16.5.0",
"drizzle-orm": "^0.44.2",
"isbot": "^5.1.28",
"jose": "6.0.12",
"lucide-react": "^0.511.0",
"mime": "^4.0.7",
"openid-client": "^6.5.0",
@@ -59,6 +60,7 @@
"react-stately": "^3.38.0",
"remix-utils": "^8.8.0",
"tailwind-merge": "^3.3.0",
"ulidx": "2.4.1",
"undici": "^7.10.0",
"usehooks-ts": "^3.1.1",
"yaml": "^2.8.0"
@@ -92,8 +94,14 @@
},
"pnpm": {
"supportedArchitectures": {
"os": ["current", "linux"],
"cpu": ["x64", "arm64"]
"os": [
"current",
"linux"
],
"cpu": [
"x64",
"arm64"
]
},
"onlyBuiltDependencies": [
"@biomejs/biome",
+23 -4
View File
@@ -100,6 +100,9 @@ importers:
isbot:
specifier: ^5.1.28
version: 5.1.28
jose:
specifier: 6.0.12
version: 6.0.12
lucide-react:
specifier: ^0.511.0
version: 0.511.0(react@19.1.1)
@@ -142,6 +145,9 @@ importers:
tailwind-merge:
specifier: ^3.3.0
version: 3.3.0
ulidx:
specifier: 2.4.1
version: 2.4.1
undici:
specifier: ^7.10.0
version: 7.10.0
@@ -3081,8 +3087,8 @@ packages:
resolution: {integrity: sha512-twQoecYPiVA5K/h6SxtORw/Bs3ar+mLUtoPSc7iMXzQzK8d7eJ/R09wmTwAjiamETn1cXYPGfNnu7DMoHgu12w==}
hasBin: true
jose@6.0.11:
resolution: {integrity: sha512-QxG7EaliDARm1O1S8BGakqncGT9s25bKL1WSf6/oa17Tkqwi8D2ZNglqCF+DsYF88/rV66Q/Q2mFAy697E1DUg==}
jose@6.0.12:
resolution: {integrity: sha512-T8xypXs8CpmiIi78k0E+Lk7T2zlK4zDyg+o1CZ4AkOHgDg98ogdP2BeZ61lTFKFyoEwJ9RgAgN+SdM3iPgNonQ==}
js-base64@3.7.7:
resolution: {integrity: sha512-7rCnleh0z2CkXhH67J8K1Ytz0b2Y+yxTPL+/KOJoa20hfnVQ/3/T6W/KflYI4bRHRagNeXeU2bkNGI3v1oS/lw==}
@@ -3127,6 +3133,9 @@ packages:
resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==}
engines: {node: '>=6'}
layerr@3.0.0:
resolution: {integrity: sha512-tv754Ki2dXpPVApOrjTyRo4/QegVb9eVFq4mjqp4+NM5NaX7syQvN5BBNfV/ZpAHCEHV24XdUVrBAoka4jt3pA==}
lefthook-darwin-arm64@1.11.13:
resolution: {integrity: sha512-gHwHofXupCtzNLN+8esdWfFTnAEkmBxE/WKA0EwxPPJXdZYa1GUsiG5ipq/CdG/0j8ekYyM9Hzyrrk5BqJ42xw==}
cpu: [arm64]
@@ -4003,6 +4012,10 @@ packages:
engines: {node: '>=14.17'}
hasBin: true
ulidx@2.4.1:
resolution: {integrity: sha512-xY7c8LPyzvhvew0Fn+Ek3wBC9STZAuDI/Y5andCKi9AX6/jvfaX45PhsDX8oxgPL0YFp0Jhr8qWMbS/p9375Xg==}
engines: {node: '>=16'}
undici-types@6.21.0:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
@@ -7415,7 +7428,7 @@ snapshots:
jiti@2.5.1: {}
jose@6.0.11: {}
jose@6.0.12: {}
js-base64@3.7.7: {}
@@ -7445,6 +7458,8 @@ snapshots:
kleur@4.1.5: {}
layerr@3.0.0: {}
lefthook-darwin-arm64@1.11.13:
optional: true
@@ -7710,7 +7725,7 @@ snapshots:
openid-client@6.5.0:
dependencies:
jose: 6.0.11
jose: 6.0.12
oauth4webapi: 3.5.1
package-json-from-dist@1.0.1: {}
@@ -8346,6 +8361,10 @@ snapshots:
typescript@5.9.2: {}
ulidx@2.4.1:
dependencies:
layerr: 3.0.0
undici-types@6.21.0: {}
undici-types@7.10.0: {}