NestJS Fastify JWKS 验证

NestJS Fastify JWKS Validation

我在我的 NestJS 应用程序中使用 Fastify 适配器,并想添加一些逻辑来进行 JWKS 验证,类似于 Auth0 website.

上的护照示例

// src/authz/jwt.strategy.ts

import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { passportJwtSecret } from 'jwks-rsa';
import * as dotenv from 'dotenv';

dotenv.config();

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
  constructor() {
    super({
      secretOrKeyProvider: passportJwtSecret({
        cache: true,
        rateLimit: true,
        jwksRequestsPerMinute: 5,
        jwksUri: `${process.env.AUTH0_ISSUER_URL}.well-known/jwks.json`,
      }),

      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      audience: process.env.AUTH0_AUDIENCE,
      issuer: `${process.env.AUTH0_ISSUER_URL}`,
      algorithms: ['RS256'],
    });
  }

  validate(payload: unknown): unknown {
    return payload;
  }
}

据我了解,Passport 仅适用于 Express,不适用于 Fastify。有谁知道如何用 Fastify 和 NestJS 做这样的事情吗?

我没能找到像 passport 这样的库来用 fastify 做 JWKS 验证。我决定使用 jsonwebtoken and the @types/jsonwebtoken 库编写自己的验证。

下面是我的解决方案示例,供有兴趣的其他人使用:)

文件结构如下:

src 
 |__ auth
       |__jwks
            |_ jwks.client.ts
            |_ jwks.service.ts
            |_ jwt-auth.guard.ts
            |_ jwt-auth.module.ts
 |__ caching
           |_ redis-cache.module.ts
 |__ models
       |__ json-web-key.model.ts
       |__ jwks-response.model.ts
 |__ my.controller.ts
 |__ app.module.ts

jwks 响应模型

// src/models/jwks-response.model.ts

import { JsonWebKey } from "src/models/json-web-key.model";

export class JwksResponse {
    keys: Array<JsonWebKey>
}

// src/models/json-web-key.model.ts

export class JsonWebKey {
        kty: string;
        kid: string;
        use: string;
        x5t: string;
        x5c: Array<string>;
        n?: string;
        e?: string;
        x?: string;
        y?: string;
        crv?: string;
    }

客户端调用 jwks 端点并处理响应

//src/auth/jwks/jwks.client.ts

import { HttpException, Injectable, Logger } from "@nestjs/common";
import { ConfigService} from "@nestjs/config";
import { HttpService } from "@nestjs/axios";
import { map, lastValueFrom } from "rxjs";
import { JwksResponse } from "src/models/jwks-response.model";
import { JsonWebKey } from "src/models/json-web-key.model";

@Injectable()
export class JwksClient {

    private readonly logger: Logger = new Logger(JwksClient.name);
    private readonly JWKS_URL: string = this.configService.get<string>('services.jwks.url');
    private readonly TIMEOUT: number = parseInt(this.configService.get<string>('services.timeout'));

    constructor(private configService: ConfigService, private httpService: HttpService){}

    async getJsonWebKeySet(): Promise<Array<JsonWebKey>> {
        this.logger.log(`Attempting to retrieve json web keys from Jwks endpoint`);

        const config = {
            timeout: this.TIMEOUT,
        };

        let response: JwksResponse = null;
        try {
            response = await lastValueFrom(this.httpService.get(this.JWKS_URL, config)
                .pipe(
                        map((response) => {
                            return response.data;
                        },
                    ),
                ),
            );
        } catch(e) {
            this.logger.error(`An error occurred invoking Jwks endpoint to retrieve public keys`);
            this.logger.error(e.stack);
            throw new HttpException(e.message, e.response.status);
        }

        if (!response || !response.keys || response.keys.length == 0) {
            this.logger.error('No json web keys were returned from Jwks endpoint')
            return [];
        }

        return response.keys;
    }
}

服务包含调用 jwks 端点并使用 public 密钥验证 jwt 令牌的逻辑。

JWT 令牌将由 header、负载和签名组成。

header 还应该有一个 kid 字段,该字段将匹配 json 网络密钥之一的孩子,以便您知道用哪个来验证您的令牌。

x5c 数组包含一个证书链,该数组的第一个元素将始终包含您用来从中获取 public 密钥以验证令牌的证书。

注意:我必须用 \n-----BEGIN CERTIFICATE-----\n${key.x5c[0]}\n-----END CERTIFICATE----- 包装证书才能创建 public 密钥,但您可能不必在实施中这样做。

您还需要添加逻辑来验证 JWT 的声明。

我还缓存了一个有效的 JWT 一段时间,以确保不需要每次都进行验证,因为这会影响性能,此缓存的密钥使用身份验证令牌来确保它是唯一的。

import { HttpException, HttpStatus, Injectable, CACHE_MANAGER, Logger, Inject } from "@nestjs/common";
import { ConfigService} from "@nestjs/config";
import { IncomingHttpHeaders } from "http";
import { JwksClient } from "src/auth/jwks/jwks.client";
import { JsonWebKey } from "src/models/json-web-key.model";
import { JwtPayload } from 'jsonwebtoken';
import * as jwt from 'jsonwebtoken';
import * as crypto from "crypto";
import { Cache } from 'cache-manager';

@Injectable()
export class JwksService {

    private readonly logger: Logger = new Logger(JwksService.name);
    private readonly CACHE_KEY: string = this.configService.get<string>('caches.jwks.key');
    private readonly CACHE_TTL: number = parseInt(this.configService.get<string>('caches.jwks.ttl'));

    constructor(private configService: ConfigService, private readonly jwksClient: JwksClient, @Inject(CACHE_MANAGER) private cacheManager: Cache){}

    async verify(request: any): Promise<boolean> {

        let token: string = this.getAuthorizationTokenFromHeader(request.headers);

        const jwksKey = `${this.CACHE_KEY}:${token}`

        const cachedVerificationResult: boolean = await this.getCachedVerificationResult(jwksKey);

        if (cachedVerificationResult) {
            this.logger.debug("Found cached verification result");
            return cachedVerificationResult;
        }

        if (!this.hasTokenWithValidClaims(token)) {
            this.logger.error("Token with invalid claims was provided")
            return false;
        }

        // Get all web keys from JWKS endpoint
        let jsonWebKeys: Array<JsonWebKey> = await this.jwksClient.getJsonWebKeySet();

        // Find the public key with matching kid
        let publicKey: string | Buffer = this.findPublicKey(token, jsonWebKeys);

        if (!publicKey) {
            this.logger.error("No public key was found for the bearer token provided")
            return false;
        }

        try {
            jwt.verify(token, publicKey, { algorithms: ['Put algorithm here e.g. HS256, ES256 etc'] });
        } catch(e) {
            this.logger.error("An error occurred verifying the bearer token with the associated public key");
            this.logger.error(e.stack);
            throw new HttpException(e.message, HttpStatus.FORBIDDEN);
        }


        // Cache Jwks validation result
        this.cacheManager.set(jwksKey, true, { ttl: this.CACHE_TTL });

        this.logger.debug("Successfully verified bearer token with the associated public key")

        return true;
    }

    private hasTokenWithValidClaims(token: string) {

        var { header, payload, signature } = jwt.decode(token, { complete: true });

        
        // TODO: Add validation for claims

        return true;
    }

    private findPublicKey(token: string, jsonWebKeys: Array<JsonWebKey>): string | Buffer {

        var { header } = jwt.decode(token, { complete: true });

        let key = null;
        for (var jsonWebKey of jsonWebKeys) {
            if (jsonWebKey.kid === header.kid) {
                this.logger.debug(`Found json web key for kid ${header.kid}`);
                key = jsonWebKey;
                break;
            }
        }

        if (!key) {
            return null;
        }

        // Exctact x509 certificate from the certificate chain
        const x509Certificate = `\n-----BEGIN CERTIFICATE-----\n${key.x5c[0]}\n-----END CERTIFICATE-----`;

        // Create the public key from the x509 certificate
        return crypto.createPublicKey(x509Certificate).export({type:'spki', format:'pem'})
    }

    private getAuthorizationTokenFromHeader(headers: IncomingHttpHeaders): string {

        if(!headers || !headers.authorization) {
            throw new HttpException("Authorization header is missing", HttpStatus.BAD_REQUEST);
        }

        let token: string = headers.authorization;

        if (token.startsWith("Bearer ")) {
            token = headers.authorization.split(" ")[1].trim();
        }

        return token;
    }

    private async getCachedVerificationResult(jwksKey: string): Promise<boolean> {
        const response: boolean = await this.cacheManager.get(jwksKey);

        if(response && response === true) {
            return response;
        }

        return null;
    }
}

guard 验证 JWT

// src/auth/jwks/jwt-auth.guard.ts

import { Injectable, CanActivate, ExecutionContext, Logger } from '@nestjs/common';
import { JwksService } from 'src/auth/jwks/jwks.service';

@Injectable()
export class JwtAuthGuard implements CanActivate {

    private readonly logger: Logger = new Logger(JwtAuthGuard.name);

    constructor(private jwksService: JwksService){}

    async canActivate(
        context: ExecutionContext,
    ): Promise<boolean> {
        const request = context.switchToHttp().getRequest();
        return await this.jwksService.verify(request);
    }
}

包含 jwks 配置的模块

// src/auth/jwks/jwt-auth.model.ts

import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { HttpModule } from '@nestjs/axios';
import configuration from '../../../config/configuration';
import { JwksClient } from 'src/auth/jwks/jwks.client';
import { JwksService } from 'src/auth/jwks/jwks.service';

@Module({
  imports: [
    ConfigModule.forRoot({ load: [configuration] }),
    HttpModule
  ],
  providers: [
    JwksClient,
    JwksService,
  ],
  exports: [JwksService, JwksClient],
})
export class JwtAuthModule {}

redis 包含 redis 缓存配置的缓存模块

// src/caching/redis-cache.module.ts
import {  CacheModule, Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import configuration from '../../config/configuration';
import { RedisClientOptions } from 'redis';
import * as redisStore from 'cache-manager-redis-store';

@Module({
  imports: [
    ConfigModule.forRoot({ load: [configuration] }),
    CacheModule.registerAsync<RedisClientOptions>({
        isGlobal: true,
        imports: [ConfigModule],
        useFactory: async (configService: ConfigService) => ({
            store: redisStore,
            host: process.env.REDIS_URL,
            port: configService.get<number>('redis.port'),
            password: configService.get<string>('redis.password'),
            tls: configService.get<boolean>('redis.tls')
        }),
        inject: [ConfigService],
    })
  ],
  controllers: [],
  providers: []
})
export class RedisCacheModule {}

使用 JwtAuthGuard 的控制器

// src/my.controller.ts
import { Controller, Get, Param, Logger } from '@nestjs/common';

@Controller()
@UseGuards(JwtAuthGuard)
export class MyController {
    private readonly logger: Logger = new Logger(MyController.name);

    @Get('/:id')
    async getCustomerDetails(@Headers() headers, @Param('id') id: string): Promise<Customer> {
        this.logger.log(`Accepted incoming request with id: ${id}`);

        // Do some processing ....

        return customer;
    }
}

包含整个应用配置的模块

// src/app.module.ts

import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { HttpModule } from '@nestjs/axios';
import configuration from '../config/configuration';
import { JwtAuthModule } from 'src/auth/jwks/jwt-auth.module';
import { RedisCacheModule } from 'src/caching/redis-cache.module';

@Module({
  imports: [
    ConfigModule.forRoot({ load: [configuration] }),
    HttpModule,
    JwtAuthModule,
    RedisCacheModule
  ],
  controllers: [MyController],
  providers: []
})
export class AppModule {}