You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.16 06:54:56