NestJS Azure AD认证守卫失效,调用接口返回401 Unauthorized
NestJS Azure AD认证守卫401问题排查
问题描述
使用NestJS开发Azure AD认证守卫保护/user接口时,登录接口可成功返回access token,但将该token作为Authorization Header调用受保护接口时,始终收到401 Unauthorized(提示用户未登录,需重定向至登录页)。
相关代码
authguard.ts
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException, } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import * as jwt from 'jsonwebtoken'; import axios from 'axios'; @Injectable() export class AuthGuard implements CanActivate { private publicKeys: Record<string, string> = {}; constructor(private readonly configService: ConfigService) {} async canActivate(context: ExecutionContext): Promise<boolean> { const request = context.switchToHttp().getRequest(); const authHeader = request.headers['authorization']; if (!authHeader || !authHeader.startsWith('Bearer ')) { throw new UnauthorizedException( 'Authorization header missing or invalid', ); } const token = authHeader.split(' ')[1]; // Validate the token try { const decodedToken = await this.validateToken(token); // Attach user details to the request request.user = decodedToken; return true; } catch (error) { throw new UnauthorizedException('Invalid or expired token'); } } private async validateToken(token: string): Promise<any> { const tenantId = this.configService.get<string>('AZURE_TENANT_ID'); const clientId = this.configService.get<string>('AZURE_CLIENT_ID'); const issuer = `https://login.microsoftonline.com/${tenantId}/v2.0`; if (!tenantId || !clientId) { throw new Error('AZURE_TENANT_ID or AZURE_CLIENT_ID is not defined'); } // Decode the token without verifying to extract header const decoded = jwt.decode(token, { complete: true }) as { header: { kid: string }; }; if (!decoded || !decoded.header || !decoded.header.kid) { throw new UnauthorizedException('Invalid token structure'); } const kid = decoded.header.kid; // Fetch public keys if not already cached if (!this.publicKeys[kid]) { const jwks = await this.fetchJwks(issuer); this.publicKeys = jwks; } const publicKey = this.publicKeys[kid]; if (!publicKey) { throw new UnauthorizedException('Public key not found'); } // Verify the token return jwt.verify(token, publicKey, { issuer, audience: clientId, }); } private async fetchJwks(issuer: string): Promise<Record<string, string>> { const response = await axios.get(`${issuer}/discovery/v2.0/keys`); const jwks = response.data.keys; const publicKeys: Record<string, string> = {}; for (const key of jwks) { const keyPem = this.convertJwkToPem(key); publicKeys[key.kid] = keyPem; } return publicKeys; } private convertJwkToPem(jwk: any): string { const modulus = Buffer.from(jwk.n, 'base64').toString('base64'); const exponent = Buffer.from(jwk.e, 'base64').toString('base64'); return `-----BEGIN RSA PUBLIC KEY----- ${modulus} ${exponent} -----END RSA PUBLIC KEY-----`; } }
user.controller.ts
import { Controller, Get, InternalServerErrorException, Req, Res, UseGuards, } from '@nestjs/common'; import { UserService } from './user.service'; import { AuthGuard } from 'src/auth/auth.guard'; import { Request, Response } from 'express'; import { HttpStatus } from '@nestjs/common'; @Controller('user') export class UserController { constructor(private userService: UserService) {} @Get() @UseGuards(AuthGuard) async getUserProfile( @Req() req: Request, @Res() res: Response, ): Promise<any> { try { const response = await this.userService.getUserProfile( req.session.token!, ); res.status(HttpStatus.OK).send(response.data); } catch (error) { const errMessage = 'Error getting user profile: ' + error.message; throw new InternalServerErrorException(errMessage); } } }
登录返回的Token Response
Token Response: { authority: 'https://login.microsoftonline.com/<some string>/', uniqueId: '<some string>', tenantId: '<some string>', scopes: [ 'openid', 'profile', 'User.Read', 'email' ], account: { homeAccountId: '<some string>', environment: 'login.windows.net', tenantId: '<some string>', username: '<some string>', localAccountId: '<some string>', name: '<some string>', nativeAccountId: undefined, authorityType: 'MSSTS', tenantProfiles: Map(1) { '<some string>' => [Object] }, idTokenClaims: { aud: '<some string>', iss: 'https://login.microsoftonline.com/<some string>/v2.0', iat: 1731659325, nbf: 1731659325, exp: 1731663225, auth_time: 1731656361, email: '<some string>', family_name: '<some string>', given_name: '<some string>', groups: [Array], name: '<some string>', oid: '<some string>', preferred_username: '<some string>', rh: '<some string>', roles: [Array], sid: '<some string>', sub: '<some string>', tid: '<some string>', uti: '<some string>', ver: '2.0', wids: [Array] }, idToken: '<some string>' }, idToken: '<some string>', idTokenClaims: { aud: '<some string>', iss: 'https://login.microsoftonline.com/<some string>/v2.0', iat: 1731659325, nbf: 1731659325, exp: 1731663225, auth_time: 1731656361, email: '<some string>', family_name: '<some string>', given_name: '<some string>', groups: [ '<some string>',], name: '<some string>', oid: '<some string>', preferred_username: '<some string>', rh: '<some string>', roles: [ 'data.read' ], sid: '<some string>', sub: '<some string>', tid: '<some string>', uti: '<some string>', ver: '2.0', wids: [ '<some string>' ] }, accessToken: '<some string>', fromCache: false, expiresOn: 2024-11-15T09:56:24.000Z, extExpiresOn: 2024-11-15T11:19:03.000Z, refreshOn: undefined, correlationId: '<some string>', requestId: '<some string>', familyId: '', tokenType: 'Bearer', state: '', cloudGraphHostName: '', msGraphHost: '', code: undefined, fromNativeBroker: false }
排查关键点
1. JWK转PEM方法错误
当前convertJwkToPem方法仅简单拼接modulus和exponent的base64值,不符合RSA公钥的PEM格式规范,会导致公钥无效,进而使jwt.verify验证失败。正确做法是使用专业库转换:
npm install jwk-to-pem
修改转换方法:
import jwkToPem from 'jwk-to-pem'; // ... private convertJwkToPem(jwk: any): string { return jwkToPem(jwk); }
2. 公钥缓存逻辑问题
当前代码获取新JWKS后直接覆盖publicKeys,会丢失之前缓存的其他kid对应的公钥,应改为合并缓存:
if (!this.publicKeys[kid]) { const jwks = await this.fetchJwks(issuer); this.publicKeys = { ...this.publicKeys, ...jwks }; // 合并而非覆盖 }
3. Audience验证匹配问题
确保配置的AZURE_CLIENT_ID与token中的aud字段完全一致,包括大小写、是否为v2.0应用ID,不匹配会导致验证失败。
4. 错误日志缺失
在validateToken的catch块中添加详细日志,定位具体失败原因:
try { // ...验证逻辑 } catch (error) { console.error('Token validation failed:', error.message); // 添加日志 throw new UnauthorizedException('Invalid or expired token'); }
通过日志可明确是签名验证失败、issuer不匹配、token过期还是其他问题。
5. Controller中的Session依赖问题
UserController中使用req.session.token,但AuthGuard基于Authorization Header验证,两者认证方式不统一。若采用Bearer token模式,应改为使用req.user中的信息或直接传入access token:
const response = await this.userService.getUserProfile(token);
内容的提问来源于stack exchange,提问作者galih
相关产品推荐
相关产品推荐

