mirror of
https://github.com/tale/headplane.git
synced 2026-07-26 15:58:14 +00:00
325 lines
7.6 KiB
TypeScript
325 lines
7.6 KiB
TypeScript
import { createHash } from 'node:crypto';
|
|
import { open, readFile, rm } from 'node:fs/promises';
|
|
import { resolve } from 'node:path';
|
|
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 {
|
|
state: 'auth';
|
|
api_key: string;
|
|
user: {
|
|
subject: string;
|
|
name: string;
|
|
email?: string;
|
|
username?: string;
|
|
picture?: string;
|
|
};
|
|
}
|
|
|
|
interface JWTSession {
|
|
api_key: string;
|
|
user: {
|
|
subject: string;
|
|
name: string;
|
|
email?: string;
|
|
username?: string;
|
|
picture?: string;
|
|
};
|
|
}
|
|
|
|
export interface OidcFlowSession {
|
|
state: 'flow';
|
|
oidc: {
|
|
state: string;
|
|
nonce: string;
|
|
code_verifier: string;
|
|
redirect_uri: string;
|
|
};
|
|
}
|
|
|
|
interface AuthSessionOptions {
|
|
secret: string;
|
|
db: LibSQLDatabase;
|
|
oidcUsersFile?: string;
|
|
cookie: {
|
|
name: string;
|
|
secure: boolean;
|
|
maxAge: number;
|
|
domain?: string;
|
|
};
|
|
}
|
|
|
|
class Sessionizer {
|
|
private options: AuthSessionOptions;
|
|
|
|
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) {
|
|
return decodeSession(request, this.options);
|
|
}
|
|
|
|
async createSession(
|
|
payload: JWTSession,
|
|
maxAge = this.options.cookie.maxAge,
|
|
) {
|
|
// TODO: What the hell is this garbage
|
|
return createSession(payload, {
|
|
...this.options,
|
|
cookie: {
|
|
...this.options.cookie,
|
|
maxAge,
|
|
},
|
|
});
|
|
}
|
|
|
|
async destroySession() {
|
|
return destroySession(this.options);
|
|
}
|
|
|
|
async roleForSubject(
|
|
subject: string,
|
|
): Promise<keyof typeof Roles | undefined> {
|
|
const [user] = await this.options.db
|
|
.select()
|
|
.from(users)
|
|
.where(eq(users.sub, subject))
|
|
.limit(1);
|
|
|
|
if (!user) {
|
|
return;
|
|
}
|
|
|
|
// We need this in string form based on Object.keys of the roles
|
|
for (const [key, value] of Object.entries(Roles)) {
|
|
if (value === user.caps) {
|
|
return key as keyof typeof Roles;
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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);
|
|
|
|
// 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 (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
|
|
const subjectRole = await this.roleForSubject(subject);
|
|
if (subjectRole === 'owner') {
|
|
return false;
|
|
}
|
|
|
|
await this.options.db
|
|
.update(users)
|
|
.set({
|
|
caps: Roles[role],
|
|
})
|
|
.where(eq(users.sub, subject));
|
|
|
|
return true;
|
|
}
|
|
}
|
|
|
|
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);
|
|
|
|
const cookie = createCookie(options.cookie.name, {
|
|
...options.cookie,
|
|
path: __PREFIX__,
|
|
});
|
|
|
|
return cookie.serialize(jwt);
|
|
}
|
|
|
|
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);
|
|
|
|
log.warn(
|
|
'config',
|
|
'oidc.user_storage_file is deprecated and will be removed in Headplane 0.7.0',
|
|
);
|
|
log.warn(
|
|
'config',
|
|
'You can ignore this warning if you do not use OIDC authentication.',
|
|
);
|
|
log.warn(
|
|
'config',
|
|
'Data will be automatically migrated to the new SQL database.',
|
|
);
|
|
log.warn(
|
|
'config',
|
|
'Refer to server.data_path to ensure this path is mounted correctly is using Docker.',
|
|
);
|
|
|
|
try {
|
|
const handle = await open(realPath, 'a+');
|
|
await handle.close();
|
|
} catch (error) {
|
|
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);
|
|
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');
|
|
if (data.trim().length === 0) {
|
|
log.info('config', 'Old user database file is empty, nothing to migrate');
|
|
log.info(
|
|
'config',
|
|
'You SHOULD remove oidc.user_storage_file from your config!',
|
|
);
|
|
await rm(realPath, { force: true });
|
|
return;
|
|
}
|
|
|
|
const users = JSON.parse(data.trim()) as {
|
|
u?: string;
|
|
c?: number;
|
|
oo?: boolean;
|
|
}[];
|
|
|
|
migratableUsers = users.filter(
|
|
(user) => user.u !== undefined && user.c !== undefined,
|
|
) as {
|
|
u: string;
|
|
c: number;
|
|
oo?: boolean;
|
|
}[];
|
|
} catch (error) {
|
|
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 });
|
|
}
|