简述: 本文介绍本地客户端 APP 如何通过 cpp-httplib
和 libcurl
库实现 OAuth 2.0
的授权码流程。侧重于技术人员实现视角如何实现,重点包括以下几个方面:
- 什么是 OAuth 2.0?它的授权流程是怎样的?
cpp-httplib
和libcurl
的区别和差异?- 如何在本地通过网络库监听并获取默认浏览器返回的授权码 code?
- 如何使用授权码 code 置换最终的 Token?
[TOC]
本文初发于 “偕臧的小站“,同步转载于此。
💻 win11 24H2
📎 Visual Studio 2022
📎 C++17
OAuth 2.0 简介
关于 OAuth 2.0 的基本概念,可以参考阮一峰老师的 《理解OAuth 2.0》 以及 Google 的官方文档 《使用 OAuth 2.0 访问 Google API》。
其中的 OAuth 2.0 访问链接的参数含义,和必须和可选、含义:
https://openid.net/specs/openid-connect-core-1_0.html#Authentication
https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest

背景
在一些桌面应用(例如 Google 账号登录)中,过去常用 C# WebView2 内嵌浏览器来加载登录页。用户输入账号和密码后,应用即可获取授权凭证,用于后续调用业务 API。
然而这种方式逐渐暴露出风险与限制:
- WebView2 中账号密码可能存在泄露风险;
- Google 等厂商逐步收缩权限,不再推荐使用内嵌 WebView 登录;
- 企业后台安全审核趋严,未来将全面禁止 WebView2 方式的认证。
因此,更安全、更合规的做法是通过 系统默认浏览器(如 Chrome、Firefox)完成登录,并将结果回调给应用。
cpp-httplib 与 libcurl 的区别
在 OAuth 2.0 授权码流程中,获取授权码与使用授权码换取 Token的过程,需要用到不同的库:
cpp-httplib
: 可在本地开启 HTTP 服务,监听浏览器的重定向回调并获取授权码。libcurl
:擅长发起 HTTP 请求,用于后续向授权服务器请求 Token。
核心点:
cpp-httplib
能监听回调,而libcurl
做不到。
🔍 操作系统对自定义协议的处理
当浏览器访问自定义协议(如 my-app://token?code=xxx
)时,操作系统会根据注册信息启动本地应用,并将 URL 作为参数传递进去。在 Windows 上,这通常表现为:
- 通过命令行参数传递
- 或通过
WM_COPYDATA
消息传递
而 libcurl
仅是一个 HTTP 客户端库,无法拦截或处理操作系统层面的自定义协议调用,因此它无法直接获取浏览器重定向中的授权码。
🧩 为什么 cpp-httplib 能监听重定向
cpp-httplib
内置了轻量级 HTTP/HTTPS 服务器。应用可以在本地开启一个端口(如 http://127.0.0.1:8080
),并将此端口写入 OAuth 2.0 授权请求的 redirect_uri
。
当用户在浏览器完成登录后,授权服务器会将页面重定向到该地址,cpp-httplib
即可接收到请求,并提取出授权码。
🛑 为什么 libcurl 无法做到
libcurl
的定位是 发起请求,它不能充当 HTTP 服务端。也就是说,它只能用来交换 Token,而不能用来监听浏览器重定向。
整体流程示例图
结合上述两个库的特点,可以总结为:
- 授权码获取:依赖
cpp-httplib
本地监听重定向 - Token 置换:使用
libcurl
发送请求获取
流程如下:
+-------------------+ 1. 打开浏览器 +----------------------+
| 本地客户端 exe | -----------------------> | 浏览器 |
| (my-app.exe) | | |
+-------------------+ +----------------------+
| |
| 2. 浏览器访问授权 URL |
| https://authserver.com/authorize?client_id=xxx
v v
+-------------------+ +----------------------+
| 授权服务器 | <---------------------- | 用户登录/授权 |
| | 3. 登录/授权 | |
+-------------------+ +----------------------+
|
| 4. 重定向到客户端注册的 redirect_uri (二选一)
| [a] 自定义协议:my-app://token?code=xxxx (推荐)
| [b] 回环浏览器方式:http://127.0.0.1:8080/callback?code=xxxx (企业Product环境不允许)
v
+-------------------+
| 本地客户端 exe |
| httplib 监听端口 |
| 收到 code |
+-------------------+
|
| 5. 使用 libcurl 发 POST 请求换 token
v
+-------------------+
| 授权服务器 |
| /token 接口 |
+-------------------+
|
| 6. 返回 JSON {access_token, refresh_token, ...}
v 使用 access_token 来调用后续业务逻辑接口
+-------------------+
| 本地客户端 exe |
| 收到并保存 token |
+-------------------+
代码示例
Google 官方示例代码
Google 官方在 GitHub 上给出了一个简易的 C# 工程示例 oauth-apps-for-windows,展示用 Google 账号登录 oauth 2.0 的流程。

对于公司适配,对于 OAuthUniversalApp 工程,通常只需要修改 clientID 等三个字段,即可将 Google账号 切换为企业集团的 XXXXID账号,可以成功运行跑通。 而另一个两个工程,需要自己适配修改则更多一些。
const string clientID = "xxxxxxxxxxxxxxxxxxxxxxxx";
const string redirectURI = "pw.oauth2:/oauth2redirect"; // 通常还需要企业面板注册和配置才能生效
const string authorizationEndpoint = "https://passport.xxxxxx.com/v1.0/xxxxxx/oauth2/authorize";
自行实现纯 C++ 示例代码
Google 使用示例 OAuth for Apps: Samples for Windows,更多还是参考含义;对于实际工程中直接集成并不适用。还是需要自己写一套来实现适配。
压轴代码来了,我们希望使用存 C++ 来实现这个项目,便于项目的集成。
监听和捕获浏览器的重定向的响应
关于 4.重定向到客户端注册的 redirect_uri 这步骤中,实现有两种方式:
- 本地回环方式:通过监听本地浏览器返回的
http://127.0.0.1:8080/callback?code=xxxx
,捕获到所需要 code;但这种方法在 product 环境中,经常会遇到不被允许,但若是正经域名方式可以。即使 Test 环境允许,也需要在 面板上面自行注册,再后台同事来配置等待半个工作日才能生效 - 自定义协议(推荐):
my-app://token?code=xxxx
; 推荐的实现,其中跳转也有两种方式。- 一个是将另一个 exe 程序在 注册表指定路径进行注册,再通过调用传参数来获取 code
- 使用管道来实现,实测更加靠谱(推荐)
示例代码流程
简化版调用流程:主实例负责监听,次实例负责转交 URI,最终由主实例拿到授权码并换 Token。
示例程序实现了 单实例 + 自定义协议回调 + 实例间通过命名管道传 URI 的流程。核心思路是:
- 程序用一个命名
Mutex
判断“是否为首个实例”。 - 如果是首个实例:
- 启动一个后台线程作为 命名管道服务器(等待其他实例连接并读取对方写入的字符串)。
- 把自定义协议
accesories-udcc
注册到当前用户的注册表(HKCU),并打开浏览器发起 OAuth 授权(auth_url
)。 - 首实例等待通过管道收到后续回调(例如包含
code
的 URI),收到后解析并处理(当前只是打印 code/state)。
- 如果是后续启动的实例(通常由浏览器在 OAuth 回调时启动):
- 识别到不是首实例后,把命令行参数(浏览器传来的
accesories-udcc://...
)通过命名管道写给首实例,然后退出。
- 识别到不是首实例后,把命令行参数(浏览器传来的
这样做的好处:授权流程只在一个主进程里做实际处理,浏览器直接触发一个新进程(由系统打开自定义协议),新进程把 URI 转交给主进程后自己退出,避免多重并行处理或 UI 冲突。
完整源码
// main.cpp 只保留通用代码,自行替换一些实际参数即可正常编译和运行
// 使用命名管道 (\\.\pipe\MyApp_Pipe_v1) 在主/子进程间传递 my-app:// URI
// 特点:主实例创建管道服务器线程;新实例尝试连接管道并写入 URI(然后退出)。
#include <windows.h>
#include <shellapi.h>
#include <shlwapi.h>
#include <string>
#include <map>
#include <sstream>
#include <iostream>
#include <thread>
#include <vector>
#pragma comment(lib, "Shlwapi.lib")
using std::string;
using std::map;
using std::cout;
using std::endl;
// 常量
const char* PIPE_NAME = R"(\\.\pipe\MyApp_Pipe_v1)";
const char* MUTEX_NAME = "Local\\MyApp_Mutex_v1";
//////////////////////////////////////////////////////////////////
// 工具函数(URL 解码、解析 query/fragment、exe 路径等)
//////////////////////////////////////////////////////////////////
string GetExePath() {
char buf[MAX_PATH];
DWORD n = GetModuleFileNameA(nullptr, buf, MAX_PATH);
if (n == 0) return "";
return string(buf, n);
}
string ToLowerAscii(const string& s) {
string r = s;
for (char& c : r) if (c >= 'A' && c <= 'Z') c = char(c - 'A' + 'a');
return r;
}
string PercentDecode(const string& s) {
string out;
out.reserve(s.size());
auto hex = [](char c)->int {
if (c >= '0' && c <= '9') return c - '0';
if (c >= 'A' && c <= 'F') return c - 'A' + 10;
if (c >= 'a' && c <= 'f') return c - 'a' + 10;
return -1;
};
for (size_t i = 0; i < s.size(); ++i) {
if (s[i] == '%' && i + 2 < s.size()) {
int h = hex(s[i + 1]);
int l = hex(s[i + 2]);
if (h >= 0 && l >= 0) {
out.push_back(char((h << 4) | l));
i += 2;
continue;
}
}
if (s[i] == '+') out.push_back(' ');
else out.push_back(s[i]);
}
return out;
}
map<string, string> ParseParamsFromUri(const string& uri) {
map<string, string> out;
size_t hashPos = uri.find('#');
size_t qPos = uri.find('?');
auto parse_part = [&](size_t start, size_t end) {
if (start == string::npos || start >= end) return;
string part = uri.substr(start, end - start);
size_t pos = 0;
while (pos < part.size()) {
size_t amp = part.find('&', pos);
string token = part.substr(pos, (amp == string::npos ? part.size() : amp) - pos);
size_t eq = token.find('=');
if (eq != string::npos) {
string k = token.substr(0, eq);
string v = token.substr(eq + 1);
out[PercentDecode(k)] = PercentDecode(v);
}
else {
out[PercentDecode(token)] = "";
}
if (amp == string::npos) break;
pos = amp + 1;
}
};
if (qPos != string::npos) {
size_t qStart = qPos + 1;
size_t qEnd = (hashPos != string::npos) ? hashPos : uri.size();
if (qStart < qEnd) parse_part(qStart, qEnd);
}
if (hashPos != string::npos) {
size_t fStart = hashPos + 1;
size_t fEnd = uri.size();
if (fStart < fEnd) parse_part(fStart, fEnd);
}
return out;
}
//////////////////////////////////////////////////////////////////
// 命名管道服务器(在主实例中运行)
// 监听管道连接,读取客户端写入的 URI 字符串(以 '\0' 结尾或读取长度)
// 收到后交给 ProcessReceivedUri 处理(当前示例只是打印并解析 code/state)
//////////////////////////////////////////////////////////////////
void ProcessReceivedUri(const string& uri) {
cout << "[Server] 收到 URI: " << uri << endl;
auto params = ParseParamsFromUri(uri);
if (params.count("code")) cout << "[Server] code = " << params["code"] << endl;
if (params.count("state")) cout << "[Server] state = " << params["state"] << endl;
// TODO: 在这里继续做 code->token 的交换(HTTP 请求)
}
void PipeServerThreadProc() {
while (true) {
// 创建命名管道(单实例:每次连接后 Disconnect 并继续)
HANDLE hPipe = CreateNamedPipeA(
PIPE_NAME,
PIPE_ACCESS_INBOUND, // 只读(客户端写入)
PIPE_TYPE_BYTE | PIPE_READMODE_BYTE | PIPE_WAIT,
1, // 最多一个实例连接
4096, // out buffer
4096, // in buffer
0,
NULL
);
if (hPipe == INVALID_HANDLE_VALUE) {
cout << "[Server] CreateNamedPipeA 失败, GetLastError=" << GetLastError() << endl;
std::this_thread::sleep_for(std::chrono::seconds(1));
continue;
}
// 等待客户端连接
BOOL connected = ConnectNamedPipe(hPipe, NULL) ? TRUE : (GetLastError() == ERROR_PIPE_CONNECTED);
if (!connected) {
cout << "[Server] ConnectNamedPipe 失败, GetLastError=" << GetLastError() << endl;
CloseHandle(hPipe);
continue;
}
// 客户端已连接,读取数据
std::vector<char> buf(4096);
DWORD bytesRead = 0;
BOOL readOk = ReadFile(hPipe, buf.data(), (DWORD)buf.size() - 1, &bytesRead, NULL);
if (!readOk || bytesRead == 0) {
cout << "[Server] ReadFile 失败或无数据, GetLastError=" << GetLastError() << endl;
// 断开并继续循环
DisconnectNamedPipe(hPipe);
CloseHandle(hPipe);
continue;
}
// 确保以 '\0' 终止
if (bytesRead >= buf.size()) bytesRead = (DWORD)buf.size() - 1;
buf[bytesRead] = '\0';
string uri(buf.data(), bytesRead);
ProcessReceivedUri(uri);
// 关闭/断开
DisconnectNamedPipe(hPipe);
CloseHandle(hPipe);
// 继续等待下一个客户端
}
}
//////////////////////////////////////////////////////////////////
// 发送端(新实例):尝试连接命名管道并写入 URI(带重试)
// 返回 true 表示发送成功(主实例会处理),false 表示发送失败
//////////////////////////////////////////////////////////////////
bool SendUriToPipeServer(const string& uri, int maxRetries = 8, int retryDelayMs = 200) {
for (int i = 0; i < maxRetries; ++i) {
// 尝试打开管道
HANDLE hPipe = CreateFileA(
PIPE_NAME,
GENERIC_WRITE,
0,
NULL,
OPEN_EXISTING,
0,
NULL
);
if (hPipe != INVALID_HANDLE_VALUE) {
// 成功连接,写入数据(包含 '\0')
DWORD bytesWritten = 0;
BOOL ok = WriteFile(hPipe, uri.c_str(), (DWORD)uri.size() + 1, &bytesWritten, NULL);
if (!ok) {
cout << "[Client] WriteFile 失败, GetLastError=" << GetLastError() << endl;
CloseHandle(hPipe);
return false;
}
cout << "[Client] 成功写入管道, bytesWritten=" << bytesWritten << endl;
CloseHandle(hPipe);
return true;
}
else {
DWORD err = GetLastError();
// ERROR_FILE_NOT_FOUND 表示服务器未创建管道(主实例尚未就绪)
if (err == ERROR_FILE_NOT_FOUND) {
// 等待并重试
// cout << "[Client] 管道不存在,等待重试..." << endl;
Sleep(retryDelayMs);
continue;
}
else {
cout << "[Client] CreateFile 打开管道失败, GetLastError=" << err << endl;
Sleep(retryDelayMs);
continue;
}
}
}
cout << "[Client] 已达最大重试次数,无法连接管道发送 URI" << endl;
return false;
}
//////////////////////////////////////////////////////////////////
// main:单实例判断、管道发送/接收、打开浏览器(保留 response_mode=fragment)
//////////////////////////////////////////////////////////////////
int main(int argc, char* argv[]) {
// 创建命名 Mutex 判断是否首个实例
HANDLE hMutex = CreateMutexA(NULL, FALSE, MUTEX_NAME);
if (!hMutex) {
cout << "CreateMutex 失败: " << GetLastError() << endl;
return 1;
}
bool firstInstance = (GetLastError() != ERROR_ALREADY_EXISTS);
cout << "firstInstance = " << (firstInstance ? "true" : "false") << endl;
// 若以协议方式启动,argv[1] 里可能包含 URI(或被引号包裹)
string rawArg;
if (argc >= 2) {
rawArg = argv[1];
if (!rawArg.empty() && rawArg.front() == '"' && rawArg.back() == '"') rawArg = rawArg.substr(1, rawArg.size() - 2);
}
// 对 argv 做 PercentDecode(把 %23 -> '#' 等恢复)
string decodedArg = PercentDecode(rawArg);
if (!firstInstance) {
// 新实例:尝试通过命名管道把 URI 发给主实例(如果带 URI)
cout << "[Client] 非首实例,尝试通过命名管道发送 URI(如果存在)..." << endl;
if (!decodedArg.empty()) {
cout << "[Client] 将发送的 URI (decoded) = " << decodedArg << endl;
bool sent = SendUriToPipeServer(decodedArg, 12, 200);
if (!sent) {
cout << "[Client] 通过管道发送失败,可能主实例未就绪或权限问题。" << endl;
// 失败后选择退出
}
else {
cout << "[Client] 发送成功,进程退出。" << endl;
return 0;
}
}
else {
cout << "[Client] 没有 URI 参数,直接退出。" << endl;
}
return 0;
}
// 首个实例:启动命名管道服务器线程
std::thread serverThread(PipeServerThreadProc);
serverThread.detach();
cout << "[Server] 命名管道服务器线程已启动,管道名=" << PIPE_NAME << endl;
// 首个实例:注册自定义协议(HKCU,免管理员)
string keyErr;
// 注册函数:写入 HKCU\Software\Classes\my-app\shell\open\command = "exe" "%1"
// 你可以在安装程序中替代此步骤
HKEY hKey = NULL;
string keyRoot = "Software\\Classes\\my-app";
LONG r = RegCreateKeyExA(HKEY_CURRENT_USER, keyRoot.c_str(), 0, nullptr,
REG_OPTION_NON_VOLATILE, KEY_WRITE, nullptr, &hKey, nullptr);
if (r == ERROR_SUCCESS) {
string desc = "URL:my-app Protocol";
RegSetValueExA(hKey, nullptr, 0, REG_SZ, (const BYTE*)desc.c_str(), DWORD(desc.size() + 1));
RegSetValueExA(hKey, "URL Protocol", 0, REG_SZ, (const BYTE*)"", 1);
RegCloseKey(hKey);
string cmdKey = keyRoot + "\\shell\\open\\command";
HKEY hCmd = NULL;
r = RegCreateKeyExA(HKEY_CURRENT_USER, cmdKey.c_str(), 0, nullptr,
REG_OPTION_NON_VOLATILE, KEY_WRITE, nullptr, &hCmd, nullptr);
if (r == ERROR_SUCCESS) {
string exePath = GetExePath();
string cmd = "\"" + exePath + "\" \"%1\"";
RegSetValueExA(hCmd, nullptr, 0, REG_SZ, (const BYTE*)cmd.c_str(), DWORD(cmd.size() + 1));
RegCloseKey(hCmd);
cout << "[Server] 已在 HKCU 注册 my-app 协议(当前用户生效)。" << endl;
}
else {
cout << "[Server] 创建 command 子键失败: " << r << endl;
}
}
else {
cout << "[Server] 创建注册表键失败: " << r << endl;
}
// 如果首个实例启动时带有 URI(例如浏览器直接以首实例方式启动),解析并处理
if (!decodedArg.empty() && ToLowerAscii(decodedArg).rfind("my-app://", 0) == 0) {
cout << "[Server] 启动时携带 deep-link: " << decodedArg << endl;
ProcessReceivedUri(decodedArg);
}
// 构造 auth_url(保持你最初要求,不修改 response_mode=fragment)
string redirect_uri = "my-app://token";
const string auth_url = std::string("https://account.xxxx.com/auth/xxxx/xxxx/auth")
+ "?client_id=my-app"
+ "&state=xxxx"
+ "&response_mode=fragment"
+ "&response_type=code"
+ "&nonce=xxxx"
+ "&scope=xxxx"
+ "&redirect_uri=" + redirect_uri; // 重点
// 打开浏览器发起授权(仅用于测试或演示)
ShellExecuteA(nullptr, "open", auth_url.c_str(), nullptr, nullptr, SW_SHOWNORMAL);
cout << "[Server] 已打开浏览器,auth_url=" << auth_url << endl;
cout << "[Server] 等待通过命名管道传入的 my-app:// 回调..." << endl;
// 简单的消息循环:这里用 GetMessage 以保持进程活跃(便于 Ctrl+C 停止)
// 你可以把主循环替换为你的 GUI/应用主循环
MSG msg;
while (GetMessage(&msg, NULL, 0, 0)) {
TranslateMessage(&msg);
DispatchMessage(&msg);
}
// 清理(永远到不了这里,除非 PostQuitMessage)
CloseHandle(hMutex);
return 0;
}
libcurl 请求用授权码 code 置换 token
下面仅简要说明使用 libcurl
发起用 Code 来置换 Token 请求;
curl --location --request POST "https://account.xxxx.xxxx.com/auth/xxxx/xxxxxxx/xxxxx/xxxxxx/token" \
--header "Content-Type: application/x-www-form-urlencoded" \
--data-urlencode "code=xxx-long-code-xxx" \
--data-urlencode "grant_type=xxxx" \
--data-urlencode "client_id=my-app" \
--data-urlencode "redirect_uri=my-app://token"
可以看到返回我们所需要的 access_token
, 至此即完成
{
"access_token": "eyJhbGciOiJSU超级长一段字符串3YV_8_vA",
"expires_in": xxxx,
.....
"scope": "xxxx1 xxxx2"
}
编译 Libcurl 指南
更进一步完善: 简单的验证方式直接在 CMD 终端中,调用上面 CURL 命令来验证可行性,再切换为调用 Libcurl 库来实现。
对于 Libcurl 不熟悉的话,可以查看写的 《C++ 编译和运行 LibCurl 动态库和静态库》 系列文章。
系列地址
QtExamples 欢迎 star
⭐ 和 fork
🍴 这个系列的 C++ / QT / DTK
学习,附学习由浅入深的目录,这里你可以学到如何亲自编写这类软件的经验,这是一系列完整的教程,并且永久免费!