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

使用MSAL客户端凭证模式调用Microsoft Graph读取Hotmail邮件时返回401错误的问题咨询

MSAL客户端凭证模式调用Microsoft Graph读取Hotmail邮件时返回401错误的问题咨询

大家好,我最近在做一个后台守护程序,需要读取我的Hotmail邮箱邮件,采用了MSAL4J的客户端凭证模式(Client Secret)来获取访问令牌。目前令牌能成功获取,调用https://graph.microsoft.com/v1.0/users接口也能正常返回结果,但尝试调用/users/{id}/messages接口时,却收到了401未授权的响应,而且响应体是空的,实在搞不懂哪里出了问题。

先给大家说下我的应用配置情况:

  • 应用账户类型:支持任何组织目录(多租户Microsoft Entra ID租户)和个人微软账户(如Skype、Xbox)
  • 已经勾选了「Access tokens(用于隐式流)」和「ID tokens(用于隐式和混合流)」
  • 配置Authority时用的是具体的Tenant Id,没有用/common这类通用值

下面是我的完整Java代码:

// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.
import com.microsoft.aad.msal4j.ClientCredentialFactory;
import com.microsoft.aad.msal4j.ClientCredentialParameters;
import com.microsoft.aad.msal4j.ConfidentialClientApplication;
import com.microsoft.aad.msal4j.IAuthenticationResult;
import com.nimbusds.oauth2.sdk.http.HTTPResponse;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.net.HttpURLConnection;
import java.net.URL;
import java.util.Collections;
import java.util.Properties;
import java.util.concurrent.CompletableFuture;

class ClientCredentialGrant {
    private static String authority;
    private static String clientId;
    private static String secret;
    private static String scope;
    private static ConfidentialClientApplication app;

    public static void main(String args[]) throws Exception{
        setUpSampleData();
        try {
            BuildConfidentialClientObject();
            IAuthenticationResult result = getAccessTokenByClientCredentialGrant();
            System.out.println(result.accessToken());

            String usersListFromGraph = getUsersListFromGraph(result.accessToken());
            System.out.println("Users in the Tenant = " + usersListFromGraph);

            String mailsListFromGraph = getEmailsFromGraph(result.accessToken());
            System.out.println("Mails in the Tenant = " + mailsListFromGraph);
        } catch(Exception ex){
            System.out.println("Oops! We have an exception of type - " + ex.getClass());
            System.out.println("Exception message - " + ex.getMessage());
            throw ex;
        }
    }

    private static void BuildConfidentialClientObject() throws Exception {
        // Load properties file and set properties used throughout the sample
        app = ConfidentialClientApplication.builder(
                clientId, ClientCredentialFactory.createFromSecret(secret))
                .authority(authority)
                .build();
    }

    private static IAuthenticationResult getAccessTokenByClientCredentialGrant() throws Exception {
        // With client credentials flows the scope is ALWAYS of the shape "resource/.default", as the
        // application permissions need to be set statically (in the portal), and then granted by a tenant administrator
        ClientCredentialParameters clientCredentialParam = ClientCredentialParameters.builder(
                Collections.singleton(scope))
                .build();

        CompletableFuture<IAuthenticationResult> future = app.acquireToken(clientCredentialParam);
        return future.get();
    }

    private static String getUsersListFromGraph(String accessToken) throws IOException {
        URL url = new URL("https://graph.microsoft.com/v1.0/users");
        HttpURLConnection conn = (HttpURLConnection) url.openConnection();
        conn.setRequestMethod("GET");
        conn.setRequestProperty("Authorization", "Bearer " + accessToken);
        conn.setRequestProperty("Accept","application/json");

        int httpResponseCode = conn.getResponseCode();
        if(httpResponseCode == HTTPResponse.SC_OK) {
            StringBuilder response;
            try(BufferedReader in = new BufferedReader(
                    new InputStreamReader(conn.getInputStream()))){
                String inputLine;
                response = new StringBuilder();
                while (( inputLine = in.readLine()) != null) {
                    response.append(inputLine);
                }
            }
            return response.toString();
        } else {
            return String.format("Connection returned HTTP code: %s with message: %s", httpResponseCode, conn.getResponseMessage());
        }
    }

    private static String getEmailsFromGraph(String accessToken) throws IOException {
        URL url = new URL("https://graph.microsoft.com/v1.0/users/9dfb85a5-b8bb-4e7d-8e05-a60d418ca16d/messages?$select=sender,subject");
        HttpURLConnection conn = (HttpURLConnection) url.openConnection();
        conn.setRequestMethod("GET");
        conn.setRequestProperty("Authorization", "Bearer " + accessToken);
        conn.setRequestProperty("Accept","application/json");

        int httpResponseCode = conn.getResponseCode();
        if(httpResponseCode == HTTPResponse.SC_OK) {
            StringBuilder response;
            try(BufferedReader in = new BufferedReader(
                    new InputStreamReader(conn.getInputStream()))){
                String inputLine;
                response = new StringBuilder();
                while (( inputLine = in.readLine()) != null) {
                    response.append(inputLine);
                }
            }
            return response.toString();
        } else {
            return String.format("Connection returned HTTP code: %s with message: %s", httpResponseCode, conn.getResponseMessage());
        }
    }

    /**
     * Helper function unique to this sample setting. In a real application these wouldn't be so
     */
    private static void setUpSampleData() throws IOException {
        // 这里应该是加载配置的代码,比如从properties文件读取authority、clientId、secret、scope
        // 示例中省略具体实现
        authority = "https://login.microsoftonline.com/你的租户ID";
        clientId = "你的客户端ID";
        secret = "你的客户端密钥";
        scope = "https://graph.microsoft.com/.default";
    }
}

问题根源分析

其实这个问题的核心在于客户端凭证模式的适用场景限制,以及个人微软账户(MSA,也就是你的Hotmail账户)的访问规则:

  1. 客户端凭证模式是「应用权限」,不支持个人邮箱
    客户端凭证模式下,令牌代表的是应用本身的身份,对应的是应用权限,这类权限只能用于访问组织租户内的资源(比如企业Exchange邮箱)。而个人Hotmail邮箱属于用户的私有数据,必须由用户本人授权,也就是需要使用委派权限,而非应用权限。

  2. 个人MSA无法授予应用权限
    即使你在Azure AD里给应用添加了Mail.Read这类应用权限,个人MSA账户也没有租户管理员来同意这些权限——个人账户本身不属于任何租户,所以应用权限对它完全无效。

  3. Tenant Id的使用错误
    当应用支持个人MSA时,Authority应该使用https://login.microsoftonline.com/common,而不是具体的租户ID。因为个人MSA不属于任何特定租户,用具体租户ID会导致认证流程无法正确识别个人账户的身份,进而令牌没有访问个人邮箱的权限。


具体解决方案

针对你的守护程序场景,推荐使用设备授权流(Device Code Flow),这是专门为没有UI的后台程序设计的认证流程,具体步骤如下:

1. 修改应用配置

  • 在Azure AD应用的「API权限」中,添加委派权限的Mail.Read,并点击「授予管理员同意」(如果是多租户应用,需要租户管理员同意;如果是仅个人使用,后续用户授权时会自动同意)。
  • 确保应用的「身份验证」设置里,支持设备授权流(在「高级设置」的「默认客户端类型」里勾选「是」,或者在「平台配置」里添加「移动和桌面应用」)。

2. 修改代码,改用设备授权流

替换原有的客户端凭证模式代码,改成设备授权流的实现:

// 构建ConfidentialClientApplication
ConfidentialClientApplication app = ConfidentialClientApplication.builder(
        clientId, ClientCredentialFactory.createFromSecret(secret))
        .authority("https://login.microsoftonline.com/common") // 改用common
        .build();

// 配置设备授权参数,指定Mail.Read权限
DeviceCodeParameters parameters = DeviceCodeParameters.builder(
        Collections.singleton("https://graph.microsoft.com/Mail.Read"),
        (deviceCode, args) -> {
            // 打印授权提示,让用户在浏览器输入代码
            System.out.println("请打开以下链接:" + deviceCode.verificationUri());
            System.out.println("输入验证码:" + deviceCode.userCode());
            System.out.println("等待用户授权...");
        })
        .build();

// 获取令牌
CompletableFuture<IAuthenticationResult> future = app.acquireToken(parameters);
IAuthenticationResult result = future.get();

// 调用Graph API读取当前用户的邮件,用/me/messages更简洁
String mailsListFromGraph = getEmailsFromGraph(result.accessToken());
System.out.println("Mails = " + mailsListFromGraph);

// 对应的getEmailsFromGraph方法可以修改为访问/me/messages
private static String getEmailsFromGraph(String accessToken) throws IOException {
    URL url = new URL("https://graph.microsoft.com/v1.0/me/messages?$select=sender,subject");
    HttpURLConnection conn = (HttpURLConnection) url.openConnection();
    conn.setRequestMethod("GET");
    conn.setRequestProperty("Authorization", "Bearer " + accessToken);
    conn.setRequestProperty("Accept","application/json");

    int httpResponseCode = conn.getResponseCode();
    if(httpResponseCode == HTTPResponse.SC_OK) {
        StringBuilder response;
        try(BufferedReader in = new BufferedReader(
                new InputStreamReader(conn.getInputStream()))){
            String inputLine;
            response = new StringBuilder();
            while (( inputLine = in.readLine()) != null) {
                response.append(inputLine);
            }
        }
        return response.toString();
    } else {
        return String.format("Connection returned HTTP code: %s with message: %s", httpResponseCode, conn.getResponseMessage());
    }
}

3. 运行程序并完成授权

运行程序后,控制台会输出一个链接和验证码,你需要用浏览器打开该链接,输入验证码并登录你的Hotmail账户,完成授权后,程序就能获取到有权限访问你邮箱的令牌,成功读取邮件了。


内容来源于stack exchange

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.04.09 03:04:35