3d370caa62
Implements Phase 3 from docs/NEXT-STEPS.md. Mongoose schemas (services/provisioning/src/schemas/): - Tenant: slug, name, status, plan, domains, billingInfo, plus handles for Authentik group, OCIS space, and Stalwart domain (set in Phase 4) - User: authentikSubjectId, tenantIds[], email, name, role, platformAdmin flag - Subscription: tenantId, plan, status, Stripe IDs (unused until Phase 4) Auth (services/provisioning/src/auth/): - JwtAuthGuard verifies Authentik access tokens against the provider's JWKS with issuer + audience checks. Uses NODE_EXTRA_CA_CERTS to trust the mkcert root for the local Authentik cert - ActorService resolves the verified JWT into a Mongo User document — every controller reads tenantIds + platformAdmin from the DB, not the token - CurrentUser decorator extracts the JWT payload onto controllers CRUD modules: - /tenants, /users, /subscriptions with create/read/update/delete - /users/me upserts the caller's User record on every request, syncing email, name, tenantIds, and platformAdmin from the JWT's groups claim — the only place we read JWT.groups outside the bootstrap Why DB-derived authz: putting all group memberships in the JWT doesn't scale past ~50 tenants per user (header/cookie size limits, no mid-session revocation, stale data until re-login). JWT now carries identity only; the DB is the source of truth for who can see what. Seed (SeedService.OnApplicationBootstrap): idempotent creation of the default 'dezky' tenant + matching subscription. User records are created on first /users/me hit. Infrastructure: - Traefik label exposes provisioning at https://api.dezky.local (dev only) - api.dezky.local added to Docker network aliases on Traefik - mkcert root CA mounted into the provisioning container for JWKS fetch - Authentik 'groups' scope mapping created + attached to dezky-portal provider; portal now requests it as a scope - nuxt.config.ts portal: exposeAccessToken=true so Nitro forwards token; NUXT_OIDC_TOKEN_KEY fixed to base64-encoded 32 bytes (was hex, causing "Invalid key length" once exposeAccessToken turned on) Portal: apps/portal/server/api/me.get.ts is a scaffolding route that forwards the user's access token to provisioning and returns profile + tenants + subscriptions — verifies the full chain end to end.
65 lines
2.1 KiB
TypeScript
65 lines
2.1 KiB
TypeScript
import {
|
|
CanActivate,
|
|
ExecutionContext,
|
|
Injectable,
|
|
Logger,
|
|
UnauthorizedException,
|
|
} from '@nestjs/common'
|
|
import { ConfigService } from '@nestjs/config'
|
|
import { createRemoteJWKSet, jwtVerify } from 'jose'
|
|
import type { AuthentikJwtPayload } from './jwt-payload.interface.js'
|
|
|
|
@Injectable()
|
|
export class JwtAuthGuard implements CanActivate {
|
|
private readonly logger = new Logger(JwtAuthGuard.name)
|
|
private jwks: ReturnType<typeof createRemoteJWKSet> | null = null
|
|
private readonly issuer: string
|
|
private readonly audience: string
|
|
private readonly jwksUri: string
|
|
|
|
constructor(config: ConfigService) {
|
|
this.issuer = config.getOrThrow<string>('AUTHENTIK_ISSUER')
|
|
this.audience = config.getOrThrow<string>('AUTHENTIK_AUDIENCE')
|
|
this.jwksUri = config.getOrThrow<string>('AUTHENTIK_JWKS_URI')
|
|
}
|
|
|
|
// Lazy init so misconfigured env doesn't crash the module bootstrap; the first
|
|
// request will surface a useful 401 instead of a startup failure.
|
|
private getJwks() {
|
|
if (!this.jwks) {
|
|
this.jwks = createRemoteJWKSet(new URL(this.jwksUri), {
|
|
cacheMaxAge: 10 * 60 * 1000,
|
|
cooldownDuration: 30 * 1000,
|
|
})
|
|
}
|
|
return this.jwks
|
|
}
|
|
|
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
|
const req = context.switchToHttp().getRequest<{
|
|
headers: Record<string, string | string[]>
|
|
user?: AuthentikJwtPayload
|
|
}>()
|
|
const authHeader = req.headers['authorization']
|
|
const headerValue = Array.isArray(authHeader) ? authHeader[0] : authHeader
|
|
|
|
if (!headerValue?.startsWith('Bearer ')) {
|
|
throw new UnauthorizedException('Missing or malformed Authorization header')
|
|
}
|
|
|
|
const token = headerValue.slice('Bearer '.length).trim()
|
|
|
|
try {
|
|
const { payload } = await jwtVerify(token, this.getJwks(), {
|
|
issuer: this.issuer,
|
|
audience: this.audience,
|
|
})
|
|
req.user = payload as unknown as AuthentikJwtPayload
|
|
return true
|
|
} catch (err) {
|
|
this.logger.warn(`JWT verification failed: ${(err as Error).message}`)
|
|
throw new UnauthorizedException('Invalid access token')
|
|
}
|
|
}
|
|
}
|