使用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账户)的访问规则:
客户端凭证模式是「应用权限」,不支持个人邮箱
客户端凭证模式下,令牌代表的是应用本身的身份,对应的是应用权限,这类权限只能用于访问组织租户内的资源(比如企业Exchange邮箱)。而个人Hotmail邮箱属于用户的私有数据,必须由用户本人授权,也就是需要使用委派权限,而非应用权限。个人MSA无法授予应用权限
即使你在Azure AD里给应用添加了Mail.Read这类应用权限,个人MSA账户也没有租户管理员来同意这些权限——个人账户本身不属于任何租户,所以应用权限对它完全无效。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

