Initial commit: everythingbutwork MVP
Internes Project-Management-Tool für ToolCTRL — ergänzend zu Plane (Tickets) und Outline (Doku). Umfasst Puls-Übersicht, Tagesrhythmus (Lock-in + Reflexion), Bereichs-Detail-Views (Entwicklung/Vertrieb/Marketing), Pflicht-Minimum-Check und Blocker-Lifecycle gemäß Projektmanagement-Leitfaden v3.0. Stack: pnpm-Monorepo · Vite + React 18 + Tailwind · Fastify + tRPC v11 + Drizzle ORM · Postgres 16 · Authentik OIDC (Phase 2, Dev-Auto-Login aktiv). Planungs-Artefakte unter _bmad-output/planning-artifacts/. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
4f33509a0f
131 files changed
+34339
No files matched your search
@@ -0,0 +1,25 @@
|
||||
// Fastify-Decorator: macht `req.cockpitUser` verfügbar, wenn die Session gültig ist.
|
||||
// Die Typaugmentation lebt in `../types.ts`, damit sie auch in tRPC-Imports
|
||||
// gezogen wird (web → api Type-Only-Import).
|
||||
|
||||
import '../types.js';
|
||||
import type { FastifyInstance, FastifyRequest } from 'fastify';
|
||||
import { loadEnv } from '../env.js';
|
||||
import { getSessionWithUser } from './session.js';
|
||||
|
||||
export async function registerAuthMiddleware(app: FastifyInstance): Promise<void> {
|
||||
const env = loadEnv();
|
||||
|
||||
app.decorateRequest('cockpitUser', null);
|
||||
app.decorateRequest('cockpitSessionId', null);
|
||||
|
||||
app.addHook('onRequest', async (req: FastifyRequest) => {
|
||||
const sessionId = req.cookies[env.SESSION_COOKIE_NAME];
|
||||
if (!sessionId) return;
|
||||
const found = await getSessionWithUser(sessionId);
|
||||
if (found) {
|
||||
req.cockpitUser = found.user;
|
||||
req.cockpitSessionId = sessionId;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
// Authentik OIDC via openid-client v6. Authorization Code + PKCE.
|
||||
|
||||
import * as oidc from 'openid-client';
|
||||
import { loadEnv } from '../env.js';
|
||||
import { logger } from '../lib/logger.js';
|
||||
|
||||
let cachedConfig: oidc.Configuration | undefined;
|
||||
|
||||
export async function getOidcConfig(): Promise<oidc.Configuration> {
|
||||
if (cachedConfig) return cachedConfig;
|
||||
const env = loadEnv();
|
||||
cachedConfig = await oidc.discovery(
|
||||
new URL(env.AUTHENTIK_ISSUER),
|
||||
env.AUTHENTIK_CLIENT_ID,
|
||||
env.AUTHENTIK_CLIENT_SECRET,
|
||||
);
|
||||
logger.info({ issuer: env.AUTHENTIK_ISSUER }, 'oidc discovery completed');
|
||||
return cachedConfig;
|
||||
}
|
||||
|
||||
export interface PendingFlow {
|
||||
state: string;
|
||||
nonce: string;
|
||||
codeVerifier: string;
|
||||
returnTo: string;
|
||||
}
|
||||
|
||||
export async function buildAuthorizationUrl(returnTo: string): Promise<{
|
||||
url: URL;
|
||||
pending: PendingFlow;
|
||||
}> {
|
||||
const config = await getOidcConfig();
|
||||
const env = loadEnv();
|
||||
|
||||
const codeVerifier = oidc.randomPKCECodeVerifier();
|
||||
const codeChallenge = await oidc.calculatePKCECodeChallenge(codeVerifier);
|
||||
const state = oidc.randomState();
|
||||
const nonce = oidc.randomNonce();
|
||||
|
||||
const url = oidc.buildAuthorizationUrl(config, {
|
||||
redirect_uri: env.AUTHENTIK_REDIRECT_URI,
|
||||
scope: 'openid profile email groups',
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: 'S256',
|
||||
state,
|
||||
nonce,
|
||||
});
|
||||
|
||||
return {
|
||||
url,
|
||||
pending: { state, nonce, codeVerifier, returnTo },
|
||||
};
|
||||
}
|
||||
|
||||
export interface OidcUserInfo {
|
||||
sub: string;
|
||||
email: string;
|
||||
name: string;
|
||||
groups: string[];
|
||||
}
|
||||
|
||||
export async function completeAuthorization(
|
||||
callbackUrl: URL,
|
||||
pending: PendingFlow,
|
||||
): Promise<OidcUserInfo> {
|
||||
const config = await getOidcConfig();
|
||||
const tokens = await oidc.authorizationCodeGrant(config, callbackUrl, {
|
||||
pkceCodeVerifier: pending.codeVerifier,
|
||||
expectedState: pending.state,
|
||||
expectedNonce: pending.nonce,
|
||||
idTokenExpected: true,
|
||||
});
|
||||
|
||||
const claims = tokens.claims();
|
||||
if (!claims) {
|
||||
throw new Error('OIDC-Token enthält keine Claims.');
|
||||
}
|
||||
|
||||
const sub = String(claims.sub ?? '');
|
||||
const email = String(claims.email ?? '');
|
||||
const name = String(claims.name ?? claims.preferred_username ?? email);
|
||||
const rawGroups = claims.groups;
|
||||
const groups: string[] = Array.isArray(rawGroups)
|
||||
? rawGroups.filter((g): g is string => typeof g === 'string')
|
||||
: [];
|
||||
|
||||
if (!sub || !email) {
|
||||
throw new Error('OIDC-Claims unvollständig (sub oder email fehlt).');
|
||||
}
|
||||
|
||||
return { sub, email, name, groups };
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
||||
import { loadEnv } from '../env.js';
|
||||
import { logger } from '../lib/logger.js';
|
||||
import { buildAuthorizationUrl, completeAuthorization, type PendingFlow } from './oidc.js';
|
||||
import { createSession, deleteSession } from './session.js';
|
||||
import { upsertUserFromOidc } from './user.js';
|
||||
|
||||
const PENDING_COOKIE = 'cockpit_oidc_pending';
|
||||
|
||||
function setSessionCookie(reply: FastifyReply, sessionId: string): void {
|
||||
const env = loadEnv();
|
||||
const maxAge = env.SESSION_TTL_HOURS * 60 * 60;
|
||||
reply.setCookie(env.SESSION_COOKIE_NAME, sessionId, {
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
secure: env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
maxAge,
|
||||
signed: false,
|
||||
});
|
||||
}
|
||||
|
||||
function clearSessionCookie(reply: FastifyReply): void {
|
||||
const env = loadEnv();
|
||||
reply.clearCookie(env.SESSION_COOKIE_NAME, { path: '/' });
|
||||
}
|
||||
|
||||
function setPendingCookie(reply: FastifyReply, pending: PendingFlow): void {
|
||||
reply.setCookie(PENDING_COOKIE, JSON.stringify(pending), {
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
secure: loadEnv().NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
maxAge: 10 * 60, // 10 Minuten für den Round-Trip
|
||||
signed: true,
|
||||
});
|
||||
}
|
||||
|
||||
function readPendingCookie(req: FastifyRequest): PendingFlow | null {
|
||||
const raw = req.cookies[PENDING_COOKIE];
|
||||
if (!raw) return null;
|
||||
const unsigned = req.unsignCookie(raw);
|
||||
if (!unsigned.valid || !unsigned.value) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(unsigned.value) as PendingFlow;
|
||||
return parsed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function registerAuthRoutes(app: FastifyInstance): Promise<void> {
|
||||
const env = loadEnv();
|
||||
|
||||
// Startet den OIDC-Flow.
|
||||
app.get('/api/auth/login', async (req, reply) => {
|
||||
const returnTo =
|
||||
typeof req.query === 'object' && req.query !== null && 'returnTo' in req.query
|
||||
? String((req.query as Record<string, unknown>).returnTo ?? '/puls')
|
||||
: '/puls';
|
||||
|
||||
const { url, pending } = await buildAuthorizationUrl(returnTo);
|
||||
setPendingCookie(reply, pending);
|
||||
return reply.redirect(url.toString(), 302);
|
||||
});
|
||||
|
||||
// OIDC-Callback. Tauscht den Code gegen Tokens und legt eine Session an.
|
||||
app.get('/api/auth/callback', async (req, reply) => {
|
||||
const pending = readPendingCookie(req);
|
||||
if (!pending) {
|
||||
logger.warn('callback ohne pending-cookie');
|
||||
return reply.redirect(`${env.PUBLIC_BASE_URL}/login?error=session_expired`, 302);
|
||||
}
|
||||
|
||||
try {
|
||||
const callbackUrl = new URL(env.AUTHENTIK_REDIRECT_URI);
|
||||
for (const [k, v] of Object.entries(req.query as Record<string, string>)) {
|
||||
callbackUrl.searchParams.set(k, v);
|
||||
}
|
||||
|
||||
const oidcUser = await completeAuthorization(callbackUrl, pending);
|
||||
const userRow = await upsertUserFromOidc(oidcUser);
|
||||
|
||||
const newSession = await createSession({
|
||||
userId: userRow.id,
|
||||
ipAddress: req.ip,
|
||||
userAgent: req.headers['user-agent'],
|
||||
});
|
||||
|
||||
setSessionCookie(reply, newSession.id);
|
||||
reply.clearCookie(PENDING_COOKIE, { path: '/' });
|
||||
|
||||
const target = pending.returnTo.startsWith('/') ? pending.returnTo : '/puls';
|
||||
return reply.redirect(`${env.PUBLIC_BASE_URL}${target}`, 302);
|
||||
} catch (err) {
|
||||
logger.error({ err }, 'oidc callback failed');
|
||||
return reply.redirect(`${env.PUBLIC_BASE_URL}/login?error=oidc_failed`, 302);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/auth/logout', async (req, reply) => {
|
||||
const sessionId = req.cookies[env.SESSION_COOKIE_NAME];
|
||||
if (sessionId) {
|
||||
await deleteSession(sessionId);
|
||||
}
|
||||
clearSessionCookie(reply);
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
// Dev-Auto-Login: legt sofort eine Session für den Admin-Dejan an, sodass
|
||||
// die App ohne Authentik durchnavigierbar ist. Authentik kommt in der späteren
|
||||
// Roadmap. Nur aktiv bei NODE_ENV=development.
|
||||
if (env.NODE_ENV === 'development') {
|
||||
const handler = async (req: import('fastify').FastifyRequest, reply: import('fastify').FastifyReply) => {
|
||||
const { getDb } = await import('../db.js');
|
||||
const { user } = await import('@cockpit/db');
|
||||
const { eq } = await import('drizzle-orm');
|
||||
const { db } = getDb();
|
||||
|
||||
const found = (await db.select().from(user).where(eq(user.email, 'dejan@tool-ctrl.de')).limit(1))[0];
|
||||
const userRow = found
|
||||
? found
|
||||
: await upsertUserFromOidc({
|
||||
sub: 'local-dejan',
|
||||
email: 'dejan@tool-ctrl.de',
|
||||
name: 'Dejan Cosic',
|
||||
groups: ['entwicklung'],
|
||||
});
|
||||
|
||||
const newSession = await createSession({
|
||||
userId: userRow.id,
|
||||
ipAddress: req.ip,
|
||||
userAgent: req.headers['user-agent'],
|
||||
});
|
||||
setSessionCookie(reply, newSession.id);
|
||||
return reply.redirect(`${env.PUBLIC_BASE_URL}/puls`, 302);
|
||||
};
|
||||
app.get('/api/auth/dev-login', handler);
|
||||
app.post('/api/auth/dev-login', handler);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
// Session-Store in Postgres. Browser sieht nur `session_id` als HttpOnly-Cookie.
|
||||
|
||||
import { eq, lt } from 'drizzle-orm';
|
||||
import { session, user, type Session, type User } from '@cockpit/db';
|
||||
import { getDb } from '../db.js';
|
||||
import { loadEnv } from '../env.js';
|
||||
|
||||
export interface CreateSessionInput {
|
||||
userId: string;
|
||||
ipAddress?: string;
|
||||
userAgent?: string;
|
||||
}
|
||||
|
||||
export async function createSession(input: CreateSessionInput): Promise<Session> {
|
||||
const env = loadEnv();
|
||||
const { db } = getDb();
|
||||
const expiresAt = new Date(Date.now() + env.SESSION_TTL_HOURS * 60 * 60 * 1000);
|
||||
|
||||
const [created] = await db
|
||||
.insert(session)
|
||||
.values({
|
||||
userId: input.userId,
|
||||
expiresAt,
|
||||
ipAddress: input.ipAddress ?? null,
|
||||
userAgent: input.userAgent ?? null,
|
||||
})
|
||||
.returning();
|
||||
|
||||
if (!created) {
|
||||
throw new Error('Session konnte nicht angelegt werden.');
|
||||
}
|
||||
return created;
|
||||
}
|
||||
|
||||
export async function getSessionWithUser(
|
||||
sessionId: string,
|
||||
): Promise<{ session: Session; user: User } | null> {
|
||||
const { db } = getDb();
|
||||
const rows = await db
|
||||
.select({ session, user })
|
||||
.from(session)
|
||||
.innerJoin(user, eq(session.userId, user.id))
|
||||
.where(eq(session.id, sessionId))
|
||||
.limit(1);
|
||||
const row = rows[0];
|
||||
if (!row) return null;
|
||||
if (row.session.expiresAt.getTime() <= Date.now()) {
|
||||
await db.delete(session).where(eq(session.id, sessionId));
|
||||
return null;
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
export async function deleteSession(sessionId: string): Promise<void> {
|
||||
const { db } = getDb();
|
||||
await db.delete(session).where(eq(session.id, sessionId));
|
||||
}
|
||||
|
||||
export async function pruneExpiredSessions(): Promise<number> {
|
||||
const { db } = getDb();
|
||||
const result = await db.delete(session).where(lt(session.expiresAt, new Date()));
|
||||
return result.rowCount ?? 0;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { user, type User } from '@cockpit/db';
|
||||
import { getDb } from '../db.js';
|
||||
import type { OidcUserInfo } from './oidc.js';
|
||||
|
||||
// MVP: Group-Claim → default_area_id wird in Story 1.x mit echter Area-Tabelle
|
||||
// gemappt. Aktuell speichern wir den User mit `default_area_id = null` und
|
||||
// pflegen den Bereich später manuell. (OQ-1.)
|
||||
export async function upsertUserFromOidc(info: OidcUserInfo): Promise<User> {
|
||||
const { db } = getDb();
|
||||
|
||||
const existing = await db.select().from(user).where(eq(user.authentikSub, info.sub)).limit(1);
|
||||
if (existing[0]) {
|
||||
const [updated] = await db
|
||||
.update(user)
|
||||
.set({
|
||||
name: info.name,
|
||||
email: info.email,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(user.id, existing[0].id))
|
||||
.returning();
|
||||
if (!updated) throw new Error('User-Update fehlgeschlagen.');
|
||||
return updated;
|
||||
}
|
||||
|
||||
const [created] = await db
|
||||
.insert(user)
|
||||
.values({
|
||||
authentikSub: info.sub,
|
||||
name: info.name,
|
||||
email: info.email,
|
||||
})
|
||||
.returning();
|
||||
if (!created) throw new Error('User-Anlage fehlgeschlagen.');
|
||||
return created;
|
||||
}
|
||||
Reference in new issue
Block a user