Node.js mit TSOA und Authentifizierung - (Teil 6) Tokenvalidierung

In diesem Artikel widme ich mich der eigentlichen Überprüfung des Tokens. Ich verknüpfe Authentifizierung und Autorisierung, sodass der Endpunkt wie gewünscht gesichert ist. Hierfür stelle ich das Herzstück der Middleware bereit und füge die Puzzleteile zusammen. Ich liebe es, wenn ein Plan funktioniert.

6 Minuten

In diesem Artikel sind wir beim Kern der Artikelserie angekommen. Nachdem wir das Token aus dem Header extrahiert haben, prüfen wir es auf Herz und Nieren. Sollte es syntaktisch korrekt sein, stellen wir sicher, dass die erwarteten Scopes gesetzt sind. Im positiven Fall stehen uns im Controller die Daten aus dem Token zur Verfügung: UserId, E-Mail-Adresse, Scopes und Name.

Bevor wir anfangen, empfiehlt es sich, die Vorgänger der Artikelserie zu lesen:

Ergänzung einer weiteren Fehlerklasse: UnauthorizedError

Bei der weiteren Überprüfung des JWTs werden wir an den Punkt kommen, dass das Token korrekt ist und dass wir wissen, mit wem wir es zu tun haben. Bei der weiteren Prüfung vergleichen wir die Scope-Angaben. Sind diese nicht passend, liegt eine fehlende Zugriffsberechtigung vor. Ein solcher Fehler ist nicht mit dem Statuscode 401 (Unauthenticated), sondern mit dem Statuscode 403 (Unauthorized) zu quittieren.

Die dazugehörige Fehlerklasse sieht wie folgt aus:

import { ProblemDocument } from 'http-problem-details'  
import { ErrorMapper } from 'http-problem-details-mapper'  

import BaseError, { BaseErrorOptions } from '@/errors/BaseError'  

class UnauthorizedError extends BaseError {  
  constructor(options: BaseErrorOptions) {  
    const { detail = 'Unauthorized', cause } = options  
    super({ detail, cause, statusCode: 403 })  
  }  

  public toJSON() {  
    return super.toJSON()  
  }  

  public toString() {  
    return JSON.stringify(this.toJSON())  
  }  
}  

export default UnauthorizedError

Der dazugehörige Mapper ist zu definieren und in die Mapping-Strategie einzubinden:

export class UnauthorizedMapper extends ErrorMapper {  
  constructor() {  
    super(UnauthorizedError)  
  }  

  mapError(error: UnauthorizedError) {  
    return new ProblemDocument({  
      status: error.statusCode,  
      title: 'Permission denied',  
      type: 'unauthorized',  
      detail: error.detail,  
    })  
  }  
}  

const mapperRegistry = new MapperRegistry()  
  .registerMapper(new UnauthenticatedMapper())  
  .registerMapper(new UnauthorizedMapper())

Hier wird deutlich, wie wir einfach neue Fehlerklassen definieren, beim Mapping registrieren und damit sicherstellen, dass ein passendes ProblemDocument aus unserer Express-Applikation purzelt.

Weitere Typdefinitionen

Das gewünschte Ergebnis der Authentifizierung ist neben der Absicherung des Zugriffs die Verfügbarkeit der JWT-Daten im Controller. Hier gilt es, einen passenden Request-Typ zu definieren:

import { Request } from 'express'

// the metadata put into the JWT on creation → see UserController.getJwt()
export type JwtData = {  
  userId: string  
  email: string  
  scopes: Scope[]  
  name: string  
}

export type AuthenticatedRequest = ExpressRequest & {  
  // tsoa middleware will attach security results to the request object with the key 'user'
  // To see the code look for "request['user'] = await Promise.any(secMethodOrPromises);" in routes.ts
  user: JwtData  
}

Integration in den Controller

Die oben erstellten Typen nutzen wir in unserem Controller. Mithilfe der Authentifizierungsmiddleware und deren Einbindung in TSOA ist sichergestellt, dass die Daten gemäß unserer Definition vorliegen.

Zusammengefasst heißt dies: Sobald wir @Security deklarieren, ist für @Request gewährleistet, dass es vom Typ AuthenticatedRequest ist!

import { Controller } from '@tsoa/runtime'  
import { Get, Request, Route, Security } from 'tsoa'  
import { Scope } from '@/types'  
import type { AuthenticatedRequest } from '@/types'  

@Route('entities')  
export class EntityController extends Controller {  
  @Get()  
  @Security('jwt', [Scope.ADMIN])  
  public async getEntities (@Request() req: AuthenticatedRequest): Promise<string[]> {  
    console.log('Authenticated user:', req.user)  

    return req.user.scopes  
  }  
}

Überprüfung des JWTs

Die Vorbereitungen sind abgeschlossen. Jetzt widmen wir uns der Überprüfung. Dazu behalten wir im Hinterkopf, dass wir mit Promise.resolve und Promise.reject arbeiten müssen. Ansonsten brechen wir aus der Express-Middleware aus.

Entsprechend knüpfen wir nach der Headerprüfung mit der Tokenprüfung an. Der erste Schritt der Prüfung besteht aus zwei Komponenten, die in der JSON-Web-Token-Verify-Funktion enthalten sind. Das Verify prüft, ob ein JWT echt und noch gültig ist, und gibt bei Erfolg den Payload im Callback zurück; bei Problemen ruft es den Callback mit einem Error auf. Weil wir den öffentlichen Schlüssel mitgeben, validiert er die Signatur und garantiert uns damit die Echtheit der Daten → wir sind der Aussteller des Tokens und niemand hat es manipuliert. Durch diesen Schritt ist die Authentizität abgehakt.

Der zweite Schritt ist unsere Implementierung in der Verify-Funktion: der Abgleich der Scopes aus dem Token und der Definition am Endpunkt. Stimmen die beiden überein – d.h. der Aufrufende hat eine der erforderlichen Scopes im Token definiert – dann gilt der Security-Check als bestanden: Wir liefern die Jwt-Payload als Antwort, sodass sie im AuthenticatedRequest unter dem Key user verfügbar ist.

import { Request } from 'express'  
import UnauthenticatedError from '@/errors/UnauthenticatedError'  
import jsonwebtoken, { JwtPayload, VerifyErrors } from 'jsonwebtoken'  
import fs from 'node:fs'  
import path from 'node:path'  
import UnauthorizedError from '@/errors/UnauthorizedError'  
import { JwtData, Scope } from '@/types'  

// we define this type here to avoid the TypeScript error about overloads  
const jwtVerify = (jsonwebtoken.verify as (token: string, secretOrPublicKey: string, callback: (err: VerifyErrors | null, decodedToken: JwtPayload) => void) => void)  

const publicKey = fs.readFileSync(path.join(path.resolve('certs'), 'jwt.pub'), 'utf8')  

// snip validateHeader + type defintion

const expressAuthentication = (  
  request: Request,  
  securityName: string,  
  requiredScopes?: string[],  
): Promise<JwtData> => {  
  const validateHeaderResult = validateHeader(request)  

  if (!validateHeaderResult.isValid) {  
    console.log(`[AuthMiddleware::${securityName}] Failed due to: ${validateHeaderResult.error.message}`)  
    return Promise.reject(validateHeaderResult.error)  
  }  

  return new Promise((resolve, reject) => {  
    const jwtVerifyCallback = (  
      err: VerifyErrors | null,  
      decodedToken: JwtPayload,  
    ): void => {  
      // This handles all syntactical verification errors (invalid signature, expired, etc.)  
      if (err) {  
        console.log('[AuthMiddleware] Token verification failed', err)  
        return reject(new UnauthenticatedError({detail: 'Token verification failed'}))  
      }  

      const userId: unknown = decodedToken.userId  
      const email: unknown = decodedToken.email  
      const name: unknown = decodedToken.name  
      const scopes: unknown = decodedToken.scopes  

      // Check for required JWT data: userId, email, scopes, name and their types  
      if (!userId || typeof userId !== 'string' ||  
        !Array.isArray(scopes) || !scopes.every(scope => typeof scope === 'string') ||  
        !email || typeof email !== 'string' ||  
        !name || typeof name !== 'string'  
      ) {  
        console.log('[AuthMiddleware] Token missing required fields', decodedToken)  
        return reject(new UnauthenticatedError({detail: 'Token missing required fields'}))  
      }  

      // Check if no scope required  
      if (!requiredScopes || requiredScopes.length === 0) {  
        return resolve({userId, scopes: (scopes as Scope[]), email, name})  
      }  

      // Check for required scopes  
      const hasScope = requiredScopes.some(scope => scopes.includes(scope))  

      if (!hasScope) {  
        console.log('[AuthMiddleware] Token missing required scopes', {requiredScopes, hasScope, scopes})  
        return reject(new UnauthorizedError({detail: 'Token missing required scopes'}))  
      }  

      return resolve({userId, scopes: (scopes as Scope[]), email, name})  
    }  

    jwtVerify(validateHeaderResult.bearerToken, publicKey, jwtVerifyCallback)  
  })  
}  

export { expressAuthentication }

Fazit

Das Herzstück ist implementiert und unser Ziel ist erreicht. Wir können unsere Schnittstelle mit einer Zeile absichern. Die Überprüfung erfolgt an zentraler Stelle. Genauso wie bei der Fehlerbehandlung, sodass unerlaubte Abfragen mit einem passenden ProblemDocument quittiert werden.

Der Aufruf via Curl:

curl -H "Accept: application/json" -H "Authorization: Bearer ${DEMO_TOKEN}" http://localhost:4000/entities

Und wir erhalten die Ausgabe unserer Zeile console.log('Authenticated user:', req.user), die wir in den Controller geschrieben haben:

Authenticated user: {
  userId: 'user-uuid',
  scopes: [ 'ADMIN' ],
  email: 'user.mail@example.com',
  name: 'Test User'
}

Wir könnten die Artikelserie jetzt beenden. Meiner Meinung nach fehlt jedoch ein wichtiger Baustein: die Tests des Endpunkts. Idealerweise hätten wir den Test gleich zuerst geschrieben. Genauso wie den Test der Middleware. Das holen wir in den kommenden Artikel nach.

call to action background image

Abonniere meinen Newsletter

Erhalte einmal im Monat Nachrichten aus den Bereichen Softwareentwicklung und Kommunikation gespikt mit Buch- und Linkempfehlungen.