init equipment & change-status
This commit is contained in:
23
server/src/auth/auth.module.ts
Normal file
23
server/src/auth/auth.module.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { APP_GUARD } from '@nestjs/core';
|
||||
import { AuthService } from './auth.service';
|
||||
import { JwtAuthGuard } from './guards/jwt-auth.guard';
|
||||
import { RolesGuard } from './guards/roles.guard';
|
||||
|
||||
@Module({
|
||||
providers: [
|
||||
AuthService,
|
||||
JwtAuthGuard,
|
||||
RolesGuard,
|
||||
{
|
||||
provide: APP_GUARD,
|
||||
useClass: JwtAuthGuard,
|
||||
},
|
||||
{
|
||||
provide: APP_GUARD,
|
||||
useClass: RolesGuard,
|
||||
},
|
||||
],
|
||||
exports: [AuthService, JwtAuthGuard, RolesGuard],
|
||||
})
|
||||
export class AuthModule {}
|
||||
102
server/src/auth/auth.service.ts
Normal file
102
server/src/auth/auth.service.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { createRemoteJWKSet, jwtVerify, JWTPayload } from 'jose';
|
||||
|
||||
export type AuthenticatedUser = {
|
||||
sub: string;
|
||||
roles: string[];
|
||||
token: JWTPayload;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
private jwksCache = new Map<string, ReturnType<typeof createRemoteJWKSet>>();
|
||||
|
||||
async verifyBearerToken(authorizationHeader?: string): Promise<AuthenticatedUser> {
|
||||
const token = this.extractBearerToken(authorizationHeader);
|
||||
if (!token) {
|
||||
throw new UnauthorizedException('Missing bearer token');
|
||||
}
|
||||
|
||||
const issuer = process.env.KEYCLOAK_ISSUER_URL;
|
||||
const audience = process.env.KEYCLOAK_AUDIENCE;
|
||||
|
||||
if (!issuer || !audience) {
|
||||
throw new UnauthorizedException('Keycloak issuer or audience is not configured');
|
||||
}
|
||||
|
||||
const jwks = await this.resolveJwks(issuer);
|
||||
const { payload } = await jwtVerify(token, jwks, {
|
||||
issuer,
|
||||
audience,
|
||||
});
|
||||
|
||||
const realmAccess = payload.realm_access as { roles?: string[] } | undefined;
|
||||
|
||||
return {
|
||||
sub: String(payload.sub ?? ''),
|
||||
roles: Array.isArray(realmAccess?.roles) ? realmAccess.roles : [],
|
||||
token: payload,
|
||||
};
|
||||
}
|
||||
|
||||
private extractBearerToken(authorizationHeader?: string) {
|
||||
if (!authorizationHeader) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [scheme, token] = authorizationHeader.split(' ');
|
||||
if (scheme !== 'Bearer' || !token) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
private async resolveJwks(issuer: string) {
|
||||
const candidates = await this.buildJwksCandidates(issuer);
|
||||
|
||||
let lastError: unknown;
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
if (!this.jwksCache.has(candidate)) {
|
||||
this.jwksCache.set(candidate, createRemoteJWKSet(new URL(candidate)));
|
||||
}
|
||||
|
||||
return this.jwksCache.get(candidate)!;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
}
|
||||
|
||||
throw new UnauthorizedException(
|
||||
`Unable to resolve JWKS for issuer ${issuer}: ${lastError instanceof Error ? lastError.message : 'unknown error'}`,
|
||||
);
|
||||
}
|
||||
|
||||
private async buildJwksCandidates(issuer: string) {
|
||||
const urls: string[] = [];
|
||||
|
||||
if (process.env.KEYCLOAK_JWKS_URL) {
|
||||
urls.push(process.env.KEYCLOAK_JWKS_URL);
|
||||
}
|
||||
|
||||
const normalizedIssuer = issuer.replace(/\/$/, '');
|
||||
const discoveryUrl = `${normalizedIssuer}/.well-known/openid-configuration`;
|
||||
|
||||
try {
|
||||
const response = await fetch(discoveryUrl);
|
||||
if (response.ok) {
|
||||
const document = (await response.json()) as { jwks_uri?: string };
|
||||
if (document.jwks_uri) {
|
||||
urls.push(document.jwks_uri);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Try the Keycloak certs endpoint below if discovery fails.
|
||||
}
|
||||
|
||||
urls.push(`${normalizedIssuer}/protocol/openid-connect/certs`);
|
||||
|
||||
return [...new Set(urls)];
|
||||
}
|
||||
}
|
||||
4
server/src/auth/decorators/public.decorator.ts
Normal file
4
server/src/auth/decorators/public.decorator.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
|
||||
export const IS_PUBLIC_KEY = 'isPublic';
|
||||
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
|
||||
4
server/src/auth/decorators/roles.decorator.ts
Normal file
4
server/src/auth/decorators/roles.decorator.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
|
||||
export const ROLES_KEY = 'roles';
|
||||
export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles);
|
||||
39
server/src/auth/guards/jwt-auth.guard.ts
Normal file
39
server/src/auth/guards/jwt-auth.guard.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { Request } from 'express';
|
||||
import { AuthService } from '../auth.service';
|
||||
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
|
||||
|
||||
@Injectable()
|
||||
export class JwtAuthGuard implements CanActivate {
|
||||
constructor(
|
||||
private readonly reflector: Reflector,
|
||||
private readonly authService: AuthService,
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext) {
|
||||
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
|
||||
if (isPublic) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const request = context.switchToHttp().getRequest<Request & { user?: unknown }>();
|
||||
const user = await this.authService.verifyBearerToken(request.headers.authorization);
|
||||
|
||||
if (!user.sub) {
|
||||
throw new UnauthorizedException('Token subject is missing');
|
||||
}
|
||||
|
||||
request.user = user;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
29
server/src/auth/guards/roles.guard.ts
Normal file
29
server/src/auth/guards/roles.guard.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { Request } from 'express';
|
||||
import { ROLES_KEY } from '../decorators/roles.decorator';
|
||||
|
||||
@Injectable()
|
||||
export class RolesGuard implements CanActivate {
|
||||
constructor(private readonly reflector: Reflector) {}
|
||||
|
||||
canActivate(context: ExecutionContext) {
|
||||
const requiredRoles = this.reflector.getAllAndOverride<string[]>(ROLES_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
|
||||
if (!requiredRoles || requiredRoles.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const request = context.switchToHttp().getRequest<Request & { user?: { roles?: string[] } }>();
|
||||
const userRoles = request.user?.roles ?? [];
|
||||
|
||||
if (requiredRoles.some((role) => userRoles.includes(role))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
throw new ForbiddenException('Insufficient role');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user