1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
|
// Copyright Epic Games, Inc. All Rights Reserved.
#include "zenhttp/auth/oidc.h"
#include <zenhttp/httpclient.h>
ZEN_THIRD_PARTY_INCLUDES_START
#include <fmt/format.h>
#include <json11.hpp>
ZEN_THIRD_PARTY_INCLUDES_END
namespace zen {
namespace details {
using StringArray = std::vector<std::string>;
StringArray ToStringArray(const json11::Json JsonArray)
{
StringArray Result;
const auto& Items = JsonArray.array_items();
for (const auto& Item : Items)
{
Result.push_back(Item.string_value());
}
return Result;
}
} // namespace details
using namespace std::literals;
// Return the "scheme://host[:port]" prefix of an absolute URL, or empty on
// malformed input. RFC 8414 §3 requires every endpoint advertised by an
// OP to live under the issuer's origin, so exact-prefix comparison on this
// value is sufficient to pin an endpoint to the configured IdP.
static std::string_view
OriginOf(std::string_view Url)
{
// scheme://...
auto SchemeEnd = Url.find("://");
if (SchemeEnd == std::string_view::npos)
{
return {};
}
// First path char (or query / fragment) after the authority.
const size_t AuthorityStart = SchemeEnd + 3;
size_t OriginEnd = Url.size();
for (size_t I = AuthorityStart; I < Url.size(); ++I)
{
const char C = Url[I];
if (C == '/' || C == '?' || C == '#')
{
OriginEnd = I;
break;
}
}
// Require at least one character in the authority.
if (OriginEnd == AuthorityStart)
{
return {};
}
return Url.substr(0, OriginEnd);
}
// True if the URL uses a scheme / host that we trust for discovery even
// without HTTPS — narrowly, only loopback for dev / test setups.
static bool
IsLoopbackHttp(std::string_view Url)
{
constexpr std::string_view Prefixes[] = {
"http://localhost"sv,
"http://127.0.0.1"sv,
"http://[::1]"sv,
};
for (std::string_view P : Prefixes)
{
if (Url.size() >= P.size() && Url.substr(0, P.size()) == P)
{
// Ensure the next char (if any) ends the authority cleanly.
if (Url.size() == P.size())
{
return true;
}
const char Next = Url[P.size()];
if (Next == ':' || Next == '/' || Next == '?' || Next == '#')
{
return true;
}
}
}
return false;
}
static std::string
FormUrlEncode(std::string_view Input)
{
std::string Result;
Result.reserve(Input.size());
for (char C : Input)
{
if ((C >= 'A' && C <= 'Z') || (C >= 'a' && C <= 'z') || (C >= '0' && C <= '9') || C == '-' || C == '_' || C == '.' || C == '~')
{
Result.push_back(C);
}
else
{
Result.append(fmt::format("%{:02X}", static_cast<uint8_t>(C)));
}
}
return Result;
}
OidcClient::OidcClient(const OidcClient::Options& Options)
{
m_BaseUrl = std::string(Options.BaseUrl);
m_ClientId = std::string(Options.ClientId);
}
OidcClient::InitResult
OidcClient::Initialize()
{
// The OIDC discovery document determines where we send refresh tokens, so
// the transport to the discovery endpoint has to be trustworthy. Require
// HTTPS on the configured BaseUrl. Loopback is permitted over plain HTTP
// for developer setups that run a local IdP mock — no meaningful attack
// surface on the loopback interface.
if (m_BaseUrl.size() < 8 || m_BaseUrl.substr(0, 8) != "https://"sv)
{
if (!IsLoopbackHttp(m_BaseUrl))
{
return {.Reason = "BaseUrl must use https:// (or a http://localhost / 127.0.0.1 / [::1] loopback)"};
}
}
HttpClient Http{m_BaseUrl};
HttpClient::Response Response = Http.Get("/.well-known/openid-configuration"sv);
if (!Response)
{
return {.Reason = Response.ErrorMessage("")};
}
if (Response.StatusCode != HttpResponseCode::OK)
{
return {.Reason = std::string{ToString(Response.StatusCode)}};
}
std::string JsonError;
json11::Json Json = json11::Json::parse(std::string{Response.AsText()}, JsonError);
if (JsonError.empty() == false)
{
return {.Reason = std::move(JsonError)};
}
// RFC 8414 §3: the discovery document's `issuer` value MUST identify the
// OP and MUST be the origin used to fetch the document. Without this
// check, an attacker who can intercept discovery (or a misconfigured
// intermediate) can swap the issuer identity without detection. Accept
// a trailing '/' divergence since OPs vary.
const std::string Issuer = Json["issuer"].string_value();
{
std::string_view ExpectedBase = m_BaseUrl;
while (!ExpectedBase.empty() && ExpectedBase.back() == '/')
{
ExpectedBase.remove_suffix(1);
}
std::string_view ActualIssuer = Issuer;
while (!ActualIssuer.empty() && ActualIssuer.back() == '/')
{
ActualIssuer.remove_suffix(1);
}
if (ActualIssuer.empty() || ActualIssuer != ExpectedBase)
{
return {.Reason = fmt::format("discovery issuer mismatch (expected '{}')", ExpectedBase)};
}
}
// Pin every endpoint we actually use to the same origin as BaseUrl. This
// is the last defense against a tampered discovery document redirecting
// token submissions to an attacker-controlled host. We check the
// endpoints we may call later; endpoints this client never dispatches to
// are left alone so a discovery document with unrelated auxiliary URLs
// isn't rejected for no reason.
const std::string_view BaseOrigin = OriginOf(m_BaseUrl);
if (BaseOrigin.empty())
{
return {.Reason = "BaseUrl is malformed"};
}
const std::string TokenEndpoint = Json["token_endpoint"].string_value();
const std::string UserInfoEndpoint = Json["userinfo_endpoint"].string_value();
const std::string JwksUri = Json["jwks_uri"].string_value();
auto CheckOrigin = [&](std::string_view Name, std::string_view Url) -> std::optional<std::string> {
if (Url.empty())
{
return std::nullopt;
}
const std::string_view Origin = OriginOf(Url);
if (Origin != BaseOrigin)
{
return fmt::format("discovery endpoint '{}' is off-origin (expected origin '{}')", Name, BaseOrigin);
}
return std::nullopt;
};
if (auto Err = CheckOrigin("token_endpoint"sv, TokenEndpoint); Err.has_value())
{
return {.Reason = std::move(*Err)};
}
if (auto Err = CheckOrigin("userinfo_endpoint"sv, UserInfoEndpoint); Err.has_value())
{
return {.Reason = std::move(*Err)};
}
if (auto Err = CheckOrigin("jwks_uri"sv, JwksUri); Err.has_value())
{
return {.Reason = std::move(*Err)};
}
// token_endpoint is required for the refresh flow we implement; fail early
// rather than at RefreshToken time if the OP omitted it.
if (TokenEndpoint.empty())
{
return {.Reason = "discovery document is missing token_endpoint"};
}
m_Config = {.Issuer = Issuer,
.AuthorizationEndpoint = Json["authorization_endpoint"].string_value(),
.TokenEndpoint = TokenEndpoint,
.UserInfoEndpoint = UserInfoEndpoint,
.RegistrationEndpoint = Json["registration_endpoint"].string_value(),
.EndSessionEndpoint = Json["end_session_endpoint"].string_value(),
.DeviceAuthorizationEndpoint = Json["device_authorization_endpoint"].string_value(),
.JwksUri = JwksUri,
.SupportedResponseTypes = details::ToStringArray(Json["response_types_supported"]),
.SupportedResponseModes = details::ToStringArray(Json["response_modes_supported"]),
.SupportedGrantTypes = details::ToStringArray(Json["grant_types_supported"]),
.SupportedScopes = details::ToStringArray(Json["scopes_supported"]),
.SupportedTokenEndpointAuthMethods = details::ToStringArray(Json["token_endpoint_auth_methods_supported"]),
.SupportedClaims = details::ToStringArray(Json["claims_supported"])};
return {.Ok = true};
}
OidcClient::RefreshTokenResult
OidcClient::RefreshToken(std::string_view RefreshToken)
{
const std::string Body =
fmt::format("grant_type=refresh_token&refresh_token={}&client_id={}", FormUrlEncode(RefreshToken), FormUrlEncode(m_ClientId));
HttpClient Http{m_Config.TokenEndpoint};
HttpClient::KeyValueMap Headers{{"Content-Type", "application/x-www-form-urlencoded"}};
HttpClient::Response Response = Http.Post("", IoBufferBuilder::MakeFromMemory(MemoryView{Body.data(), Body.size()}), Headers);
if (!Response)
{
return {.Reason = std::string{Response.ErrorMessage("")}};
}
if (Response.StatusCode != HttpResponseCode::OK)
{
// Do NOT include Response.AsText() in the reason string. Some IdPs
// echo the submitted refresh_token (or a prefix of it) in their error
// body — plumbing that into the Reason string causes AuthMgrImpl's
// ZEN_WARN in the refresh paths to write the token into the log.
// Only the status code is safe to surface up to the log sites.
return {.Reason = fmt::format("{} (provider returned {} bytes)", ToString(Response.StatusCode), Response.AsText().size())};
}
std::string JsonError;
json11::Json Json = json11::Json::parse(std::string{Response.AsText()}, JsonError);
if (JsonError.empty() == false)
{
return {.Reason = std::move(JsonError)};
}
// Note: id_token is intentionally not parsed. It is a JWT whose contents
// are meaningful only after signature / issuer / audience / expiry
// verification against the provider's JWKS, and nothing downstream
// currently consumes it. Leaving it unparsed avoids planting an
// unauthenticated identity claim in the OpenIdToken cache.
return {.TokenType = Json["token_type"].string_value(),
.AccessToken = Json["access_token"].string_value(),
.RefreshToken = Json["refresh_token"].string_value(),
.Scope = Json["scope"].string_value(),
.ExpiresInSeconds = static_cast<int64_t>(Json["expires_in"].int_value()),
.Ok = true};
}
} // namespace zen
|