-
+
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() {
setSelectedUser(value?.toString() ?? '')
}
+ placeholder="Select a user"
>
{[
All ,
@@ -202,14 +203,14 @@ export default function Page() {
]}
setStatus((value?.toString() ?? 'active') as Status)
}
+ placeholder="Select a status"
>
All
Active
diff --git a/app/routes/ssh/console.tsx b/app/routes/ssh/console.tsx
index ff6ba74..afa40b2 100644
--- a/app/routes/ssh/console.tsx
+++ b/app/routes/ssh/console.tsx
@@ -1,24 +1,24 @@
+/** biome-ignore-all lint/correctness/noNestedComponentDefinitions: Wtf? */
import { faker } from '@faker-js/faker';
+import { eq } from 'drizzle-orm';
import { Loader2 } from 'lucide-react';
import { useEffect, useState } from 'react';
import {
ActionFunctionArgs,
+ data,
LoaderFunctionArgs,
ShouldRevalidateFunction,
- data,
useLoaderData,
useSubmit,
} from 'react-router';
+import wasm from '~/hp_ssh.wasm?url';
import { LoadContext } from '~/server';
+import { EphemeralNodeInsert, ephemeralNodes } from '~/server/db/schema';
import { Machine, PreAuthKey, User } from '~/types';
import { useLiveData } from '~/utils/live-data';
-import XTerm from './xterm.client';
-
-import { eq } from 'drizzle-orm';
-import wasm from '~/hp_ssh.wasm?url';
-import { EphemeralNodeInsert, ephemeralNodes } from '~/server/db/schema';
import '~/wasm_exec';
import UserPrompt from './user-prompt';
+import XTerm from './xterm.client';
export const shouldRevalidate: ShouldRevalidateFunction = () => {
return false;
@@ -36,16 +36,12 @@ export async function loader({
}
const session = await context.sessions.auth(request);
- const user = session.get('user');
- if (!user) {
- throw data('Unauthorized', 401);
- }
- if (user.subject === 'unknown-non-oauth') {
+ if (session.user.subject === 'unknown-non-oauth') {
throw data('Only OAuth users are allowed to use WebSSH', 403);
}
const { users } = await context.client.get<{ users: User[] }>(
'v1/user',
- session.get('api_key')!,
+ session.api_key,
);
// MARK: This assumes that a user has authenticated with Headscale first
@@ -57,19 +53,19 @@ export async function loader({
if (!subject) {
return false;
}
- return subject === user.subject;
+ return subject === session.user.subject;
});
if (!lookup) {
throw data(
- `User with subject ${user.subject} not found within Headscale`,
+ `User with subject ${session.user.subject} not found within Headscale`,
404,
);
}
const { preAuthKey } = await context.client.post<{ preAuthKey: PreAuthKey }>(
'v1/preauthkey',
- session.get('api_key')!,
+ session.api_key,
{
user: lookup.id,
reusable: false,
@@ -122,7 +118,7 @@ export async function loader({
const { nodes } = await context.client.get<{ nodes: Machine[] }>(
'v1/node',
- session.get('api_key')!,
+ session.api_key,
);
// node.name is the hostname, given_name is the set name
@@ -133,7 +129,7 @@ export async function loader({
// Last thing is keeping track of the ephemeral node in the database
// because Headscale doesn't automatically delete ephemeral nodes???
- const [ephemeralNode] = await context.db
+ const [_ephemeralNode] = await context.db
.insert(ephemeralNodes)
.values({
auth_key: preAuthKey.key,
@@ -176,7 +172,7 @@ export async function action({
request,
context,
}: ActionFunctionArgs) {
- const session = await context.sessions.auth(request);
+ const _session = await context.sessions.auth(request);
if (!context.agents?.agentID()) {
throw data(
'WebSSH is only available with the Headplane agent integration',
@@ -274,9 +270,9 @@ export default function Page() {
) : (
)}
diff --git a/app/routes/users/onboarding-skip.tsx b/app/routes/users/onboarding-skip.tsx
index 06a9837..faf72a4 100644
--- a/app/routes/users/onboarding-skip.tsx
+++ b/app/routes/users/onboarding-skip.tsx
@@ -1,16 +1,23 @@
+import { eq } from 'drizzle-orm';
import { LoaderFunctionArgs, redirect } from 'react-router';
import { LoadContext } from '~/server';
+import { users } from '~/server/db/schema';
export async function loader({
request,
context,
}: LoaderFunctionArgs) {
- const session = await context.sessions.auth(request);
- const user = session.get('user');
- if (!user) {
+ try {
+ const { user } = await context.sessions.auth(request);
+ await context.db
+ .update(users)
+ .set({
+ onboarded: true,
+ })
+ .where(eq(users.sub, user.subject));
+
+ return redirect('/machines');
+ } catch {
return redirect('/login');
}
-
- context.sessions.overrideOnboarding(user.subject, true);
- return redirect('/machines');
}
diff --git a/app/routes/users/onboarding.tsx b/app/routes/users/onboarding.tsx
index 52e4cee..5f92dfb 100644
--- a/app/routes/users/onboarding.tsx
+++ b/app/routes/users/onboarding.tsx
@@ -4,12 +4,7 @@ import { GrApple } from 'react-icons/gr';
import { ImFinder } from 'react-icons/im';
import { MdAndroid } from 'react-icons/md';
import { PiTerminalFill, PiWindowsLogoFill } from 'react-icons/pi';
-import {
- LoaderFunctionArgs,
- NavLink,
- redirect,
- useLoaderData,
-} from 'react-router';
+import { LoaderFunctionArgs, NavLink, useLoaderData } from 'react-router';
import Button from '~/components/Button';
import Card from '~/components/Card';
import Link from '~/components/Link';
@@ -27,10 +22,6 @@ export async function loader({
context,
}: LoaderFunctionArgs) {
const session = await context.sessions.auth(request);
- const user = session.get('user');
- if (!user) {
- return redirect('/login');
- }
// Try to determine the OS split between Linux, Windows, macOS, iOS, and Android
// We need to convert this to a known value to return it to the client so we can
@@ -60,11 +51,11 @@ export async function loader({
break;
}
- let firstMachine: Machine | undefined = undefined;
+ let firstMachine: Machine | undefined;
try {
const { nodes } = await context.client.get<{ nodes: Machine[] }>(
'v1/node',
- session.get('api_key')!,
+ session.api_key,
);
const node = nodes.find((n) => {
@@ -79,12 +70,7 @@ export async function loader({
return false;
}
- const sessionUser = session.get('user');
- if (!sessionUser) {
- return false;
- }
-
- if (subject !== sessionUser.subject) {
+ if (subject !== session.user.subject) {
return false;
}
@@ -98,7 +84,7 @@ export async function loader({
}
return {
- user,
+ user: session.user,
osValue,
firstMachine,
};
@@ -126,7 +112,7 @@ export default function Page() {
return (
-
+
Welcome!
@@ -138,9 +124,9 @@ export default function Page() {
-
+
Download for Windows
@@ -206,12 +192,12 @@ export default function Page() {
}
>
-
+
Download for macOS
@@ -238,12 +224,12 @@ export default function Page() {
}
>
-
+
Download for iOS
@@ -261,12 +247,12 @@ export default function Page() {
}
>
-
+
Download for Android
@@ -287,8 +273,8 @@ export default function Page() {
@@ -300,7 +286,7 @@ export default function Page() {
IP Addresses
{firstMachine.ipAddresses.map((ip) => (
-
+
{ip}
))}
@@ -308,8 +294,8 @@ export default function Page() {
-
-
+
+
Continue
@@ -335,7 +321,7 @@ export default function Page() {
)}
-
+
I already know what I'm doing
diff --git a/app/routes/users/overview.tsx b/app/routes/users/overview.tsx
index a0fcf99..0335eb4 100644
--- a/app/routes/users/overview.tsx
+++ b/app/routes/users/overview.tsx
@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react';
import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';
-import { useLoaderData, useSubmit } from 'react-router';
+import { useLoaderData } from 'react-router';
import type { LoadContext } from '~/server';
import { Capabilities } from '~/server/web/roles';
import { Machine, User } from '~/types';
@@ -32,11 +32,8 @@ export async function loader({
);
const [machines, apiUsers] = await Promise.all([
- context.client.get<{ nodes: Machine[] }>(
- 'v1/node',
- session.get('api_key')!,
- ),
- context.client.get<{ users: User[] }>('v1/user', session.get('api_key')!),
+ context.client.get<{ nodes: Machine[] }>('v1/node', session.api_key),
+ context.client.get<{ users: User[] }>('v1/user', session.api_key),
]);
const users = apiUsers.users.map((user) => ({
@@ -44,30 +41,32 @@ export async function loader({
machines: machines.nodes.filter((machine) => machine.user.id === user.id),
}));
- const roles = users
- .sort((a, b) => a.name.localeCompare(b.name))
- .map((user) => {
- if (user.provider !== 'oidc') {
- return 'no-oidc';
- }
-
- if (user.provider === 'oidc' && user.providerId) {
- // For some reason, headscale makes providerID a url where the
- // last component is the subject, so we need to strip that out
- const subject = user.providerId.split('/').pop();
- if (!subject) {
- return 'invalid-oidc';
+ const roles = await Promise.all(
+ users
+ .sort((a, b) => a.name.localeCompare(b.name))
+ .map(async (user) => {
+ if (user.provider !== 'oidc') {
+ return 'no-oidc';
}
- const role = context.sessions.roleForSubject(subject);
- return role ?? 'no-role';
- }
+ if (user.provider === 'oidc' && user.providerId) {
+ // For some reason, headscale makes providerID a url where the
+ // last component is the subject, so we need to strip that out
+ const subject = user.providerId.split('/').pop();
+ if (!subject) {
+ return 'invalid-oidc';
+ }
- // No role means the user is not registered in Headplane, but they
- // are in Headscale. We also need to handle what happens if someone
- // logs into the UI and they don't have a Headscale setup.
- return 'no-role';
- });
+ const role = await context.sessions.roleForSubject(subject);
+ return role ?? 'no-role';
+ }
+
+ // No role means the user is not registered in Headplane, but they
+ // are in Headscale. We also need to handle what happens if someone
+ // logs into the UI and they don't have a Headscale setup.
+ return 'no-role';
+ }),
+ );
let magic: string | undefined;
if (context.hs.readable()) {
@@ -107,7 +106,7 @@ export default function Page() {
Manage the users in your network and their permissions.
-
+
@@ -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: {}