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
No known key found for this signature in database
29 changed files with 628 additions and 505 deletions

View File

@ -5,6 +5,7 @@
- Begin using a new SQLite database file in `/var/lib/headplane/hp_persist.db`. - Begin using a new SQLite database file in `/var/lib/headplane/hp_persist.db`.
- The database is created automatically if it does not exist. - The database is created automatically if it does not exist.
- It currently stores SSH connection details and HostInfo for the agent. - 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)). - 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`. - 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)). - 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) - 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)) - 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. - 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) ### 0.6.0 (May 25, 2025)
- Headplane 0.6.0 now requires **Headscale 0.26.0** or newer. - Headplane 0.6.0 now requires **Headscale 0.26.0** or newer.

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 // TODO: Notify in the logs or the UI that OIDC auth key is wrong if enabled
if (healthy) { if (healthy) {
try { try {
await context.client.get('v1/apikey', session.get('api_key')!); await context.client.get('v1/apikey', session.api_key);
} catch (error) { } catch (error) {
if (error instanceof ResponseError) { if (error instanceof ResponseError) {
log.debug('api', 'API Key validation failed %o', error); log.debug('api', 'API Key validation failed %o', error);
return redirect('/login', { return redirect('/login', {
headers: { 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() { export default function Layout() {
return ( return (
<> <main className="container mx-auto overscroll-contain mt-4 mb-24">
<main className="container mx-auto overscroll-contain mt-4 mb-24"> <Outlet />
<Outlet /> </main>
</main>
</>
); );
} }

View File

@ -1,3 +1,4 @@
import { eq } from 'drizzle-orm';
import { CircleCheckIcon } from 'lucide-react'; import { CircleCheckIcon } from 'lucide-react';
import { import {
LoaderFunctionArgs, LoaderFunctionArgs,
@ -10,9 +11,8 @@ import Card from '~/components/Card';
import Footer from '~/components/Footer'; import Footer from '~/components/Footer';
import Header from '~/components/Header'; import Header from '~/components/Header';
import type { LoadContext } from '~/server'; import type { LoadContext } from '~/server';
import { users } from '~/server/db/schema';
import { Capabilities } from '~/server/web/roles'; import { Capabilities } from '~/server/web/roles';
import { User } from '~/types';
import log from '~/utils/log';
import toast from '~/utils/toast'; import toast from '~/utils/toast';
// This loads the bare minimum for the application to function // This loads the bare minimum for the application to function
@ -23,72 +23,18 @@ export async function loader({
}: LoaderFunctionArgs<LoadContext>) { }: LoaderFunctionArgs<LoadContext>) {
try { try {
const session = await context.sessions.auth(request); const session = await context.sessions.auth(request);
if (!session.has('api_key')) { if (
// There is a session, but it's not valid context.oidc &&
return redirect('/login', { session.user.subject !== 'unknown-non-oauth' &&
headers: { !request.url.endsWith('/onboarding')
'Set-Cookie': await context.sessions.destroy(session), ) {
}, 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 (!user?.onboarded) {
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) {
return redirect('/onboarding'); return redirect('/onboarding');
} }
} }
@ -99,7 +45,7 @@ export async function loader({
url: context.config.headscale.public_url ?? context.config.headscale.url, url: context.config.headscale.public_url ?? context.config.headscale.url,
configAvailable: context.hs.readable(), configAvailable: context.hs.readable(),
debug: context.config.debug, debug: context.config.debug,
user: session.get('user'), user: session.user,
uiAccess: check, uiAccess: check,
access: { access: {
ui: await context.sessions.check(request, Capabilities.ui_access), ui: await context.sessions.check(request, Capabilities.ui_access),
@ -119,8 +65,11 @@ export async function loader({
healthy: await context.client.healthcheck(), healthy: await context.client.healthcheck(),
}; };
} catch { } catch {
// No session, so we can just return return redirect('/login', {
return redirect('/login'); headers: {
'Set-Cookie': await context.sessions.destroySession(),
},
});
} }
} }

View File

@ -31,7 +31,7 @@ export async function aclAction({
const { policy, updatedAt } = await context.client.put<{ const { policy, updatedAt } = await context.client.put<{
policy: string; policy: string;
updatedAt: string; updatedAt: string;
}>('v1/policy', session.get('api_key')!, { }>('v1/policy', session.api_key, {
policy: policyData, policy: policyData,
}); });

View File

@ -34,7 +34,7 @@ export async function aclLoader({
const { policy, updatedAt } = await context.client.get<{ const { policy, updatedAt } = await context.client.get<{
policy: string; policy: string;
updatedAt: string | null; updatedAt: string | null;
}>('v1/policy', session.get('api_key')!); }>('v1/policy', session.api_key);
// Successfully loaded the policy, mark it as readable // Successfully loaded the policy, mark it as readable
// If `updatedAt` is null, it means the policy is in file mode. // If `updatedAt` is null, it means the policy is in file mode.

View File

@ -60,25 +60,23 @@ export async function loginAction({
}; };
} }
// Set the session
const session = await context.sessions.getOrCreate(request);
const expiresDays = Math.round( const expiresDays = Math.round(
(expiry.getTime() - Date.now()) / 1000 / 60 / 60 / 24, (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', { return redirect('/machines', {
headers: { headers: {
'Set-Cookie': await context.sessions.commit(session, { 'Set-Cookie': await context.sessions.createSession(
maxAge: expiry.getTime() - Date.now(), {
}), api_key: apiKey,
user: {
subject: 'unknown-non-oauth',
name: `${lookup.prefix}...`,
email: `expires@${expiresDays.toString()}-days`,
},
},
expiry.getTime() - Date.now(),
),
}, },
}); });
} catch (error) { } catch (error) {

View File

@ -1,10 +1,10 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import { import {
ActionFunctionArgs, ActionFunctionArgs,
data,
Form, Form,
LoaderFunctionArgs, LoaderFunctionArgs,
Link as RemixLink, Link as RemixLink,
data,
redirect, redirect,
useActionData, useActionData,
useLoaderData, useLoaderData,
@ -24,10 +24,8 @@ export async function loader({
context, context,
}: LoaderFunctionArgs<LoadContext>) { }: LoaderFunctionArgs<LoadContext>) {
try { try {
const session = await context.sessions.auth(request); await context.sessions.auth(request);
if (session.has('api_key')) { return redirect('/machines');
return redirect('/machines');
}
} catch {} } catch {}
const qp = new URL(request.url).searchParams; const qp = new URL(request.url).searchParams;
@ -104,26 +102,26 @@ export default function Page() {
terminal. terminal.
</Card.Text> </Card.Text>
<Input <Input
className="mt-8 mb-2"
isRequired isRequired
labelHidden
label="API Key" label="API Key"
labelHidden
name="api_key" name="api_key"
placeholder="API Key" placeholder="API Key"
type="password" type="password"
className="mt-8 mb-2"
/> />
{formData?.success === false ? ( {formData?.success === false ? (
<Card.Text className="text-sm mb-2 text-red-600 dark:text-red-300"> <Card.Text className="text-sm mb-2 text-red-600 dark:text-red-300">
{formData.message} {formData.message}
</Card.Text> </Card.Text>
) : undefined} ) : undefined}
<Button className="w-full" variant="heavy" type="submit"> <Button className="w-full" type="submit" variant="heavy">
Sign In Sign In
</Button> </Button>
</Form> </Form>
{oidc ? ( {oidc ? (
<RemixLink to="/oidc/start"> <RemixLink to="/oidc/start">
<Button variant="light" className="w-full mt-2"> <Button className="w-full mt-2" variant="light">
Single Sign-On Single Sign-On
</Button> </Button>
</RemixLink> </RemixLink>

View File

@ -9,9 +9,10 @@ export async function action({
request, request,
context, context,
}: ActionFunctionArgs<LoadContext>) { }: ActionFunctionArgs<LoadContext>) {
const session = await context.sessions.auth(request); try {
if (!session.has('api_key')) { await context.sessions.auth(request);
return redirect('/login'); } catch {
redirect('/login');
} }
// When API key is disabled, we need to explicitly redirect // When API key is disabled, we need to explicitly redirect
@ -22,7 +23,7 @@ export async function action({
return redirect(url, { return redirect(url, {
headers: { headers: {
'Set-Cookie': await context.sessions.destroy(session), 'Set-Cookie': await context.sessions.destroySession(),
}, },
}); });
} }

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 { 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 { finishAuthFlow, formatError } from '~/utils/oidc';
import { send } from '~/utils/res'; import { send } from '~/utils/res';
interface OidcFlowSession {
state: string;
nonce: string;
code_verifier: string;
redirect_uri: string;
}
export async function loader({ export async function loader({
request, request,
context, context,
@ -18,13 +28,21 @@ export async function loader({
return redirect('/login'); return redirect('/login');
} }
const session = await context.sessions.getOrCreate<OidcFlowSession>(request); const cookie = createCookie('__oidc_auth_flow', {
if (session.get('state') !== 'flow') { httpOnly: true,
return redirect('/login'); // Haven't started an OIDC flow 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 } = data;
const { code_verifier, state, nonce, redirect_uri } = payload;
if (!code_verifier || !state || !nonce || !redirect_uri) { if (!code_verifier || !state || !nonce || !redirect_uri) {
return send({ error: 'Missing OIDC state' }, { status: 400 }); return send({ error: 'Missing OIDC state' }, { status: 400 });
} }
@ -43,19 +61,30 @@ export async function loader({
try { try {
const user = await finishAuthFlow(context.oidc, flowOptions); 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 const [{ count: userCount }] = await context.db
// keys because they are currently non-deletable in the headscale .select({ count: count() })
// database. Look at this in the future once we have a solution .from(users);
// or we have permissioned API keys.
userSession.set('user', user); await context.db
userSession.set('api_key', context.config.oidc?.headscale_api_key!); .insert(users)
userSession.set('state', 'auth'); .values({
id: ulid(),
sub: user.subject,
caps: userCount === 0 ? Roles.owner : Roles.member,
})
.onConflictDoNothing();
return redirect('/machines', { return redirect('/machines', {
headers: { 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) { } catch (error) {

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 type { LoadContext } from '~/server';
import { AuthSession, OidcFlowSession } from '~/server/web/sessions';
import { beginAuthFlow, getRedirectUri } from '~/utils/oidc'; import { beginAuthFlow, getRedirectUri } from '~/utils/oidc';
export async function loader({ export async function loader({
request, request,
context, context,
}: LoaderFunctionArgs<LoadContext>) { }: LoaderFunctionArgs<LoadContext>) {
const session = await context.sessions.getOrCreate<OidcFlowSession>(request); try {
if ((session as Session<AuthSession>).has('api_key')) { await context.sessions.auth(request);
return redirect('/machines'); return redirect('/machines');
} } catch {}
if (!context.oidc) { if (!context.oidc || !context.config.oidc) {
throw new Error('OIDC is not enabled'); throw new Error('OIDC is not enabled');
} }
const cookie = createCookie('__oidc_auth_flow', {
httpOnly: true,
maxAge: 300, // 5 minutes
});
const redirectUri = const redirectUri =
context.config.oidc?.redirect_uri ?? getRedirectUri(request); context.config.oidc?.redirect_uri ?? getRedirectUri(request);
const data = await beginAuthFlow( const data = await beginAuthFlow(
context.oidc, context.oidc,
redirectUri, 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, { return redirect(data.url, {
status: 302, status: 302,
headers: { 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,
}),
}, },
}); });
} }

View File

@ -14,7 +14,7 @@ export async function machineAction({
); );
const formData = await request.formData(); const formData = await request.formData();
const apiKey = session.get('api_key')!; const apiKey = session.api_key;
const action = formData.get('action_id')?.toString(); const action = formData.get('action_id')?.toString();
if (!action) { if (!action) {
@ -55,7 +55,7 @@ export async function machineAction({
} }
if ( if (
node.user.providerId?.split('/').pop() !== session.get('user')!.subject && node.user.providerId?.split('/').pop() !== session.user.subject &&
!check !check
) { ) {
throw data('You do not have permission to act on this machine', { throw data('You do not have permission to act on this machine', {

View File

@ -39,9 +39,9 @@ export async function loader({
const [machine, { users }] = await Promise.all([ const [machine, { users }] = await Promise.all([
context.client.get<{ node: Machine }>( context.client.get<{ node: Machine }>(
`v1/node/${params.id}`, `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]); const lookup = await context.agents?.lookup([machine.node.nodeKey]);
@ -77,7 +77,7 @@ export default function Page() {
return ( return (
<div> <div>
<p className="mb-8 text-md"> <p className="mb-8 text-md">
<RemixLink to="/machines" className="font-medium"> <RemixLink className="font-medium" to="/machines">
All Machines All Machines
</RemixLink> </RemixLink>
<span className="mx-2">/</span> <span className="mx-2">/</span>
@ -91,9 +91,9 @@ export default function Page() {
> >
<span className="flex items-baseline gap-x-4 text-sm"> <span className="flex items-baseline gap-x-4 text-sm">
<h1 className="text-2xl font-medium">{node.givenName}</h1> <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> </span>
<MenuOptions isFullButton node={node} users={users} magic={magic} /> <MenuOptions isFullButton magic={magic} node={node} users={users} />
</div> </div>
<div className="flex gap-1 mb-4"> <div className="flex gap-1 mb-4">
<div className="border-r border-headplane-100 dark:border-headplane-800 p-2 pr-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> </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> <h2 className="text-xl font-medium mt-8">Subnets & Routing</h2>
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<p> <p>
Subnets let you expose physical network routes onto Tailscale.{' '} Subnets let you expose physical network routes onto Tailscale.{' '}
<Link <Link
to="https://tailscale.com/kb/1019/subnets"
name="Tailscale Subnets Documentation" name="Tailscale Subnets Documentation"
to="https://tailscale.com/kb/1019/subnets"
> >
Learn More Learn More
</Link> </Link>
@ -138,11 +138,11 @@ export default function Page() {
<Button onPress={() => setShowRouting(true)}>Review</Button> <Button onPress={() => setShowRouting(true)}>Review</Button>
</div> </div>
<Card <Card
variant="flat"
className={cn( className={cn(
'w-full max-w-full grid sm:grid-cols-2', 'w-full max-w-full grid sm:grid-cols-2',
'md:grid-cols-4 gap-8 mr-2 text-sm mb-8', 'md:grid-cols-4 gap-8 mr-2 text-sm mb-8',
)} )}
variant="flat"
> >
<div> <div>
<span className="text-headplane-600 dark:text-headplane-300 flex items-center gap-x-1"> <span className="text-headplane-600 dark:text-headplane-300 flex items-center gap-x-1">
@ -166,11 +166,11 @@ export default function Page() {
)} )}
</div> </div>
<Button <Button
onPress={() => setShowRouting(true)}
className={cn( className={cn(
'px-1.5 py-0.5 rounded-md mt-1.5', 'px-1.5 py-0.5 rounded-md mt-1.5',
'text-blue-500 dark:text-blue-400', 'text-blue-500 dark:text-blue-400',
)} )}
onPress={() => setShowRouting(true)}
> >
Edit Edit
</Button> </Button>
@ -198,11 +198,11 @@ export default function Page() {
)} )}
</div> </div>
<Button <Button
onPress={() => setShowRouting(true)}
className={cn( className={cn(
'px-1.5 py-0.5 rounded-md mt-1.5', 'px-1.5 py-0.5 rounded-md mt-1.5',
'text-blue-500 dark:text-blue-400', 'text-blue-500 dark:text-blue-400',
)} )}
onPress={() => setShowRouting(true)}
> >
Edit Edit
</Button> </Button>
@ -233,11 +233,11 @@ export default function Page() {
)} )}
</div> </div>
<Button <Button
onPress={() => setShowRouting(true)}
className={cn( className={cn(
'px-1.5 py-0.5 rounded-md mt-1.5', 'px-1.5 py-0.5 rounded-md mt-1.5',
'text-blue-500 dark:text-blue-400', 'text-blue-500 dark:text-blue-400',
)} )}
onPress={() => setShowRouting(true)}
> >
Edit Edit
</Button> </Button>
@ -249,15 +249,15 @@ export default function Page() {
issues. issues.
</p> </p>
<Card <Card
variant="flat"
className="w-full max-w-full grid grid-cols-1 lg:grid-cols-2 gap-y-2 sm:gap-x-12" 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"> <div className="flex flex-col gap-1">
<Attribute name="Creator" value={node.user.name || node.user.email} /> <Attribute name="Creator" value={node.user.name || node.user.email} />
<Attribute name="Machine name" value={node.givenName} /> <Attribute name="Machine name" value={node.givenName} />
<Attribute <Attribute
tooltip="OS hostname is published by the machines operating system and is used as the default name for the machine."
name="OS hostname" 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} value={node.name}
/> />
{stats ? ( {stats ? (
@ -267,14 +267,14 @@ export default function Page() {
</> </>
) : undefined} ) : undefined}
<Attribute <Attribute
tooltip="ID for this machine. Used in the Headscale API."
name="ID" name="ID"
tooltip="ID for this machine. Used in the Headscale API."
value={node.id} value={node.id}
/> />
<Attribute <Attribute
isCopyable isCopyable
tooltip="Public key which uniquely identifies this machine."
name="Node key" name="Node key"
tooltip="Public key which uniquely identifies this machine."
value={node.nodeKey} value={node.nodeKey}
/> />
<Attribute <Attribute
@ -311,27 +311,27 @@ export default function Page() {
</p> </p>
<Attribute <Attribute
isCopyable isCopyable
tooltip="This machines IPv4 address within your tailnet (your private Tailscale network)."
name="Tailscale IPv4" name="Tailscale IPv4"
tooltip="This machines IPv4 address within your tailnet (your private Tailscale network)."
value={getIpv4Address(node.ipAddresses)} value={getIpv4Address(node.ipAddresses)}
/> />
<Attribute <Attribute
isCopyable 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" 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)} value={getIpv6Address(node.ipAddresses)}
/> />
<Attribute <Attribute
isCopyable isCopyable
tooltip="Users of your tailnet can use this DNS short name to access this machine."
name="Short domain" name="Short domain"
tooltip="Users of your tailnet can use this DNS short name to access this machine."
value={node.givenName} value={node.givenName}
/> />
{magic ? ( {magic ? (
<Attribute <Attribute
isCopyable isCopyable
tooltip="Users of your tailnet can use this DNS name to access this machine."
name="Full domain" name="Full domain"
tooltip="Users of your tailnet can use this DNS name to access this machine."
value={`${node.givenName}.${magic}`} value={`${node.givenName}.${magic}`}
/> />
) : undefined} ) : undefined}
@ -341,13 +341,13 @@ export default function Page() {
Client Connectivity Client Connectivity
</p> </p>
<Attribute <Attribute
tooltip="Whether the machine is behind a difficult NAT that varies the machines IP address depending on the destination."
name="Varies" 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'} value={stats.NetInfo?.MappingVariesByDestIP ? 'Yes' : 'No'}
/> />
<Attribute <Attribute
tooltip="Whether the machine needs to traverse NATs with hairpinning."
name="Hairpinning" name="Hairpinning"
tooltip="Whether the machine needs to traverse NATs with hairpinning."
value={stats.NetInfo?.HairPinning ? 'Yes' : 'No'} value={stats.NetInfo?.HairPinning ? 'Yes' : 'No'}
/> />
<Attribute <Attribute

View File

@ -18,7 +18,7 @@ export async function loader({
context, context,
}: LoaderFunctionArgs<LoadContext>) { }: LoaderFunctionArgs<LoadContext>) {
const session = await context.sessions.auth(request); const session = await context.sessions.auth(request);
const user = session.get('user'); const user = session.user;
if (!user) { if (!user) {
throw new Error('Missing user session. Please log in again.'); throw new Error('Missing user session. Please log in again.');
} }
@ -41,11 +41,8 @@ export async function loader({
); );
const [{ nodes }, { users }] = await Promise.all([ const [{ nodes }, { users }] = await Promise.all([
context.client.get<{ nodes: Machine[] }>( context.client.get<{ nodes: Machine[] }>('v1/node', session.api_key),
'v1/node', context.client.get<{ users: User[] }>('v1/user', session.api_key),
session.get('api_key')!,
),
context.client.get<{ users: User[] }>('v1/user', session.get('api_key')!),
]); ]);
let magic: string | undefined; let magic: string | undefined;
@ -90,18 +87,18 @@ export default function Page() {
<p> <p>
Manage the devices connected to your Tailnet.{' '} Manage the devices connected to your Tailnet.{' '}
<Link <Link
to="https://tailscale.com/kb/1372/manage-devices"
name="Tailscale Manage Devices Documentation" name="Tailscale Manage Devices Documentation"
to="https://tailscale.com/kb/1372/manage-devices"
> >
Learn more Learn more
</Link> </Link>
</p> </p>
</div> </div>
<NewMachine <NewMachine
disabledKeys={data.preAuth ? [] : ['pre-auth']}
isDisabled={!data.writable}
server={data.publicServer ?? data.server} server={data.publicServer ?? data.server}
users={data.users} users={data.users}
isDisabled={!data.writable}
disabledKeys={data.preAuth ? [] : ['pre-auth']}
/> />
</div> </div>
<table className="table-auto w-full rounded-lg"> <table className="table-auto w-full rounded-lg">
@ -141,16 +138,16 @@ export default function Page() {
> >
{data.populatedNodes.map((machine) => ( {data.populatedNodes.map((machine) => (
<MachineRow <MachineRow
key={machine.id}
node={machine}
users={data.users}
magic={data.magic}
isAgent={data.agent ? data.agent === machine.nodeKey : undefined} isAgent={data.agent ? data.agent === machine.nodeKey : undefined}
isDisabled={ isDisabled={
data.writable data.writable
? false // If the user has write permissions, they can edit all machines ? false // If the user has write permissions, they can edit all machines
: machine.user.providerId?.split('/').pop() !== data.subject : machine.user.providerId?.split('/').pop() !== data.subject
} }
key={machine.id}
magic={data.magic}
node={machine}
users={data.users}
/> />
))} ))}
</tbody> </tbody>

View File

@ -20,7 +20,7 @@ export async function authKeysAction({
} }
const formData = await request.formData(); const formData = await request.formData();
const apiKey = session.get('api_key')!; const apiKey = session.api_key;
const action = formData.get('action_id')?.toString(); const action = formData.get('action_id')?.toString();
if (!action) { if (!action) {
throw data('Missing `action_id` in the form data.', { throw data('Missing `action_id` in the form data.', {

View File

@ -1,8 +1,7 @@
import { FileKey2 } from 'lucide-react'; import { FileKey2 } from 'lucide-react';
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router'; import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';
import { useLoaderData } from 'react-router'; import { Link as RemixLink, useLoaderData } from 'react-router';
import { Link as RemixLink } from 'react-router';
import Code from '~/components/Code'; import Code from '~/components/Code';
import Link from '~/components/Link'; import Link from '~/components/Link';
import Notice from '~/components/Notice'; import Notice from '~/components/Notice';
@ -23,7 +22,7 @@ export async function loader({
const session = await context.sessions.auth(request); const session = await context.sessions.auth(request);
const { users } = await context.client.get<{ users: User[] }>( const { users } = await context.client.get<{ users: User[] }>(
'v1/user', 'v1/user',
session.get('api_key')!, session.api_key,
); );
const preAuthKeys = await Promise.all( const preAuthKeys = await Promise.all(
@ -36,7 +35,7 @@ export async function loader({
try { try {
const { preAuthKeys } = await context.client.get<{ const { preAuthKeys } = await context.client.get<{
preAuthKeys: PreAuthKey[]; preAuthKeys: PreAuthKey[];
}>(`v1/preauthkey?${qp.toString()}`, session.get('api_key')!); }>(`v1/preauthkey?${qp.toString()}`, session.api_key);
return { return {
success: true, success: true,
user, user,
@ -139,13 +138,15 @@ export default function Page() {
return key.reusable; return key.reusable;
} }
return false;
}); });
}, [keys, selectedUser, status]); }, [keys, selectedUser, status]);
return ( return (
<div className="flex flex-col md:w-2/3"> <div className="flex flex-col md:w-2/3">
<p className="mb-8 text-md"> <p className="mb-8 text-md">
<RemixLink to="/settings" className="font-medium"> <RemixLink className="font-medium" to="/settings">
Settings Settings
</RemixLink> </RemixLink>
<span className="mx-2">/</span> Pre-Auth Keys <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 devices to your Tailnet. To learn more about using pre-authentication
keys, visit the{' '} keys, visit the{' '}
<Link <Link
to="https://tailscale.com/kb/1085/auth-keys/"
name="Tailscale Auth Keys documentation" name="Tailscale Auth Keys documentation"
to="https://tailscale.com/kb/1085/auth-keys/"
> >
Tailscale documentation Tailscale documentation
</Link> </Link>
@ -185,14 +186,14 @@ export default function Page() {
<AddAuthKey users={users} /> <AddAuthKey users={users} />
<div className="flex items-center gap-4 mt-4"> <div className="flex items-center gap-4 mt-4">
<Select <Select
label="User"
placeholder="Select a user"
className="w-full" className="w-full"
defaultSelectedKey="__headplane_all" defaultSelectedKey="__headplane_all"
isDisabled={isDisabled} isDisabled={isDisabled}
label="User"
onSelectionChange={(value) => onSelectionChange={(value) =>
setSelectedUser(value?.toString() ?? '') setSelectedUser(value?.toString() ?? '')
} }
placeholder="Select a user"
> >
{[ {[
<Select.Item key="__headplane_all">All</Select.Item>, <Select.Item key="__headplane_all">All</Select.Item>,
@ -202,14 +203,14 @@ export default function Page() {
]} ]}
</Select> </Select>
<Select <Select
label="Status"
placeholder="Select a status"
className="w-full" className="w-full"
defaultSelectedKey="active" defaultSelectedKey="active"
isDisabled={isDisabled} isDisabled={isDisabled}
label="Status"
onSelectionChange={(value) => onSelectionChange={(value) =>
setStatus((value?.toString() ?? 'active') as Status) setStatus((value?.toString() ?? 'active') as Status)
} }
placeholder="Select a status"
> >
<Select.Item key="all">All</Select.Item> <Select.Item key="all">All</Select.Item>
<Select.Item key="active">Active</Select.Item> <Select.Item key="active">Active</Select.Item>

View File

@ -1,24 +1,24 @@
/** biome-ignore-all lint/correctness/noNestedComponentDefinitions: Wtf? */
import { faker } from '@faker-js/faker'; import { faker } from '@faker-js/faker';
import { eq } from 'drizzle-orm';
import { Loader2 } from 'lucide-react'; import { Loader2 } from 'lucide-react';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { import {
ActionFunctionArgs, ActionFunctionArgs,
data,
LoaderFunctionArgs, LoaderFunctionArgs,
ShouldRevalidateFunction, ShouldRevalidateFunction,
data,
useLoaderData, useLoaderData,
useSubmit, useSubmit,
} from 'react-router'; } from 'react-router';
import wasm from '~/hp_ssh.wasm?url';
import { LoadContext } from '~/server'; import { LoadContext } from '~/server';
import { EphemeralNodeInsert, ephemeralNodes } from '~/server/db/schema';
import { Machine, PreAuthKey, User } from '~/types'; import { Machine, PreAuthKey, User } from '~/types';
import { useLiveData } from '~/utils/live-data'; 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 '~/wasm_exec';
import UserPrompt from './user-prompt'; import UserPrompt from './user-prompt';
import XTerm from './xterm.client';
export const shouldRevalidate: ShouldRevalidateFunction = () => { export const shouldRevalidate: ShouldRevalidateFunction = () => {
return false; return false;
@ -36,16 +36,12 @@ export async function loader({
} }
const session = await context.sessions.auth(request); const session = await context.sessions.auth(request);
const user = session.get('user'); if (session.user.subject === 'unknown-non-oauth') {
if (!user) {
throw data('Unauthorized', 401);
}
if (user.subject === 'unknown-non-oauth') {
throw data('Only OAuth users are allowed to use WebSSH', 403); throw data('Only OAuth users are allowed to use WebSSH', 403);
} }
const { users } = await context.client.get<{ users: User[] }>( const { users } = await context.client.get<{ users: User[] }>(
'v1/user', 'v1/user',
session.get('api_key')!, session.api_key,
); );
// MARK: This assumes that a user has authenticated with Headscale first // MARK: This assumes that a user has authenticated with Headscale first
@ -57,19 +53,19 @@ export async function loader({
if (!subject) { if (!subject) {
return false; return false;
} }
return subject === user.subject; return subject === session.user.subject;
}); });
if (!lookup) { if (!lookup) {
throw data( throw data(
`User with subject ${user.subject} not found within Headscale`, `User with subject ${session.user.subject} not found within Headscale`,
404, 404,
); );
} }
const { preAuthKey } = await context.client.post<{ preAuthKey: PreAuthKey }>( const { preAuthKey } = await context.client.post<{ preAuthKey: PreAuthKey }>(
'v1/preauthkey', 'v1/preauthkey',
session.get('api_key')!, session.api_key,
{ {
user: lookup.id, user: lookup.id,
reusable: false, reusable: false,
@ -122,7 +118,7 @@ export async function loader({
const { nodes } = await context.client.get<{ nodes: Machine[] }>( const { nodes } = await context.client.get<{ nodes: Machine[] }>(
'v1/node', 'v1/node',
session.get('api_key')!, session.api_key,
); );
// node.name is the hostname, given_name is the set name // 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 // Last thing is keeping track of the ephemeral node in the database
// because Headscale doesn't automatically delete ephemeral nodes??? // because Headscale doesn't automatically delete ephemeral nodes???
const [ephemeralNode] = await context.db const [_ephemeralNode] = await context.db
.insert(ephemeralNodes) .insert(ephemeralNodes)
.values({ .values({
auth_key: preAuthKey.key, auth_key: preAuthKey.key,
@ -176,7 +172,7 @@ export async function action({
request, request,
context, context,
}: ActionFunctionArgs<LoadContext>) { }: ActionFunctionArgs<LoadContext>) {
const session = await context.sessions.auth(request); const _session = await context.sessions.auth(request);
if (!context.agents?.agentID()) { if (!context.agents?.agentID()) {
throw data( throw data(
'WebSSH is only available with the Headplane agent integration', 'WebSSH is only available with the Headplane agent integration',
@ -274,9 +270,9 @@ export default function Page() {
) : ( ) : (
<div className="flex flex-col h-screen"> <div className="flex flex-col h-screen">
<XTerm <XTerm
hostname={sshDetails.hostname}
ipn={ipn} ipn={ipn}
username={sshDetails.username} username={sshDetails.username}
hostname={sshDetails.hostname}
/> />
</div> </div>
)} )}

View File

@ -1,16 +1,23 @@
import { eq } from 'drizzle-orm';
import { LoaderFunctionArgs, redirect } from 'react-router'; import { LoaderFunctionArgs, redirect } from 'react-router';
import { LoadContext } from '~/server'; import { LoadContext } from '~/server';
import { users } from '~/server/db/schema';
export async function loader({ export async function loader({
request, request,
context, context,
}: LoaderFunctionArgs<LoadContext>) { }: LoaderFunctionArgs<LoadContext>) {
const session = await context.sessions.auth(request); try {
const user = session.get('user'); const { user } = await context.sessions.auth(request);
if (!user) { await context.db
.update(users)
.set({
onboarded: true,
})
.where(eq(users.sub, user.subject));
return redirect('/machines');
} catch {
return redirect('/login'); return redirect('/login');
} }
context.sessions.overrideOnboarding(user.subject, true);
return redirect('/machines');
} }

View File

@ -4,12 +4,7 @@ import { GrApple } from 'react-icons/gr';
import { ImFinder } from 'react-icons/im'; import { ImFinder } from 'react-icons/im';
import { MdAndroid } from 'react-icons/md'; import { MdAndroid } from 'react-icons/md';
import { PiTerminalFill, PiWindowsLogoFill } from 'react-icons/pi'; import { PiTerminalFill, PiWindowsLogoFill } from 'react-icons/pi';
import { import { LoaderFunctionArgs, NavLink, useLoaderData } from 'react-router';
LoaderFunctionArgs,
NavLink,
redirect,
useLoaderData,
} from 'react-router';
import Button from '~/components/Button'; import Button from '~/components/Button';
import Card from '~/components/Card'; import Card from '~/components/Card';
import Link from '~/components/Link'; import Link from '~/components/Link';
@ -27,10 +22,6 @@ export async function loader({
context, context,
}: LoaderFunctionArgs<LoadContext>) { }: LoaderFunctionArgs<LoadContext>) {
const session = await context.sessions.auth(request); 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 // 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 // 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; break;
} }
let firstMachine: Machine | undefined = undefined; let firstMachine: Machine | undefined;
try { try {
const { nodes } = await context.client.get<{ nodes: Machine[] }>( const { nodes } = await context.client.get<{ nodes: Machine[] }>(
'v1/node', 'v1/node',
session.get('api_key')!, session.api_key,
); );
const node = nodes.find((n) => { const node = nodes.find((n) => {
@ -79,12 +70,7 @@ export async function loader({
return false; return false;
} }
const sessionUser = session.get('user'); if (subject !== session.user.subject) {
if (!sessionUser) {
return false;
}
if (subject !== sessionUser.subject) {
return false; return false;
} }
@ -98,7 +84,7 @@ export async function loader({
} }
return { return {
user, user: session.user,
osValue, osValue,
firstMachine, firstMachine,
}; };
@ -126,7 +112,7 @@ export default function Page() {
return ( return (
<div className="fixed w-full h-screen flex items-center px-4"> <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"> <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"> <Card.Title className="mb-8">
Welcome! Welcome!
<br /> <br />
@ -138,9 +124,9 @@ export default function Page() {
</Card.Text> </Card.Text>
<Options <Options
className="my-4"
defaultSelectedKey={osValue} defaultSelectedKey={osValue}
label="Download Selector" label="Download Selector"
className="my-4"
> >
<Options.Item <Options.Item
key="linux" key="linux"
@ -183,12 +169,12 @@ export default function Page() {
} }
> >
<a <a
href="https://pkgs.tailscale.com/stable/tailscale-setup-latest.exe"
aria-label="Download for Windows" aria-label="Download for Windows"
target="_blank" href="https://pkgs.tailscale.com/stable/tailscale-setup-latest.exe"
rel="noreferrer" rel="noreferrer"
target="_blank"
> >
<Button variant="heavy" className="my-4 w-full"> <Button className="my-4 w-full" variant="heavy">
Download for Windows Download for Windows
</Button> </Button>
</a> </a>
@ -206,12 +192,12 @@ export default function Page() {
} }
> >
<a <a
href="https://pkgs.tailscale.com/stable/Tailscale-latest-macos.pkg"
aria-label="Download for macOS" aria-label="Download for macOS"
target="_blank" href="https://pkgs.tailscale.com/stable/Tailscale-latest-macos.pkg"
rel="noreferrer" rel="noreferrer"
target="_blank"
> >
<Button variant="heavy" className="my-4 w-full"> <Button className="my-4 w-full" variant="heavy">
Download for macOS Download for macOS
</Button> </Button>
</a> </a>
@ -238,12 +224,12 @@ export default function Page() {
} }
> >
<a <a
href="https://apps.apple.com/us/app/tailscale/id1470499037"
aria-label="Download for iOS" aria-label="Download for iOS"
target="_blank" href="https://apps.apple.com/us/app/tailscale/id1470499037"
rel="noreferrer" rel="noreferrer"
target="_blank"
> >
<Button variant="heavy" className="my-4 w-full"> <Button className="my-4 w-full" variant="heavy">
Download for iOS Download for iOS
</Button> </Button>
</a> </a>
@ -261,12 +247,12 @@ export default function Page() {
} }
> >
<a <a
href="https://play.google.com/store/apps/details?id=com.tailscale.ipn"
aria-label="Download for Android" aria-label="Download for Android"
target="_blank" href="https://play.google.com/store/apps/details?id=com.tailscale.ipn"
rel="noreferrer" rel="noreferrer"
target="_blank"
> >
<Button variant="heavy" className="my-4 w-full"> <Button className="my-4 w-full" variant="heavy">
Download for Android Download for Android
</Button> </Button>
</a> </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="border border-headplane-100 dark:border-headplane-800 rounded-xl p-4">
<div className="flex items-start gap-4"> <div className="flex items-start gap-4">
<StatusCircle <StatusCircle
isOnline={firstMachine.online}
className="size-6 mt-3" className="size-6 mt-3"
isOnline={firstMachine.online}
/> />
<div> <div>
<p className="font-semibold leading-snug"> <p className="font-semibold leading-snug">
@ -300,7 +286,7 @@ export default function Page() {
<div className="mt-6"> <div className="mt-6">
<p className="text-sm font-semibold">IP Addresses</p> <p className="text-sm font-semibold">IP Addresses</p>
{firstMachine.ipAddresses.map((ip) => ( {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} {ip}
</p> </p>
))} ))}
@ -308,8 +294,8 @@ export default function Page() {
</div> </div>
</div> </div>
</div> </div>
<NavLink to="/"> <NavLink to="/onboarding/skip">
<Button variant="heavy" className="w-full"> <Button className="w-full" variant="heavy">
Continue Continue
</Button> </Button>
</NavLink> </NavLink>
@ -335,7 +321,7 @@ export default function Page() {
</div> </div>
)} )}
</Card> </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"> <Button className="flex items-center gap-1">
I already know what I'm doing I already know what I'm doing
<ArrowRight className="p-1" /> <ArrowRight className="p-1" />

View File

@ -1,6 +1,6 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router'; import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';
import { useLoaderData, useSubmit } from 'react-router'; import { useLoaderData } from 'react-router';
import type { LoadContext } from '~/server'; import type { LoadContext } from '~/server';
import { Capabilities } from '~/server/web/roles'; import { Capabilities } from '~/server/web/roles';
import { Machine, User } from '~/types'; import { Machine, User } from '~/types';
@ -32,11 +32,8 @@ export async function loader({
); );
const [machines, apiUsers] = await Promise.all([ const [machines, apiUsers] = await Promise.all([
context.client.get<{ nodes: Machine[] }>( context.client.get<{ nodes: Machine[] }>('v1/node', session.api_key),
'v1/node', context.client.get<{ users: User[] }>('v1/user', session.api_key),
session.get('api_key')!,
),
context.client.get<{ users: User[] }>('v1/user', session.get('api_key')!),
]); ]);
const users = apiUsers.users.map((user) => ({ const users = apiUsers.users.map((user) => ({
@ -44,30 +41,32 @@ export async function loader({
machines: machines.nodes.filter((machine) => machine.user.id === user.id), machines: machines.nodes.filter((machine) => machine.user.id === user.id),
})); }));
const roles = users const roles = await Promise.all(
.sort((a, b) => a.name.localeCompare(b.name)) users
.map((user) => { .sort((a, b) => a.name.localeCompare(b.name))
if (user.provider !== 'oidc') { .map(async (user) => {
return 'no-oidc'; 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 role = context.sessions.roleForSubject(subject); if (user.provider === 'oidc' && user.providerId) {
return role ?? 'no-role'; // 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 const role = await context.sessions.roleForSubject(subject);
// are in Headscale. We also need to handle what happens if someone return role ?? 'no-role';
// logs into the UI and they don't have a Headscale setup. }
return '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; let magic: string | undefined;
if (context.hs.readable()) { if (context.hs.readable()) {
@ -107,7 +106,7 @@ export default function Page() {
<p className="mb-8 text-md"> <p className="mb-8 text-md">
Manage the users in your network and their permissions. Manage the users in your network and their permissions.
</p> </p>
<ManageBanner oidc={data.oidc} isDisabled={!data.writable} /> <ManageBanner isDisabled={!data.writable} oidc={data.oidc} />
<table className="table-auto w-full rounded-lg"> <table className="table-auto w-full rounded-lg">
<thead className="text-headplane-600 dark:text-headplane-300"> <thead className="text-headplane-600 dark:text-headplane-300">
<tr className="text-left px-0.5"> <tr className="text-left px-0.5">
@ -128,8 +127,8 @@ export default function Page() {
.map((user) => ( .map((user) => (
<UserRow <UserRow
key={user.id} key={user.id}
user={user}
role={data.roles[users.indexOf(user)]} role={data.roles[users.indexOf(user)]}
user={user}
/> />
))} ))}
</tbody> </tbody>

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 type { LoadContext } from '~/server';
import { Capabilities, Roles } from '~/server/web/roles'; import { Capabilities, Roles } from '~/server/web/roles';
import { AuthSession } from '~/server/web/sessions';
import { User } from '~/types'; import { User } from '~/types';
import { data400, data403 } from '~/utils/res'; import { data400, data403 } from '~/utils/res';
@ -15,7 +14,7 @@ export async function userAction({
throw data403('You do not have permission to update users'); 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 formData = await request.formData();
const action = formData.get('action_id')?.toString(); const action = formData.get('action_id')?.toString();
if (!action) { if (!action) {

View File

@ -22,7 +22,7 @@ export async function pruneEphemeralNodes({
const { nodes } = await context.client.get<{ nodes: Machine[] }>( const { nodes } = await context.client.get<{ nodes: Machine[] }>(
'v1/node', 'v1/node',
session.get('api_key')!, session.api_key,
); );
const toPrune = nodes.filter((node) => { const toPrune = nodes.filter((node) => {
@ -42,10 +42,7 @@ export async function pruneEphemeralNodes({
const promises = toPrune.map((node) => { const promises = toPrune.map((node) => {
return async () => { return async () => {
log.debug('api', `Pruning node ${node.name}`); log.debug('api', `Pruning node ${node.name}`);
await context.client.delete( await context.client.delete(`v1/node/${node.id}`, session.api_key);
`v1/node/${node.id}`,
session.get('api_key')!,
);
await context.db await context.db
.delete(ephemeralNodes) .delete(ephemeralNodes)

View File

@ -19,3 +19,13 @@ export const hostInfo = sqliteTable('host_info', {
export type HostInfoRecord = typeof hostInfo.$inferSelect; export type HostInfoRecord = typeof hostInfo.$inferSelect;
export type HostInfoInsert = typeof hostInfo.$inferInsert; 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;

View File

@ -49,15 +49,17 @@ const appLoadContext = {
), ),
// TODO: Better cookie options in config // TODO: Better cookie options in config
sessions: await createSessionStorage( sessions: await createSessionStorage({
{ secret: config.server.cookie_secret,
name: '_hp_session', db,
maxAge: 60 * 60 * 24, // 24 hours oidcUsersFile: config.oidc?.user_storage_file,
cookie: {
name: '_hp_auth',
secure: config.server.cookie_secure, 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( client: await createApiClient(
config.headscale.url, config.headscale.url,

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 { resolve } from 'node:path';
import { exit } from 'node:process'; import { eq } from 'drizzle-orm';
import { import { LibSQLDatabase } from 'drizzle-orm/libsql/driver';
CookieSerializeOptions, import { EncryptJWT, jwtDecrypt } from 'jose';
Session, import { createCookie } from 'react-router';
SessionStorage, import { ulid } from 'ulidx';
createCookieSessionStorage,
} from 'react-router';
import log from '~/utils/log'; import log from '~/utils/log';
import { users } from '../db/schema';
import { Capabilities, Roles } from './roles'; import { Capabilities, Roles } from './roles';
export interface AuthSession { 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 { export interface OidcFlowSession {
state: 'flow'; state: 'flow';
oidc: { oidc: {
@ -32,254 +43,207 @@ export interface OidcFlowSession {
}; };
} }
type JoinedSession = AuthSession | OidcFlowSession; interface AuthSessionOptions {
interface Error { secret: string;
error: string; db: LibSQLDatabase;
} oidcUsersFile?: string;
cookie: {
interface CookieOptions { name: string;
name: string; secure: boolean;
secure: boolean; maxAge: number;
maxAge: number; domain?: string;
secrets: string[]; };
domain?: string;
} }
class Sessionizer { class Sessionizer {
private storage: SessionStorage<JoinedSession, Error>; private options: AuthSessionOptions;
private caps: Record<string, { c: Capabilities; oo?: boolean }>;
private capsPath?: string;
constructor( constructor(options: AuthSessionOptions) {
options: CookieOptions, this.options = options;
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,
},
});
} }
// This throws on the assumption that auth is already checked correctly // This throws on the assumption that auth is already checked correctly
// on something that wraps the route calling auth. The top-level routes // on something that wraps the route calling auth. The top-level routes
// that call this are wrapped with try/catch to handle the error. // that call this are wrapped with try/catch to handle the error.
async auth(request: Request) { async auth(request: Request) {
const cookie = request.headers.get('cookie'); return decodeSession(request, this.options);
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>;
} }
roleForSubject(subject: string): keyof typeof Roles | undefined { async createSession(
const role = this.caps[subject]?.c; payload: JWTSession,
if (!role) { 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; return;
} }
// We need this in string form based on Object.keys of the roles // We need this in string form based on Object.keys of the roles
for (const [key, value] of Object.entries(Roles)) { for (const [key, value] of Object.entries(Roles)) {
if (value === role) { if (value === user.caps) {
return key as keyof typeof Roles; 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 // Given an OR of capabilities, check if the session has the required
// capabilities. If not, return false. Can throw since it calls auth() // capabilities. If not, return false. Can throw since it calls auth()
async check(request: Request, capabilities: Capabilities) { async check(request: Request, capabilities: Capabilities) {
const session = await this.auth(request); 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; return false;
} }
// This is the subject we set on API key based sessions. API keys return (capabilities & user.caps) === capabilities;
// 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);
}
} }
// Updates the capabilities and roles of a subject // Updates the capabilities and roles of a subject
async reassignSubject(subject: string, role: keyof typeof Roles) { async reassignSubject(subject: string, role: keyof typeof Roles) {
// Check if we are owner // Check if we are owner
if (this.roleForSubject(subject) === 'owner') { const subjectRole = await this.roleForSubject(subject);
if (subjectRole === 'owner') {
return false; return false;
} }
this.caps[subject] = { await this.options.db
...this.caps[subject], // Preserve the existing capabilities if any .update(users)
c: Roles[role], .set({
}; caps: Roles[role],
})
.where(eq(users.sub, subject));
await this.flushUserDatabase();
return true; 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( async function createSession(payload: JWTSession, options: AuthSessionOptions) {
options: CookieOptions, const secret = createHash('sha256').update(options.secret, 'utf8').digest();
usersPath?: string, const jwt = await new EncryptJWT({
) { ...payload,
const map: Record< })
string, .setProtectedHeader({ alg: 'dir', enc: 'A256GCM', typ: 'JWT' })
{ .setIssuedAt()
c: number; .setExpirationTime('1d')
oo?: boolean; .setIssuer('urn:tale:headplane')
} .setAudience('urn:tale:headplane')
> = {}; .setJti(ulid())
if (usersPath) { .encrypt(secret);
// 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);
for (const user of data) { const cookie = createCookie(options.cookie.name, {
map[user.u] = { ...options.cookie,
c: user.c, path: __PREFIX__,
oo: user.oo, });
};
}
}
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); const realPath = resolve(path);
try { try {
const handle = await open(realPath, 'a+'); const handle = await open(realPath, 'a+');
log.info('config', 'Using user database file at %s', realPath);
await handle.close(); await handle.close();
} catch (error) { } 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); 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 { try {
const data = await readFile(realPath, 'utf8'); const data = await readFile(realPath, 'utf8');
const users = JSON.parse(data.trim()) as { const users = JSON.parse(data.trim()) as {
@ -288,8 +252,7 @@ async function loadUserFile(path: string) {
oo?: boolean; oo?: boolean;
}[]; }[];
// Never trust user input migratableUsers = users.filter(
return users.filter(
(user) => user.u !== undefined && user.c !== undefined, (user) => user.u !== undefined && user.c !== undefined,
) as { ) as {
u: string; u: string;
@ -297,8 +260,38 @@ async function loadUserFile(path: string) {
oo?: boolean; oo?: boolean;
}[]; }[];
} catch (error) { } catch (error) {
log.debug('config', 'Error reading user database file: %s', error); log.warn('config', 'Error reading old user database file: %s', error);
log.debug('config', 'Using empty user database'); log.warn('config', 'Not migrating any users');
return []; 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 });
} }

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

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": {}
}
}

View File

@ -15,6 +15,13 @@
"when": 1755554742267, "when": 1755554742267,
"tag": "0001_naive_lilith", "tag": "0001_naive_lilith",
"breakpoints": true "breakpoints": true
},
{
"idx": 2,
"version": "6",
"when": 1755617607599,
"tag": "0002_square_bloodstorm",
"breakpoints": true
} }
] ]
} }

View File

@ -45,6 +45,7 @@
"dotenv": "^16.5.0", "dotenv": "^16.5.0",
"drizzle-orm": "^0.44.2", "drizzle-orm": "^0.44.2",
"isbot": "^5.1.28", "isbot": "^5.1.28",
"jose": "6.0.12",
"lucide-react": "^0.511.0", "lucide-react": "^0.511.0",
"mime": "^4.0.7", "mime": "^4.0.7",
"openid-client": "^6.5.0", "openid-client": "^6.5.0",
@ -59,6 +60,7 @@
"react-stately": "^3.38.0", "react-stately": "^3.38.0",
"remix-utils": "^8.8.0", "remix-utils": "^8.8.0",
"tailwind-merge": "^3.3.0", "tailwind-merge": "^3.3.0",
"ulidx": "2.4.1",
"undici": "^7.10.0", "undici": "^7.10.0",
"usehooks-ts": "^3.1.1", "usehooks-ts": "^3.1.1",
"yaml": "^2.8.0" "yaml": "^2.8.0"
@ -92,8 +94,14 @@
}, },
"pnpm": { "pnpm": {
"supportedArchitectures": { "supportedArchitectures": {
"os": ["current", "linux"], "os": [
"cpu": ["x64", "arm64"] "current",
"linux"
],
"cpu": [
"x64",
"arm64"
]
}, },
"onlyBuiltDependencies": [ "onlyBuiltDependencies": [
"@biomejs/biome", "@biomejs/biome",

27
pnpm-lock.yaml generated
View File

@ -100,6 +100,9 @@ importers:
isbot: isbot:
specifier: ^5.1.28 specifier: ^5.1.28
version: 5.1.28 version: 5.1.28
jose:
specifier: 6.0.12
version: 6.0.12
lucide-react: lucide-react:
specifier: ^0.511.0 specifier: ^0.511.0
version: 0.511.0(react@19.1.1) version: 0.511.0(react@19.1.1)
@ -142,6 +145,9 @@ importers:
tailwind-merge: tailwind-merge:
specifier: ^3.3.0 specifier: ^3.3.0
version: 3.3.0 version: 3.3.0
ulidx:
specifier: 2.4.1
version: 2.4.1
undici: undici:
specifier: ^7.10.0 specifier: ^7.10.0
version: 7.10.0 version: 7.10.0
@ -3081,8 +3087,8 @@ packages:
resolution: {integrity: sha512-twQoecYPiVA5K/h6SxtORw/Bs3ar+mLUtoPSc7iMXzQzK8d7eJ/R09wmTwAjiamETn1cXYPGfNnu7DMoHgu12w==} resolution: {integrity: sha512-twQoecYPiVA5K/h6SxtORw/Bs3ar+mLUtoPSc7iMXzQzK8d7eJ/R09wmTwAjiamETn1cXYPGfNnu7DMoHgu12w==}
hasBin: true hasBin: true
jose@6.0.11: jose@6.0.12:
resolution: {integrity: sha512-QxG7EaliDARm1O1S8BGakqncGT9s25bKL1WSf6/oa17Tkqwi8D2ZNglqCF+DsYF88/rV66Q/Q2mFAy697E1DUg==} resolution: {integrity: sha512-T8xypXs8CpmiIi78k0E+Lk7T2zlK4zDyg+o1CZ4AkOHgDg98ogdP2BeZ61lTFKFyoEwJ9RgAgN+SdM3iPgNonQ==}
js-base64@3.7.7: js-base64@3.7.7:
resolution: {integrity: sha512-7rCnleh0z2CkXhH67J8K1Ytz0b2Y+yxTPL+/KOJoa20hfnVQ/3/T6W/KflYI4bRHRagNeXeU2bkNGI3v1oS/lw==} resolution: {integrity: sha512-7rCnleh0z2CkXhH67J8K1Ytz0b2Y+yxTPL+/KOJoa20hfnVQ/3/T6W/KflYI4bRHRagNeXeU2bkNGI3v1oS/lw==}
@ -3127,6 +3133,9 @@ packages:
resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==}
engines: {node: '>=6'} engines: {node: '>=6'}
layerr@3.0.0:
resolution: {integrity: sha512-tv754Ki2dXpPVApOrjTyRo4/QegVb9eVFq4mjqp4+NM5NaX7syQvN5BBNfV/ZpAHCEHV24XdUVrBAoka4jt3pA==}
lefthook-darwin-arm64@1.11.13: lefthook-darwin-arm64@1.11.13:
resolution: {integrity: sha512-gHwHofXupCtzNLN+8esdWfFTnAEkmBxE/WKA0EwxPPJXdZYa1GUsiG5ipq/CdG/0j8ekYyM9Hzyrrk5BqJ42xw==} resolution: {integrity: sha512-gHwHofXupCtzNLN+8esdWfFTnAEkmBxE/WKA0EwxPPJXdZYa1GUsiG5ipq/CdG/0j8ekYyM9Hzyrrk5BqJ42xw==}
cpu: [arm64] cpu: [arm64]
@ -4003,6 +4012,10 @@ packages:
engines: {node: '>=14.17'} engines: {node: '>=14.17'}
hasBin: true hasBin: true
ulidx@2.4.1:
resolution: {integrity: sha512-xY7c8LPyzvhvew0Fn+Ek3wBC9STZAuDI/Y5andCKi9AX6/jvfaX45PhsDX8oxgPL0YFp0Jhr8qWMbS/p9375Xg==}
engines: {node: '>=16'}
undici-types@6.21.0: undici-types@6.21.0:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
@ -7415,7 +7428,7 @@ snapshots:
jiti@2.5.1: {} jiti@2.5.1: {}
jose@6.0.11: {} jose@6.0.12: {}
js-base64@3.7.7: {} js-base64@3.7.7: {}
@ -7445,6 +7458,8 @@ snapshots:
kleur@4.1.5: {} kleur@4.1.5: {}
layerr@3.0.0: {}
lefthook-darwin-arm64@1.11.13: lefthook-darwin-arm64@1.11.13:
optional: true optional: true
@ -7710,7 +7725,7 @@ snapshots:
openid-client@6.5.0: openid-client@6.5.0:
dependencies: dependencies:
jose: 6.0.11 jose: 6.0.12
oauth4webapi: 3.5.1 oauth4webapi: 3.5.1
package-json-from-dist@1.0.1: {} package-json-from-dist@1.0.1: {}
@ -8346,6 +8361,10 @@ snapshots:
typescript@5.9.2: {} typescript@5.9.2: {}
ulidx@2.4.1:
dependencies:
layerr: 3.0.0
undici-types@6.21.0: {} undici-types@6.21.0: {}
undici-types@7.10.0: {} undici-types@7.10.0: {}