diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e01999..124fb56 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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:-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. diff --git a/app/layouts/dashboard.tsx b/app/layouts/dashboard.tsx index 5e93233..ffd8aea 100644 --- a/app/layouts/dashboard.tsx +++ b/app/layouts/dashboard.tsx @@ -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 ( - <> -
- -
- +
+ +
); } diff --git a/app/layouts/shell.tsx b/app/layouts/shell.tsx index 7fb7a17..0b2d247 100644 --- a/app/layouts/shell.tsx +++ b/app/layouts/shell.tsx @@ -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) { 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(), + }, + }); } } diff --git a/app/routes/acls/acl-action.ts b/app/routes/acls/acl-action.ts index 11e4bab..fc319cf 100644 --- a/app/routes/acls/acl-action.ts +++ b/app/routes/acls/acl-action.ts @@ -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, }); diff --git a/app/routes/acls/acl-loader.ts b/app/routes/acls/acl-loader.ts index 4b7ce56..19ffd3c 100644 --- a/app/routes/acls/acl-loader.ts +++ b/app/routes/acls/acl-loader.ts @@ -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. diff --git a/app/routes/auth/login/action.ts b/app/routes/auth/login/action.ts index ad5c2dd..10f728d 100644 --- a/app/routes/auth/login/action.ts +++ b/app/routes/auth/login/action.ts @@ -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) { diff --git a/app/routes/auth/login/page.tsx b/app/routes/auth/login/page.tsx index 3f8cb61..8ed5370 100644 --- a/app/routes/auth/login/page.tsx +++ b/app/routes/auth/login/page.tsx @@ -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) { 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. {formData?.success === false ? ( {formData.message} ) : undefined} - {oidc ? ( - diff --git a/app/routes/auth/logout.ts b/app/routes/auth/logout.ts index 0713052..e46e7a2 100644 --- a/app/routes/auth/logout.ts +++ b/app/routes/auth/logout.ts @@ -9,9 +9,10 @@ export async function action({ request, context, }: ActionFunctionArgs) { - 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(), }, }); } diff --git a/app/routes/auth/oidc-callback.ts b/app/routes/auth/oidc-callback.ts index 342beca..eec883b 100644 --- a/app/routes/auth/oidc-callback.ts +++ b/app/routes/auth/oidc-callback.ts @@ -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(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; - // 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) { diff --git a/app/routes/auth/oidc-start.ts b/app/routes/auth/oidc-start.ts index 89c6dfb..2b8d056 100644 --- a/app/routes/auth/oidc-start.ts +++ b/app/routes/auth/oidc-start.ts @@ -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) { - const session = await context.sessions.getOrCreate(request); - if ((session as Session).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, + }), }, }); } diff --git a/app/routes/machines/machine-actions.ts b/app/routes/machines/machine-actions.ts index d613cfb..791cb68 100644 --- a/app/routes/machines/machine-actions.ts +++ b/app/routes/machines/machine-actions.ts @@ -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', { diff --git a/app/routes/machines/machine.tsx b/app/routes/machines/machine.tsx index 23b4a2a..5b8bcaa 100644 --- a/app/routes/machines/machine.tsx +++ b/app/routes/machines/machine.tsx @@ -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 (

- + All Machines / @@ -91,9 +91,9 @@ export default function Page() { >

{node.givenName}

- + - +
@@ -123,14 +123,14 @@ export default function Page() {
- +

Subnets & Routing

Subnets let you expose physical network routes onto Tailscale.{' '} Learn More @@ -138,11 +138,11 @@ export default function Page() {

@@ -166,11 +166,11 @@ export default function Page() { )}
@@ -198,11 +198,11 @@ export default function Page() { )} @@ -233,11 +233,11 @@ export default function Page() { )} @@ -249,15 +249,15 @@ export default function Page() { issues.

{stats ? ( @@ -267,14 +267,14 @@ export default function Page() { ) : undefined} {magic ? ( ) : undefined} @@ -341,13 +341,13 @@ export default function Page() { Client Connectivity

) { 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() {

Manage the devices connected to your Tailnet.{' '} Learn more

@@ -141,16 +138,16 @@ export default function Page() { > {data.populatedNodes.map((machine) => ( ))} diff --git a/app/routes/settings/auth-keys/actions.ts b/app/routes/settings/auth-keys/actions.ts index cdb4517..e503bc6 100644 --- a/app/routes/settings/auth-keys/actions.ts +++ b/app/routes/settings/auth-keys/actions.ts @@ -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.', { diff --git a/app/routes/settings/auth-keys/overview.tsx b/app/routes/settings/auth-keys/overview.tsx index a2457e7..9f328e2 100644 --- a/app/routes/settings/auth-keys/overview.tsx +++ b/app/routes/settings/auth-keys/overview.tsx @@ -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 (

- + Settings / 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{' '} Tailscale documentation @@ -185,14 +186,14 @@ export default function Page() {

@@ -128,8 +127,8 @@ export default function Page() { .map((user) => ( ))} diff --git a/app/routes/users/user-actions.ts b/app/routes/users/user-actions.ts index 821d121..1e94a12 100644 --- a/app/routes/users/user-actions.ts +++ b/app/routes/users/user-actions.ts @@ -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) { diff --git a/app/server/db/pruner.ts b/app/server/db/pruner.ts index 815e393..9491957 100644 --- a/app/server/db/pruner.ts +++ b/app/server/db/pruner.ts @@ -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) diff --git a/app/server/db/schema.ts b/app/server/db/schema.ts index 9aa6f69..1f24d1f 100644 --- a/app/server/db/schema.ts +++ b/app/server/db/schema.ts @@ -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; diff --git a/app/server/index.ts b/app/server/index.ts index acb6fdd..0e82e4f 100644 --- a/app/server/index.ts +++ b/app/server/index.ts @@ -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, diff --git a/app/server/web/sessions.ts b/app/server/web/sessions.ts index 164dc33..88b5724 100644 --- a/app/server/web/sessions.ts +++ b/app/server/web/sessions.ts @@ -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; - private caps: Record; - private capsPath?: string; + private options: AuthSessionOptions; - constructor( - options: CookieOptions, - caps: Record, - 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; + 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 { + 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(request: Request) { - return this.storage.getSession(request.headers.get('cookie')) as Promise< - Session - >; - } - - 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 }); } diff --git a/drizzle/0002_square_bloodstorm.sql b/drizzle/0002_square_bloodstorm.sql new file mode 100644 index 0000000..2c22aff --- /dev/null +++ b/drizzle/0002_square_bloodstorm.sql @@ -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`); diff --git a/drizzle/meta/0002_snapshot.json b/drizzle/meta/0002_snapshot.json new file mode 100644 index 0000000..9b09baf --- /dev/null +++ b/drizzle/meta/0002_snapshot.json @@ -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": {} + } +} diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index e7c8c17..78491b6 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -15,6 +15,13 @@ "when": 1755554742267, "tag": "0001_naive_lilith", "breakpoints": true + }, + { + "idx": 2, + "version": "6", + "when": 1755617607599, + "tag": "0002_square_bloodstorm", + "breakpoints": true } ] } diff --git a/package.json b/package.json index 7600d17..30d598a 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4a2a9e7..e5c4dbb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: {}