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 {}
我在我的 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 {}