-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathAuthServer.cpp
More file actions
2269 lines (1984 loc) · 107 KB
/
Copy pathAuthServer.cpp
File metadata and controls
2269 lines (1984 loc) · 107 KB
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
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#if defined(WITH_POSTGRESQL) && defined(WITH_SSL)
#include "AuthServer.hpp"
#include "apostol/application.hpp"
#include "apostol/http_utils.hpp"
#include "apostol/jwt.hpp"
#include "apostol/logger.hpp"
#include "apostol/db_platform.hpp"
#include "apostol/pg_utils.hpp"
#include <fmt/format.h>
#include <nlohmann/json.hpp>
#include <algorithm>
namespace apostol
{
static std::string join_strings(const std::vector<std::string>& v, std::string_view sep)
{
if (v.empty()) return {};
std::string result = v[0];
for (std::size_t i = 1; i < v.size(); ++i) {
result += sep;
result += v[i];
}
return result;
}
// Extract a JSON value as string regardless of its actual type (number → string).
static std::string json_string(const nlohmann::json& j, const char* key)
{
auto it = j.find(key);
if (it == j.end() || it->is_null())
return {};
if (it->is_string())
return it->get<std::string>();
return it->dump(); // number, bool, etc. → their textual representation
}
// Identifies this module in db.session.agent and the event log.
static constexpr const char* kUserAgent = "AuthServer/2.0";
// Recorded as db.session.host for the service session. Loopback: the grant is
// issued by this process against its own database, not on behalf of a client.
static constexpr const char* kServiceHost = "127.0.0.1";
static constexpr const char* WEB_APP = "web";
static constexpr const char* SVC_APP = "service";
static constexpr auto kHeartbeatInterval = std::chrono::minutes(30);
static constexpr auto kRetryInterval = std::chrono::seconds(5);
// T063. Two independent limits on the outbound-provider login path.
// 2a — a per-request ceiling on the outbound HTTP call itself. Without it a
// provider that accepts the connection and then never answers hangs the
// sign-in with no bound at all; curl reports the timeout as on_error,
// which already redirects to a login refusal.
// 2b — a backstop on the whole deferred response. It fires only if NO callback
// arrives — for any reason, including ones CURLOPT_TIMEOUT cannot catch
// (a transfer that never started, a framework bug) — and closes the
// response as a refusal instead of leaving it for nginx to cut as a 504.
// The safety window is wider than two per-request budgets so a slow-but-working
// exchange (token → userinfo), or its own timeout turning into an on_error
// refusal, always resolves first. Both stay below nginx's proxy_read_timeout so
// the caller sees "sign-in failed", never a gateway error.
//
// This last ordering — 30 < 65 < proxy_read_timeout — is a coupling across two
// repositories with nothing to enforce it. nginx currently sets 90
// (.docker/nginx-certbot/default.conf.template, proxy_read_timeout, carries the
// mirror of this note). Drop it to 60 — nginx's own default — and the 65s safety
// timer becomes unreachable: the gateway answers first, the "did not complete in
// time" message never appears, and the failure reverts to the traceless 504 that
// T058 cost half a day. Keep proxy_read_timeout above kDeferredSafetyWindow.
static constexpr long kFetchTimeoutMs = 30000; // 2a
static constexpr auto kDeferredSafetyWindow = std::chrono::seconds(65); // 2b, < nginx proxy_read_timeout (90)
static constexpr const char* kCookieAT = "__Secure-AT";
static constexpr const char* kCookieRT = "__Secure-RT";
static constexpr const char* kCookieSAT = "__Secure-SAT";
static constexpr const char* kCookieSRT = "__Secure-SRT";
// The session code itself. Unlike the tokens it carries no signature: whatever
// arrives under this name is taken as the caller's identity, so the __Host- prefix
// is load-bearing here too. It forbids a Domain attribute, which means a sibling
// subdomain — one that answers over http, or one whose https was taken — cannot
// plant a session for this host.
static constexpr const char* kCookieSID = "__Host-SID";
// Double-submit token for POST /oauth2/consent. Written by the consent screen and
// echoed back in the form body; see do_consent for why it exists and why Origin
// does not. The __Host- prefix is load-bearing: it forbids a Domain attribute, so
// only this exact host can set the cookie — a sibling subdomain cannot plant one.
static constexpr const char* kCookieConsentToken = "__Host-CT";
// CSRF state for the external sign-in (RFC 6749 §10.12). The login screen mints it,
// keeps it in this cookie, and passes the same value to the provider as ?state=; on
// return, do_get compares the two. Same double-submit as the consent token, and the
// __Host- prefix carries the same weight — but SameSite=Lax, not Strict: the return
// is a top-level navigation from the provider's origin, which a Strict cookie would
// not accompany. The login screen sets it (a browser lets script set a __Host-
// cookie as long as it is Secure and Path=/), so nothing here writes it.
static constexpr const char* kCookieOAuthState = "__Host-OAuthState";
static constexpr int kCookieMaxAge = 60 * 86400; // 60 days
// ─── Construction ────────────────────────────────────────────────────────────
AuthServer::AuthServer(Application& app)
: pool_(app.db_pool())
, fetch_(app.worker_loop())
, loop_(app.worker_loop())
, log_(app.logger())
, providers_(app.providers())
, sites_(app.sites())
, enabled_(true)
, next_heartbeat_(std::chrono::system_clock::now())
{
// 2a — bound every outbound call (token exchange, userinfo). Default is 0,
// i.e. no timeout, so a provider that stops responding would hang the sign-in
// indefinitely. See kFetchTimeoutMs.
fetch_.set_timeout(kFetchTimeoutMs);
load_allowed_origins(providers_);
}
// ─── check_location ─────────────────────────────────────────────────────────
bool AuthServer::check_location(const HttpRequest& req) const
{
return req.path.size() >= 8 && req.path.substr(0, 8) == "/oauth2/";
}
// ─── init_methods ───────────────────────────────────────────────────────────
void AuthServer::init_methods()
{
add_method("GET", [this](auto& req, auto& resp) { do_get(req, resp); });
add_method("POST", [this](auto& req, auto& resp) { do_post(req, resp); });
add_allowed_header("Authorization");
load_allowed_origins(providers_);
}
// ─── heartbeat ──────────────────────────────────────────────────────────────
void AuthServer::on_stop()
{
// Every client_credentials grant writes a row to db.session and nothing
// collects them; leaving without this leaks one per worker per restart.
// By token, not by session code. This pool's role is daemon, which reaches the
// daemon schema and not api — closing by code answers "permission denied for
// schema api", which is why these sessions used to survive every restart.
db_platform::close_session(pool_, service_token_.token(), &log_, "[AuthServer]");
service_token_.invalidate();
}
// ─── refresh_service_token ──────────────────────────────────────────────────
//
// Which credentials to use; db_platform::refresh_service_token issues the request.
void AuthServer::refresh_service_token()
{
// Read the credentials now rather than at construction: providers are loaded by
// the application, and a value cached once at start-up is a value that can be
// cached before it exists.
const auto* svc = providers_.find_default(SVC_APP);
if (!svc) {
// Only worth saying when something is due, or it repeats every beat.
if (service_token_.needs_refresh()) {
log_.error("[AuthServer] no \"{}\" client in conf/oauth2: /oauth2/identifier "
"will refuse unauthenticated callers", SVC_APP);
service_token_.failed();
}
return;
}
// The scope is named rather than left to the server's default. They resolve to
// the same set today, but a token's scope decides what it may reach.
db_platform::refresh_service_token(pool_, service_token_, log_, "[AuthServer]",
svc->client_id, svc->client_secret,
join_strings(svc->scopes, " "),
kUserAgent, kServiceHost);
}
void AuthServer::heartbeat(std::chrono::system_clock::time_point now)
{
// Every beat: cheap when the token is still good, and the only thing that
// keeps it available to do_identifier without blocking a request on a query.
refresh_service_token();
if (now >= next_heartbeat_) {
next_heartbeat_ = now + kHeartbeatInterval;
check_providers();
fetch_providers();
}
}
// ─── Helpers ────────────────────────────────────────────────────────────────
std::string AuthServer::extract_action(std::string_view path)
{
// "/oauth2/<action>[/extra]" → "<action>"
if (path.size() < 9 || path.substr(0, 8) != "/oauth2/")
return {};
auto rest = path.substr(8); // after "/oauth2/"
auto slash = rest.find('/');
return std::string(rest.substr(0, slash));
}
std::string AuthServer::extract_provider(std::string_view path)
{
// "/oauth2/code/<provider>" → "<provider>"
if (path.size() < 9)
return {};
auto rest = path.substr(8); // after "/oauth2/"
auto slash = rest.find('/');
if (slash == std::string_view::npos || slash + 1 >= rest.size())
return {};
return std::string(rest.substr(slash + 1));
}
void AuthServer::parse_string_list(std::string_view input,
const std::vector<std::string>& allowed,
std::vector<std::string>& valid,
std::vector<std::string>& invalid)
{
valid.clear();
invalid.clear();
if (input.empty())
return;
// Split on space, comma, or both
std::string_view rest = input;
while (!rest.empty()) {
auto pos = rest.find_first_of(" ,");
auto token = rest.substr(0, pos);
if (!token.empty()) {
bool found = false;
for (const auto& a : allowed) {
if (a == token) { found = true; break; }
}
if (found)
valid.emplace_back(token);
else
invalid.emplace_back(token);
}
if (pos == std::string_view::npos) break;
rest = rest.substr(pos + 1);
}
}
// ─── OAuth2 error responses ─────────────────────────────────────────────────
void AuthServer::reply_oauth2_error(HttpResponse& resp, HttpStatus status,
std::string_view error,
std::string_view description)
{
if (status == HttpStatus::unauthorized) {
// RFC 6750 §3 defines three codes for this header — invalid_request,
// invalid_token, insufficient_scope — and nothing else. The header used to
// say access_denied whatever had happened, which is an RFC 6749 code from a
// different vocabulary: a client that reads WWW-Authenticate to decide
// whether to refresh finds a word it does not know and gives up.
//
// Anything outside that vocabulary becomes invalid_token, which is what a
// 401 from a bearer-token endpoint means when it means anything.
auto scheme_error = error;
if (scheme_error != "invalid_request" && scheme_error != "insufficient_scope")
scheme_error = "invalid_token";
resp.set_header("WWW-Authenticate",
fmt::format("Bearer error=\"{}\", "
"error_description=\"{}\"",
json_escape(scheme_error),
json_escape(description)));
}
resp.set_status(status)
.set_body(fmt::format(R"({{"error":"{}","error_description":"{}"}})",
json_escape(error), json_escape(description)),
"application/json");
}
void AuthServer::redirect_error(HttpResponse& resp, std::string_view location,
int code, std::string_view error,
std::string_view message)
{
if (location.empty()) {
// No site config for this host — return JSON error instead of a relative
// redirect that would loop back to the same handler.
reply_oauth2_error(resp, error_code_to_status(code), error, message);
return;
}
// Both values encoded, and error is the one that matters: it arrives from a
// query parameter on /oauth2/code, from an external identity provider's JSON,
// and from the database — three sources outside this process — and lands in a
// Location header. Unencoded, a CR LF in it ended the header and let the caller
// write further headers of its own onto this origin. libapostol now truncates
// header values at the first control byte, so this is the second of two locks
// on the same door; it is also simply how a query string is built.
auto url = fmt::format("{}?code={}&error={}&error_description={}",
location, code, url_encode(error),
url_encode(message));
redirect(resp, url);
}
void AuthServer::set_secure_cookies(HttpResponse& resp,
std::string_view access_token,
std::string_view refresh_token,
std::string_view session,
std::string_view domain)
{
if (!access_token.empty())
resp.set_cookie(kCookieAT, access_token, "/", kCookieMaxAge,
true, "None", true, domain);
if (!refresh_token.empty())
resp.set_cookie(kCookieRT, refresh_token, "/", kCookieMaxAge,
true, "None", true, domain);
// Never with a domain, even when the tokens above take one: __Host- forbids the
// attribute outright, and a browser drops the whole cookie if it appears.
if (!session.empty())
resp.set_cookie(kCookieSID, session, "/", kCookieMaxAge,
true, "Lax", true);
}
void AuthServer::set_service_cookies(HttpResponse& resp,
std::string_view access_token,
std::string_view refresh_token)
{
if (!access_token.empty())
resp.set_cookie(kCookieSAT, access_token, "/", kCookieMaxAge,
true, "None", true);
if (!refresh_token.empty())
resp.set_cookie(kCookieSRT, refresh_token, "/", kCookieMaxAge,
true, "None", true);
}
// ─── JWT ────────────────────────────────────────────────────────────────────
std::string AuthServer::get_public_key(std::string_view kid,
std::string_view provider) const
{
// Only this provider's keys. The kid comes out of the token being checked,
// and the token also names the audience that selected this provider — so
// searching every cache would let a key published by one provider verify a
// token claiming another's audience, which is the audience check undone.
// With one asymmetric provider it never showed; the second one is where it
// would have.
auto cache = key_cache_.find(std::string(provider));
if (cache == key_cache_.end() ||
cache->second.status != ProviderKeyCache::Status::success)
return {};
auto it = cache->second.keys.find(std::string(kid));
return it != cache->second.keys.end() ? it->second : std::string();
}
// ─── do_get ─────────────────────────────────────────────────────────────────
void AuthServer::do_get(const HttpRequest& req, HttpResponse& resp)
{
const auto action = extract_action(req.path);
const auto host = get_host(req);
const auto* site = sites_.find(host);
const std::string redirect_identifier = site ? site->oauth2.identifier : "";
const std::string redirect_secret = site ? site->oauth2.secret : "";
const std::string redirect_consent = site ? site->oauth2.consent : "";
const std::string redirect_callback = site ? site->oauth2.callback : "";
const std::string redirect_err = site ? site->oauth2.error : "";
static const std::vector<std::string> kResponseTypes{"code", "token"};
static const std::vector<std::string> kAccessTypes{"online", "offline"};
// "login" is the OpenID Connect Core §3.1.2.1 spelling; "signin" is this
// server's, kept because clients use it. Both name the same screen — see
// wants_prompt below, which reads them as one.
static const std::vector<std::string> kPrompts{
"none", "login", "signin", "secret", "consent", "select_account"};
std::vector<std::string> valid, invalid;
if (action == "authorize" || action == "auth") {
const auto& response_type = req.param("response_type");
const auto& client_id = req.param("client_id");
const auto& access_type = req.param("access_type");
const auto& redirect_uri = req.param("redirect_uri");
const auto& scope = req.param("scope");
const auto& state = req.param("state");
const auto& prompt = req.param("prompt");
const auto& max_age = req.param("max_age");
if (redirect_uri.empty()) {
redirect_error(resp, redirect_err, 400, "invalid_request",
"Parameter value redirect_uri cannot be empty.");
return;
}
// Client, redirect_uri and scope — checked together, and against the local
// provider's registration only.
auto* app = validate_client(resp, redirect_err, client_id, redirect_uri, scope);
if (!app)
return;
// prompt is read first, before anything else is validated, because it
// decides how every error below is answered: prompt=none means the client
// asked to be told rather than to have its user interrupted, and an error
// page is an interruption. Its own token list, not the shared valid/invalid
// pair — response_type is parsed after this and would overwrite it.
std::vector<std::string> prompts, bad_prompts;
parse_string_list(prompt, kPrompts, prompts, bad_prompts);
if (!bad_prompts.empty()) {
// Whether this client would have accepted a silent answer cannot be
// known from a prompt that did not parse, so this one keeps the screen.
redirect_error(resp, redirect_err, 400, "unsupported_prompt_type",
fmt::format("Some requested prompt type were invalid: "
"{{valid=[{}], invalid=[{}]}}",
join_strings(prompts, ", "),
join_strings(bad_prompts, ", ")));
return;
}
// A prompt that names a screen means the client wants that screen shown,
// even when the user is already signed in.
const auto wants_prompt = [&prompts](std::string_view value) {
return std::find(prompts.begin(), prompts.end(), value) != prompts.end();
};
const bool wants_signin = wants_prompt("signin") || wants_prompt("login");
const bool silent = wants_prompt("none");
// Where a rejected request goes. client_id and redirect_uri are validated
// above, which is what RFC 6749 §4.1.2.1 and OpenID Connect Core §3.1.2.6
// require before an error may be sent to the client's own address instead of
// shown to the user. Below that line the answer is the error page, as before.
const auto fail = [&](int code, std::string_view error,
std::string_view description) {
if (silent)
redirect_client_error(resp, redirect_uri, error, description, state);
else
redirect_error(resp, redirect_err, code, error, description);
};
// prompt=none says "show the user nothing at all". Pairing it with a value
// that names a screen asks for both, and OpenID Connect Core §3.1.2.1 makes
// that an error rather than letting the server pick a winner.
if (silent && prompts.size() > 1) {
fail(400, "invalid_request",
"prompt=none must not be combined with any other prompt value.");
return;
}
// Validate response_type
parse_string_list(response_type, kResponseTypes, valid, invalid);
if (!invalid.empty()) {
fail(400, "unsupported_response_type",
fmt::format("Some requested response type were invalid: "
"{{valid=[{}], invalid=[{}]}}",
join_strings(valid, ", "),
join_strings(invalid, ", ")));
return;
}
// `valid` is reused by the parses below — capture what we need now.
const bool wants_code =
std::find(valid.begin(), valid.end(), "code") != valid.end();
const bool wants_token =
std::find(valid.begin(), valid.end(), "token") != valid.end();
// Validate access_type.
//
// Meaningless only when the response is purely implicit: there is no code to
// exchange, so there is no refresh token for offline access to describe.
// A hybrid "code token" still has the code half, and access_type still
// applies to it — which the previous whole-string comparison happened to get
// right and a plain token-wise test would have got wrong.
auto access_types = kAccessTypes;
if (wants_token && !wants_code)
access_types.clear();
if (!access_type.empty()) {
bool at_ok = false;
for (const auto& at : access_types) {
if (at == access_type) { at_ok = true; break; }
}
if (!at_ok) {
fail(400, "invalid_request",
fmt::format("Invalid access_type: {}", access_type));
return;
}
}
// Validate max_age. OpenID Connect defines it as "Non-negative integer
// Seconds"; a value that is not one is a malformed request, not a hint to
// ignore — ignoring it would answer a demand for a fresh sign-in with a
// stale session and look like it had complied.
if (!max_age.empty()) {
// Digits, and few enough of them to be an integer on the other side.
// daemon.authorization_code takes pMaxAge integer: "99999999999999" is
// all digits, passes a syntax check, and then overflows in the database
// — answering a malformed request with server_error, which says the
// server broke when in fact the client sent nonsense.
const bool digits =
max_age.find_first_not_of("0123456789") == std::string::npos;
const bool in_range =
max_age.size() <= 10 && (max_age.size() < 10 || max_age <= "2147483647");
if (!digits || !in_range) {
fail(400, "invalid_request",
"max_age must be a non-negative integer number of seconds.");
return;
}
}
const bool interactive = wants_signin || wants_prompt("secret") ||
wants_prompt("consent") || wants_prompt("select_account");
// The original request, ready to be appended to whichever page we send
// the browser to — so that the flow resumes where it left off.
// Encoded like every other parameter here. These two were the exception, and
// the result goes straight into a Location header — parse_string_list keeps
// response_type to a known word, but client_id is whatever was registered,
// and a header is no place to find out that assumption was wrong.
auto query = fmt::format("?client_id={}&response_type={}",
url_encode(client_id), url_encode(response_type));
if (!redirect_uri.empty())
query += "&redirect_uri=" + url_encode(redirect_uri);
if (!access_type.empty())
query += "&access_type=" + url_encode(access_type);
if (!scope.empty())
query += "&scope=" + url_encode(scope);
if (!prompt.empty())
query += "&prompt=" + url_encode(prompt);
if (!max_age.empty())
query += "&max_age=" + url_encode(max_age);
if (!state.empty())
query += "&state=" + url_encode(state);
// wants_prompt, not prompt == "secret": prompt is a space-separated list, so
// comparing the whole string means "prompt=secret signin" matches neither
// branch and lands on the identifier page — the one screen the client did
// not ask for. The same list is already read token-wise three lines above.
//
// No relative location: an empty oauth2.identifier (or oauth2.secret) would
// make redirect_login read as "?client_id=…", which the browser resolves
// against /oauth2/authorize and lands right back here — a redirect loop, worse
// than the finite 404 it replaced. Kept empty when there is no page to send to,
// and refused below rather than redirected (T065). do_consent shares this
// reasoning — no relative Location — and its comment points here, but the mirror
// is only partial: do_consent always returns to the identifier page, while this
// path also offers the secret page (prompt=secret). That branch has no
// equivalent in the consent flow, so the asymmetry is by design, not a gap.
const std::string login_base =
wants_prompt("secret") ? redirect_secret : redirect_identifier;
const std::string redirect_login =
login_base.empty() ? std::string() : login_base + query;
// What the user will be asked to agree to. An empty scope is not "nothing" —
// the database expands it to every scope there is — so resolve it here, to
// the list this client is registered for. The consent screen then shows the
// same list that gets recorded, and neither is a blank cheque.
const std::string consent_scope =
scope.empty() ? join_strings(app->scopes, " ") : scope;
auto consent_query = fmt::format("?client_id={}&response_type={}",
url_encode(client_id), url_encode(response_type));
consent_query += "&redirect_uri=" + url_encode(redirect_uri);
if (!access_type.empty())
consent_query += "&access_type=" + url_encode(access_type);
if (!consent_scope.empty())
consent_query += "&scope=" + url_encode(consent_scope);
if (!prompt.empty())
consent_query += "&prompt=" + url_encode(prompt);
if (!max_age.empty())
consent_query += "&max_age=" + url_encode(max_age);
if (!state.empty())
consent_query += "&state=" + url_encode(state);
// Where the consent screen lives. There is deliberately no fallback path:
// guessing one sends the browser to a URL on whichever host it happened to
// ask, and the screen only exists on the host that serves the SPA. A site
// that has not named oauth2.consent cannot ask the question, and saying so
// to the client beats a redirect into a 404.
const std::string consent_page =
redirect_consent.empty() ? std::string() : redirect_consent + consent_query;
// Signed in already and nothing to ask: hand the client its code and be done.
// Without this the consent screen can never complete — its "Allow" button
// comes back here, and every answer used to be "go to the login page".
// Whether the user has actually granted this client access is decided in
// daemon.authorization_code, which answers consent_required when they have not.
// Read once: on a request with no SID cookie this verifies a JWT, and the
// silent branch below asks the same question again.
const auto session = session_from_request(req);
if (wants_code && !interactive) {
if (!session.empty()) {
issue_authorization_code(req, resp, session, client_id, redirect_uri,
scope, state, access_type,
redirect_login, consent_page, redirect_err,
/* consent */ false, max_age, silent);
return;
}
}
// Nothing above could be answered without a screen, and prompt=none forbids
// one. The client hears why on its own redirect_uri: it asked to be told
// rather than to have its user interrupted, and OpenID Connect Core §3.1.2.1
// names both answers. login_required when there is no session to work from;
// interaction_required when the response type this server can issue silently
// — an authorization code — was not the one asked for.
if (silent) {
// Two ways to get here. Without a session there is nobody to answer for,
// and the client must send its user to sign in. With one, the response
// type asked for is not the authorization code this server issues
// silently — which is a statement about the request, not about the user,
// so unsupported_response_type says more than interaction_required would.
if (session.empty())
fail(401, "login_required", "The user is not signed in.");
else
fail(400, "unsupported_response_type",
"Only response_type=code can be answered without user interaction.");
return;
}
// Redirect to the login — or, when asked for, the consent — page
if (wants_prompt("consent")) {
if (consent_page.empty()) {
redirect_client_error(resp, redirect_uri, "consent_required",
"This server has no consent screen configured.",
state);
return;
}
redirect(resp, consent_page);
return;
}
if (redirect_login.empty()) {
// Names the cause, like the consent_required refusal above: this site has
// no oauth2.identifier (nor secret) configured, so there is nowhere to send
// the user to sign in. "Not signed in" would point at the user, who is not
// the problem — an admin reading the client's log would hunt for a session
// rather than the missing configuration.
redirect_client_error(resp, redirect_uri, "access_denied",
"This server has no sign-in screen configured.", state);
return;
}
redirect(resp, redirect_login);
} else if (action == "code") {
const auto& state = req.param("state");
auto provider_name = extract_provider(req.path);
if (provider_name.empty())
provider_name = "default";
// CSRF for the external sign-in (RFC 6749 §10.12), and it is the very first
// thing done here — before the error and code parameters are even read.
// Every hit of this endpoint is a provider sending the browser back, and a
// provider echoes state on the error return as much as on the success one
// (RFC 6749 §4.1.2.1). Checking success only would leave the error branch
// open: error and error_description are attacker-chosen text, and rendering
// them on our own error page, under our own certificate, for anyone who
// crafts the URL, is a phishing surface — no session is handed out, but
// "your session expired, call this number" appears on auth.<domain>. So the
// gate comes first and the check is unconditional.
//
// The state that returns must equal the one the login screen minted and kept
// in a __Host- cookie before it sent the browser off. This is the consent
// token's double-submit, and it holds for the same reason: an attacker on
// another origin can neither read our cookie to forge a matching state nor
// plant one under the __Host- prefix. The cookie is SameSite=Lax, not Strict,
// because this return is a top-level navigation from the provider's origin —
// a Strict cookie would not be sent and every real sign-in would fail. (The
// old "debug" routing that compared state to a literal is gone with it: under
// a real check that literal was a hole straight through.)
const auto state_cookie = req.cookie(kCookieOAuthState);
if (state.empty() || state_cookie.empty() || state != state_cookie) {
if (state.empty() || state_cookie.empty())
log_.warn("[AuthServer] oauth2 code refused: state {} missing (provider={})",
state_cookie.empty() ? "cookie" : "parameter", provider_name);
else
log_.warn("[AuthServer] oauth2 code refused: state mismatch (provider={})",
provider_name);
// Actionable for the one legitimate way to reach it — a state cookie that
// expired while the user registered at the provider — without helping the
// other: it names nothing an attacker did not already send.
redirect_error(resp, redirect_err, 403, "access_denied",
"The sign-in could not be verified. Please start again.");
return;
}
// Only now, past the gate: a provider error the browser carried back (the
// provider echoed our state, so this is a real return), then the code.
const auto& error = req.param("error");
if (!error.empty()) {
redirect_error(resp, redirect_err, 400, error,
req.param("error_description"));
return;
}
const auto& code = req.param("code");
if (code.empty()) {
redirect_error(resp, redirect_err, 400, "invalid_request",
"Parameter \"code\" not found.");
return;
}
auto* app = providers_.find(provider_name, WEB_APP);
if (!app) {
redirect_error(resp, redirect_err, 400, "invalid_request",
fmt::format("Provider \"{}\" not found.", provider_name));
return;
}
auto conn = std::static_pointer_cast<HttpConnection>(req.connection_ctx);
resp.set_deferred(true);
auto redir = redirect_callback;
auto agent = get_user_agent(req, "AuthServer/2.0");
auto real_ip = get_real_ip(req);
auto full_origin = get_protocol(req) + "://" + host;
fetch_access_token(conn, *app, code, full_origin,
redir, redirect_err, agent, real_ip);
return;
} else if (action == "callback") {
// Same loop guard as the sign-in redirect above (T065). redirect_callback is
// site->oauth2.callback, empty when the site did not configure it, and an empty
// Location resolves relative to /oauth2/callback — the browser lands right back
// here. Refuse to the site error page instead; redirect_error is itself
// loop-safe (a JSON error when that page too is unset). The mechanical version
// of this guard, inside redirect() so no caller has to remember it, is T087.
if (redirect_callback.empty()) {
// redirect_error is static and cannot log; the /error screen no longer shows
// the prose (T050), so name the cause here — this is a deployment fault, and
// the log is where it has to be visible.
log_.warn("[AuthServer] callback refused: oauth2.callback is not configured "
"for host \"{}\"", host);
redirect_error(resp, redirect_err, 500, "server_error",
"This server has no callback page configured.");
return;
}
redirect(resp, redirect_callback);
} else if (action == "identifier") {
do_identifier(req, resp);
return;
} else if (action == "providers") {
do_providers(req, resp);
return;
} else {
resp.set_status(HttpStatus::not_found)
.set_body("", "text/plain");
return;
}
}
// ─── do_providers ─────────────────────────────────────────────────────────────
//
// The list an unauthenticated login screen fetches to render its "sign in through
// X" buttons. Two things make the projection here load-bearing rather than a
// formality:
//
// 1. There is no session yet — the screen calls this *before* anyone has signed
// in — so nothing but this code decides what leaves the server. And the same
// OAuthApp that feeds it holds client_secret. The answer is therefore built
// field by field from a fixed set; the whole object is never serialised. Adding
// a field to OAuthApp must not add it here by accident, which is why this does
// not iterate keys.
//
// 2. Only providers that declare `external` appear. `default` and `bridge` are
// this installation's own applications — their auth_uri points back at us, and
// "sign in through ourselves" is not a button. Listing them would also put our
// own web client_id on an anonymous endpoint for no reason.
//
// Only the `web` section is considered: the external path takes that section by a
// constant everywhere (see validate flow), so a provider is reachable through
// exactly one application, and the list must agree with it.
void AuthServer::do_providers(const HttpRequest& req, HttpResponse& resp)
{
(void) req;
nlohmann::json list = nlohmann::json::array();
for (const auto& app : providers_.apps()) {
if (!app.external || app.name != WEB_APP)
continue;
// Explicit projection. Everything here is public: it ends up in the browser's
// address bar the moment the user starts the flow. client_secret, issuers,
// algorithm, userinfo_*, redirect/origin lists and our own scope codes are
// none of the screen's business and are not copied.
nlohmann::json item;
item["provider"] = app.provider; // path segment: /oauth2/code/<provider>
item["display_name"] = app.display_name;
item["icon"] = app.icon;
item["client_id"] = app.client_id; // public; the redirect carries it anyway
item["auth_uri"] = app.auth_uri; // the provider's authorize endpoint
item["login_scope"] = app.login_scope; // the provider's OAuth scope string
list.push_back(std::move(item));
}
resp.set_status(HttpStatus::ok)
.set_body(list.dump(), "application/json");
}
// ─── do_post ────────────────────────────────────────────────────────────────
void AuthServer::do_post(const HttpRequest& req, HttpResponse& resp)
{
const auto action = extract_action(req.path);
if (action == "token") {
do_token(req, resp);
} else if (action == "identifier") {
do_identifier(req, resp);
} else if (action == "consent") {
do_consent(req, resp);
} else {
reply_oauth2_error(resp, HttpStatus::not_found,
"invalid_request", "Not found.");
}
}
// ─── do_token ───────────────────────────────────────────────────────────────
void AuthServer::do_token(const HttpRequest& req, HttpResponse& resp)
{
auto json = content_to_json(req);
const auto grant_type = json.value("grant_type", "");
const auto client_id = json.value("client_id", "");
const auto client_secret = json.value("client_secret", "");
const auto redirect_uri = json.value("redirect_uri", "");
std::string auth_username;
std::string auth_password;
if (grant_type != "urn:ietf:params:oauth:grant-type:jwt-bearer") {
const auto auth_header = req.header("Authorization");
const auto origin = get_origin(req);
if (auth_header.empty()) {
auth_username = client_id;
auth_password = client_secret;
} else {
auto auth = parse_authorization(auth_header);
if (auth.schema != Authorization::Schema::basic) {
reply_oauth2_error(resp, HttpStatus::bad_request,
"invalid_request", "Invalid authorization schema.");
return;
}
auth_username = std::move(auth.username);
auth_password = std::move(auth.password);
}
if (auth_username.empty()) {
if (grant_type != "password") {
reply_oauth2_error(resp, HttpStatus::bad_request,
"invalid_request",
"Parameter value client_id cannot be empty.");
return;
}
// Default to the web app's client_id (client_id omitted by browser
// to avoid exposing client_secret in DevTools). When allowed_ips
// is configured, only requests from those IPs may use this shortcut.
auto* default_app = providers_.find_default(WEB_APP);
if (default_app) {
// allowed_ips guards the no-client_id shortcut by peer_ip
// (who connected to our socket — nginx or direct client).
// Default: loopback + private networks (RFC 1918).
const auto& peer = req.peer_ip;
bool ip_ok = false;
if (default_app->allowed_ips.empty()) {
ip_ok = is_private_ip(peer);
} else {
for (const auto& entry : default_app->allowed_ips) {
if (peer == entry || peer.starts_with(entry)) {
ip_ok = true;
break;
}
}
}
if (!ip_ok) {
reply_oauth2_error(resp, HttpStatus::bad_request,
"invalid_request",
"Parameter value client_id cannot be empty.");
return;
}
auth_username = default_app->client_id;
}
}
if (auth_password.empty()) {
auto* app = providers_.find_by_client_id(auth_username);
if (app && (app->name == WEB_APP || app->name == SVC_APP)) {
// Validate redirect_uri if provided
if (!redirect_uri.empty()) {
bool uri_ok = false;
for (const auto& uri : app->redirect_uris) {
if (uri == redirect_uri) { uri_ok = true; break; }
}
if (!uri_ok) {
reply_oauth2_error(resp, HttpStatus::bad_request,
"invalid_request",
fmt::format("Invalid parameter value for redirect_uri: "
"Non-public domains not allowed: {}",
redirect_uri));
return;
}
}
// Validate javascript_origins
bool origin_ok = false;
for (const auto& jo : app->javascript_origins) {
if (jo == origin) { origin_ok = true; break; }
}
if (!origin_ok) {
reply_oauth2_error(resp, HttpStatus::bad_request,
"invalid_request",
fmt::format("The JavaScript origin in the request, {}, "
"does not match the ones authorized for "
"the OAuth client.", origin));
return;
}
auth_password = app->client_secret;
}
}
if (auth_password.empty()) {
reply_oauth2_error(resp, HttpStatus::bad_request,
"invalid_request",
"Parameter value client_secret cannot be empty.");
return;
}
}
const auto agent = get_user_agent(req, "AuthServer/2.0");
const auto host = get_real_ip(req);
const auto hostname = get_host(req);
auto sql = fmt::format("SELECT * FROM daemon.token({}, {}, {}::jsonb, {}, {});",
pq_quote_literal(auth_username),
pq_quote_literal(auth_password),
pq_quote_literal(json.dump()),
pq_quote_literal(agent),
pq_quote_literal(host));
resp.set_deferred(true);
auto conn = std::static_pointer_cast<HttpConnection>(req.connection_ctx);
const bool is_service = (grant_type == "client_credentials");
// quiet: the statement carries daemon.token's arguments — the end user's
// password on grant_type=password, a client secret on client_credentials.
pool_.execute(std::move(sql),
// on_result
[conn, hostname, is_service](std::vector<PgResult> results) {
HttpResponse r;
if (results.empty() || !results[0].ok()) {
auto msg = results.empty() ? "no results"
: results[0].error_message();
reply_oauth2_error(r, HttpStatus::internal_server_error,
"server_error", msg);
conn->send_response(r);
return;
}
auto body = results[0].value(0, 0);
try {
auto result_json = nlohmann::json::parse(body);
// Check for OAuth2 error in PG result
if (result_json.contains("error")) {
auto& err_obj = result_json["error"];
int code = err_obj.value("code", 400);