feat(backend): implement phases 0-6 (auth, kc, wahl, files, chat, sync)
Full NestJS backend for the KC-App platform: - auth: Authentik OIDC resource-server strategy + guest invite-code JWT login, plus TokenVerificationService for the WS handshake path - kc: Leitungsteam-only KC (event) creation/listing - wahl: Wahl/Workshop admin, Force-Zuteilung overrides, ZuteilungService (port of the WP plugin's kc_run_zuteilung), CSV export - files: LT-only upload with visibility tiers; list/download filtered by caller tier; StorageProvider abstraction (WebDAV/Nextcloud default, S3) - chat: Gemeinde group / DM / LT-wide / broadcast channels; REST + raw ws gateway sharing ChatService access rules - sync: append-only SyncLogEntry replication log + local<->cloud push/pull scheduler, shared-secret guarded - common: Role enum, @Roles decorator, KC-scoped RolesGuard (LT global) - serves client/web/ interim static web client under / (API under /api) Typecheck, nest build and boot test pass; needs real Postgres/Authentik/ Nextcloud to run end to end. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import * as jwt from 'jsonwebtoken';
|
||||
import * as jwksRsa from 'jwks-rsa';
|
||||
import { PrismaClient } from '../prisma/prisma.module';
|
||||
import { AuthenticatedUser } from './authenticated-request';
|
||||
import { GuestJwtPayload } from './guest-auth.service';
|
||||
|
||||
/// Verifies raw bearer tokens outside the HTTP/passport pipeline, needed for
|
||||
/// the WebSocket handshake where AuthGuard('authentik'|'guest') don't apply.
|
||||
@Injectable()
|
||||
export class TokenVerificationService {
|
||||
private readonly issuerUrl: string;
|
||||
private readonly jwks: jwksRsa.JwksClient;
|
||||
|
||||
constructor(
|
||||
private readonly config: ConfigService,
|
||||
private readonly prisma: PrismaClient,
|
||||
private readonly guestJwt: JwtService,
|
||||
) {
|
||||
this.issuerUrl = config.getOrThrow<string>('AUTHENTIK_ISSUER_URL');
|
||||
this.jwks = jwksRsa({ jwksUri: `${this.issuerUrl}/jwks/`, cache: true, rateLimit: true });
|
||||
}
|
||||
|
||||
async verifyAuthentik(token: string): Promise<AuthenticatedUser> {
|
||||
const decoded = jwt.decode(token, { complete: true });
|
||||
const kid = decoded?.header.kid;
|
||||
if (!kid) {
|
||||
throw new UnauthorizedException('Malformed Authentik token');
|
||||
}
|
||||
const key = await this.jwks.getSigningKey(kid);
|
||||
const payload = jwt.verify(token, key.getPublicKey(), {
|
||||
issuer: this.issuerUrl,
|
||||
algorithms: ['RS256'],
|
||||
}) as jwt.JwtPayload;
|
||||
if (!payload.sub) {
|
||||
throw new UnauthorizedException('Authentik token missing subject');
|
||||
}
|
||||
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { authentikSub: payload.sub },
|
||||
include: { memberships: true },
|
||||
});
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('User not provisioned locally yet');
|
||||
}
|
||||
return {
|
||||
userId: user.id,
|
||||
authentikSub: user.authentikSub,
|
||||
email: user.email,
|
||||
memberships: user.memberships.map((m) => ({
|
||||
kcId: m.kcId,
|
||||
gemeindeId: m.gemeindeId,
|
||||
role: m.role,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async verifyGuest(token: string): Promise<GuestJwtPayload> {
|
||||
return this.guestJwt.verifyAsync<GuestJwtPayload>(token);
|
||||
}
|
||||
|
||||
/// Tries Authentik first (team member), then falls back to a guest token.
|
||||
async verifyEither(token: string): Promise<
|
||||
{ kind: 'user'; user: AuthenticatedUser } | { kind: 'guest'; guest: GuestJwtPayload }
|
||||
> {
|
||||
try {
|
||||
return { kind: 'user', user: await this.verifyAuthentik(token) };
|
||||
} catch {
|
||||
return { kind: 'guest', guest: await this.verifyGuest(token) };
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user