-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathoauth.cpp
More file actions
1362 lines (1246 loc) · 60.8 KB
/
Copy pathoauth.cpp
File metadata and controls
1362 lines (1246 loc) · 60.8 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
// SPDX-FileCopyrightText: 2026 Paolo Anzani
// SPDX-License-Identifier: Apache-2.0
#include "oauth.h"
#include "api.h"
#include "json.h"
#ifdef __APPLE__
#include <CommonCrypto/CommonDigest.h>
#elif defined(__linux__)
#include <openssl/sha.h>
#else
#error "OAuth SHA-256 is unsupported on this platform"
#endif
#include <arpa/inet.h>
#include <curl/curl.h>
#include <fcntl.h>
#include <netinet/in.h>
#include <sys/select.h>
#include <sys/socket.h>
#include <sys/stat.h>
#include <unistd.h>
#include <algorithm>
#include <array>
#include <cerrno>
#include <charconv>
#include <chrono>
#include <cstddef>
#include <cstdint>
#include <cstdlib>
#include <cstring>
#include <ctime>
#include <expected>
#include <filesystem>
#include <map>
#include <memory>
#include <optional>
#include <span>
#include <string>
#include <string_view>
#include <system_error>
#include <utility>
namespace {
constexpr std::size_t maximum_http_header_size = std::size_t{32} * 1024;
constexpr std::size_t maximum_token_response_size = std::size_t{1024} * 1024;
// Keeps every early-return path from leaking a socket or file descriptor.
// OAuth has many error exits, so centralizing close() here makes the rest of
// the flow much easier to audit.
class FileDescriptor {
public:
FileDescriptor() = default;
explicit FileDescriptor(const int descriptor) : descriptor_(descriptor) {}
FileDescriptor(const FileDescriptor &) = delete;
FileDescriptor &operator=(const FileDescriptor &) = delete;
FileDescriptor(FileDescriptor &&other) noexcept
: descriptor_(std::exchange(other.descriptor_, -1)) {}
FileDescriptor &operator=(FileDescriptor &&other) noexcept {
if (this != &other) {
reset();
descriptor_ = std::exchange(other.descriptor_, -1);
}
return *this;
}
~FileDescriptor() { reset(); }
[[nodiscard]] int get() const { return descriptor_; }
[[nodiscard]] explicit operator bool() const { return descriptor_ >= 0; }
void reset(const int descriptor = -1) {
if (descriptor_ >= 0) {
::close(descriptor_);
}
descriptor_ = descriptor;
}
private:
int descriptor_ = -1;
};
std::string systemError(const std::string_view operation, const int error_number = errno) {
return std::string(operation) + ": " + std::strerror(error_number);
}
bool containsNewline(const std::string_view value) {
return value.find_first_of("\r\n") != std::string_view::npos;
}
// PKCE and JWTs use the URL-safe Base64 alphabet without '=' padding. These
// helpers live here instead of pulling in another encoding dependency.
std::string base64UrlEncode(const std::span<const unsigned char> bytes) {
constexpr char alphabet[] =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
std::string encoded;
encoded.reserve((bytes.size() * 4 + 2) / 3);
std::size_t position = 0;
while (bytes.size() - position >= 3) {
const std::uint32_t block = (static_cast<std::uint32_t>(bytes[position]) << 16) |
(static_cast<std::uint32_t>(bytes[position + 1]) << 8) |
bytes[position + 2];
encoded += alphabet[(block >> 18) & 0x3f];
encoded += alphabet[(block >> 12) & 0x3f];
encoded += alphabet[(block >> 6) & 0x3f];
encoded += alphabet[block & 0x3f];
position += 3;
}
const std::size_t remaining = bytes.size() - position;
if (remaining == 1) {
const std::uint32_t block = static_cast<std::uint32_t>(bytes[position]) << 16;
encoded += alphabet[(block >> 18) & 0x3f];
encoded += alphabet[(block >> 12) & 0x3f];
} else if (remaining == 2) {
const std::uint32_t block = (static_cast<std::uint32_t>(bytes[position]) << 16) |
(static_cast<std::uint32_t>(bytes[position + 1]) << 8);
encoded += alphabet[(block >> 18) & 0x3f];
encoded += alphabet[(block >> 12) & 0x3f];
encoded += alphabet[(block >> 6) & 0x3f];
}
return encoded;
}
int base64UrlDigit(const char character) {
if (character >= 'A' && character <= 'Z') {
return character - 'A';
}
if (character >= 'a' && character <= 'z') {
return character - 'a' + 26;
}
if (character >= '0' && character <= '9') {
return character - '0' + 52;
}
if (character == '-') {
return 62;
}
if (character == '_') {
return 63;
}
return -1;
}
std::expected<std::string, std::string> base64UrlDecode(std::string_view encoded) {
while (!encoded.empty() && encoded.back() == '=') {
encoded.remove_suffix(1);
}
if (encoded.size() % 4 == 1) {
return std::unexpected("Invalid base64url length");
}
std::string decoded;
decoded.reserve(encoded.size() * 3 / 4);
std::uint32_t accumulator = 0;
int available_bits = 0;
int last_digit = 0;
for (const char character : encoded) {
const int digit = base64UrlDigit(character);
if (digit < 0) {
return std::unexpected("Invalid base64url character");
}
last_digit = digit;
accumulator = (accumulator << 6) | static_cast<std::uint32_t>(digit);
available_bits += 6;
if (available_bits >= 8) {
available_bits -= 8;
decoded += static_cast<char>((accumulator >> available_bits) & 0xff);
}
}
// A canonical base64url value has zeroes in bits which only served as
// padding.
if ((available_bits == 4 && (last_digit & 0x0f) != 0) ||
(available_bits == 2 && (last_digit & 0x03) != 0)) {
return std::unexpected("Invalid base64url padding bits");
}
return decoded;
}
// Generates the high-entropy PKCE verifier and anti-CSRF state value.
std::string randomBase64Url(const std::size_t byte_count) {
std::string bytes(byte_count, '\0');
// arc4random_buf is provided by macOS and modern glibc and needs no
// caller-managed state.
::arc4random_buf(bytes.data(), bytes.size());
return base64UrlEncode(
std::span(reinterpret_cast<const unsigned char *>(bytes.data()), bytes.size()));
}
// RFC 7636 defines an S256 challenge as BASE64URL(SHA256(verifier)).
std::string sha256Base64Url(const std::string_view value) {
#ifdef __APPLE__
std::array<unsigned char, CC_SHA256_DIGEST_LENGTH> digest{};
CC_SHA256(value.data(), static_cast<CC_LONG>(value.size()), digest.data());
#else
std::array<unsigned char, SHA256_DIGEST_LENGTH> digest{};
SHA256(reinterpret_cast<const unsigned char *>(value.data()), value.size(), digest.data());
#endif
return base64UrlEncode(digest);
}
// OAuth query strings and form bodies both need RFC 3986 percent encoding.
// Spaces deliberately become %20; the authorization server accepts this in
// application/x-www-form-urlencoded bodies as well as in URLs.
std::string percentEncode(const std::string_view value) {
constexpr char hex[] = "0123456789ABCDEF";
std::string encoded;
encoded.reserve(value.size());
for (const unsigned char character : value) {
if ((character >= 'a' && character <= 'z') || (character >= 'A' && character <= 'Z') ||
(character >= '0' && character <= '9') || character == '-' || character == '.' ||
character == '_' || character == '~') {
encoded += static_cast<char>(character);
} else {
encoded += '%';
encoded += hex[character >> 4];
encoded += hex[character & 0x0f];
}
}
return encoded;
}
int hexDigit(const char character) {
if (character >= '0' && character <= '9') {
return character - '0';
}
if (character >= 'a' && character <= 'f') {
return character - 'a' + 10;
}
if (character >= 'A' && character <= 'F') {
return character - 'A' + 10;
}
return -1;
}
std::expected<std::string, std::string> percentDecode(const std::string_view encoded) {
std::string decoded;
decoded.reserve(encoded.size());
for (std::size_t position = 0; position < encoded.size(); ++position) {
if (encoded[position] == '+') {
decoded += ' ';
continue;
}
if (encoded[position] != '%') {
decoded += encoded[position];
continue;
}
if (encoded.size() - position < 3) {
return std::unexpected("Incomplete percent escape in OAuth callback");
}
const int high = hexDigit(encoded[position + 1]);
const int low = hexDigit(encoded[position + 2]);
if (high < 0 || low < 0) {
return std::unexpected("Invalid percent escape in OAuth callback");
}
decoded += static_cast<char>((high << 4) | low);
position += 2;
}
return decoded;
}
using QueryParameters = std::map<std::string, std::string>;
// Decode the callback query once and reject duplicate keys. Rejecting
// duplicates avoids ambiguous state/code values being interpreted
// differently by this client and an intermediary.
std::expected<QueryParameters, std::string> parseQuery(const std::string_view query) {
QueryParameters parameters;
std::size_t position = 0;
while (position < query.size()) {
const std::size_t separator = query.find('&', position);
const std::string_view pair =
query.substr(position, separator == std::string_view::npos ? query.size() - position
: separator - position);
const std::size_t equals = pair.find('=');
auto key = percentDecode(pair.substr(0, equals));
auto value = percentDecode(equals == std::string_view::npos ? std::string_view{}
: pair.substr(equals + 1));
if (!key || !value) {
return std::unexpected(key ? value.error() : key.error());
}
if (!parameters.emplace(std::move(key.value()), std::move(value.value())).second) {
return std::unexpected("Duplicate parameter in OAuth callback");
}
if (separator == std::string_view::npos) {
break;
}
position = separator + 1;
}
return parameters;
}
// State is a secret nonce. Avoid returning early at the first differing byte.
bool constantTimeEqual(const std::string_view left, const std::string_view right) {
if (left.size() != right.size()) {
return false;
}
unsigned char difference = 0;
for (std::size_t position = 0; position < left.size(); ++position) {
difference |= static_cast<unsigned char>(left[position] ^ right[position]);
}
return difference == 0;
}
struct SocketError {
std::string message;
int error_number;
};
struct BoundListener {
FileDescriptor socket;
std::uint16_t port;
};
// Bind only to loopback: authorization codes must never be exposed on a LAN
// interface. Port zero is supported for tests; production uses the two
// redirect ports registered for the Codex OAuth client.
std::expected<BoundListener, SocketError> bindListener(const std::uint16_t port) {
FileDescriptor listener(::socket(AF_INET, SOCK_STREAM, 0));
if (!listener) {
const int error = errno;
return std::unexpected(
SocketError{systemError("Could not create OAuth listener", error), error});
}
const int enabled = 1;
::setsockopt(listener.get(), SOL_SOCKET, SO_REUSEADDR, &enabled, sizeof(enabled));
#ifdef SO_NOSIGPIPE
::setsockopt(listener.get(), SOL_SOCKET, SO_NOSIGPIPE, &enabled, sizeof(enabled));
#endif
sockaddr_in address{};
address.sin_family = AF_INET;
address.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
address.sin_port = htons(port);
if (::bind(listener.get(), reinterpret_cast<const sockaddr *>(&address), sizeof(address)) <
0) {
const int error = errno;
return std::unexpected(
SocketError{systemError("Could not bind OAuth listener", error), error});
}
if (::listen(listener.get(), 4) < 0) {
const int error = errno;
return std::unexpected(
SocketError{systemError("Could not listen for OAuth callback", error), error});
}
socklen_t address_size = sizeof(address);
if (::getsockname(listener.get(), reinterpret_cast<sockaddr *>(&address), &address_size) <
0) {
const int error = errno;
return std::unexpected(
SocketError{systemError("Could not determine OAuth callback port", error), error});
}
return BoundListener{std::move(listener), ntohs(address.sin_port)};
}
// select() lets the entire login attempt share one absolute deadline. EINTR
// is retried without extending that deadline.
std::expected<FileDescriptor, std::string> acceptBefore(const int listener, const std::chrono::steady_clock::time_point deadline) {
while (true) {
const auto now = std::chrono::steady_clock::now();
if (now >= deadline) {
return std::unexpected("Timed out waiting for the OAuth browser callback");
}
const auto remaining =
std::chrono::duration_cast<std::chrono::microseconds>(deadline - now);
timeval timeout{static_cast<time_t>(remaining.count() / 1'000'000),
static_cast<suseconds_t>(remaining.count() % 1'000'000)};
fd_set readable;
FD_ZERO(&readable);
FD_SET(listener, &readable);
const int selected = ::select(listener + 1, &readable, nullptr, nullptr, &timeout);
if (selected == 0) {
return std::unexpected("Timed out waiting for the OAuth browser callback");
}
if (selected < 0) {
if (errno == EINTR) {
continue;
}
return std::unexpected(systemError("Could not wait for OAuth callback"));
}
FileDescriptor connection(::accept(listener, nullptr, nullptr));
if (!connection) {
if (errno == EINTR) {
continue;
}
return std::unexpected(systemError("Could not accept OAuth callback"));
}
#ifdef SO_NOSIGPIPE
const int enabled = 1;
::setsockopt(connection.get(), SOL_SOCKET, SO_NOSIGPIPE, &enabled, sizeof(enabled));
#endif
return connection;
}
}
// Read only through the end of the HTTP headers. The callback is a GET with
// no body, and the fixed size limit prevents a local peer from growing memory
// without bound or holding finish() forever.
std::expected<std::string, std::string> readHttpRequest(const int connection, const std::chrono::steady_clock::time_point deadline) {
std::string request;
std::array<char, 4096> buffer{};
while (request.find("\r\n\r\n") == std::string::npos) {
const auto now = std::chrono::steady_clock::now();
if (now >= deadline) {
return std::unexpected("Timed out reading the OAuth browser callback");
}
const auto remaining =
std::chrono::duration_cast<std::chrono::microseconds>(deadline - now);
timeval timeout{static_cast<time_t>(remaining.count() / 1'000'000),
static_cast<suseconds_t>(remaining.count() % 1'000'000)};
fd_set readable;
FD_ZERO(&readable);
FD_SET(connection, &readable);
const int selected = ::select(connection + 1, &readable, nullptr, nullptr, &timeout);
if (selected == 0) {
return std::unexpected("Timed out reading the OAuth browser callback");
}
if (selected < 0) {
if (errno == EINTR) {
continue;
}
return std::unexpected(systemError("Could not wait for OAuth callback data"));
}
const ssize_t received = ::recv(connection, buffer.data(), buffer.size(), 0);
if (received == 0) {
return std::unexpected("Browser closed the OAuth callback connection early");
}
if (received < 0) {
if (errno == EINTR) {
continue;
}
return std::unexpected(systemError("Could not read OAuth callback"));
}
if (request.size() + static_cast<std::size_t>(received) > maximum_http_header_size) {
return std::unexpected("OAuth callback headers are too large");
}
request.append(buffer.data(), static_cast<std::size_t>(received));
}
return request;
}
struct HttpRequestLine {
std::string method;
std::string target;
};
// We only need method and request-target; a general HTTP parser would add a
// large dependency for a single localhost GET.
std::expected<HttpRequestLine, std::string> parseHttpRequestLine(const std::string_view request) {
const std::size_t line_end = request.find("\r\n");
const std::string_view line = request.substr(0, line_end);
const std::size_t first_space = line.find(' ');
const std::size_t second_space = first_space == std::string_view::npos
? std::string_view::npos
: line.find(' ', first_space + 1);
if (first_space == std::string_view::npos || second_space == std::string_view::npos ||
line.substr(second_space + 1).find(' ') != std::string_view::npos ||
!line.substr(second_space + 1).starts_with("HTTP/")) {
return std::unexpected("Malformed OAuth callback request line");
}
return HttpRequestLine{
std::string(line.substr(0, first_space)),
std::string(line.substr(first_space + 1, second_space - first_space - 1))};
}
// Always close the connection so browser keep-alive cannot leave the login
// waiting on an already-handled request. SIGPIPE is suppressed below because
// browsers are allowed to close the tab before reading our final page.
void sendHttpResponse(const int connection, const int status, const std::string_view reason, const std::string_view body) {
const std::string response =
"HTTP/1.1 " + std::to_string(status) + " " + std::string(reason) +
"\r\nContent-Type: text/plain; charset=utf-8\r\nCache-Control: "
"no-store\r\nConnection: "
"close\r\nContent-Length: " +
std::to_string(body.size()) + "\r\n\r\n" + std::string(body);
std::size_t sent = 0;
while (sent < response.size()) {
#ifdef MSG_NOSIGNAL
const ssize_t count =
::send(connection, response.data() + sent, response.size() - sent, MSG_NOSIGNAL);
#else
const ssize_t count =
::send(connection, response.data() + sent, response.size() - sent, 0);
#endif
if (count < 0 && errno == EINTR) {
continue;
}
if (count <= 0) {
return;
}
sent += static_cast<std::size_t>(count);
}
}
struct HttpResponse {
long status = 0;
std::string body;
std::string callback_error;
};
// libcurl invokes this through a C ABI. Catch every exception here so none
// can cross that boundary, and cap the response before appending it.
std::size_t receiveHttpBody(char *data, const std::size_t size, const std::size_t count, void *user_data) {
const std::size_t byte_count = size * count;
auto &response = *static_cast<HttpResponse *>(user_data);
try {
if (byte_count > maximum_token_response_size -
std::min(response.body.size(), maximum_token_response_size)) {
response.callback_error = "OAuth token response is too large";
return 0;
}
response.body.append(data, byte_count);
} catch (...) {
response.callback_error = "Could not store OAuth token response";
return 0;
}
return byte_count;
}
// Shared token-endpoint transport. The initial code exchange is form encoded,
// while refresh follows Codex and sends JSON, so content_type stays explicit.
std::expected<HttpResponse, std::string> postBody(const std::string &url, const std::string &body, const std::string &content_type, const long timeout_seconds) {
if (timeout_seconds <= 0) {
return std::unexpected("OAuth token request timeout must be greater than zero");
}
static const CURLcode curl_initialization = curl_global_init(CURL_GLOBAL_DEFAULT);
if (curl_initialization != CURLE_OK) {
return std::unexpected(std::string("Could not initialize HTTP client: ") +
curl_easy_strerror(curl_initialization));
}
std::unique_ptr<CURL, decltype(&curl_easy_cleanup)> curl(curl_easy_init(),
&curl_easy_cleanup);
if (!curl) {
return std::unexpected("Could not create OAuth token request");
}
HttpResponse response;
std::array<char, CURL_ERROR_SIZE> curl_error{};
curl_slist *raw_headers = curl_slist_append(nullptr, content_type.c_str());
if (raw_headers == nullptr) {
return std::unexpected("Could not allocate OAuth request headers");
}
std::unique_ptr<curl_slist, decltype(&curl_slist_free_all)> headers(raw_headers,
&curl_slist_free_all);
const auto setOption = [&curl](const CURLoption option, const auto value) {
return curl_easy_setopt(curl.get(), option, value) == CURLE_OK;
};
if (!setOption(CURLOPT_URL, url.c_str()) || !setOption(CURLOPT_HTTPHEADER, headers.get()) ||
!setOption(CURLOPT_POST, 1L) || !setOption(CURLOPT_POSTFIELDS, body.data()) ||
!setOption(CURLOPT_POSTFIELDSIZE_LARGE, static_cast<curl_off_t>(body.size())) ||
!setOption(CURLOPT_WRITEFUNCTION, &receiveHttpBody) ||
!setOption(CURLOPT_WRITEDATA, &response) ||
!setOption(CURLOPT_ERRORBUFFER, curl_error.data()) ||
!setOption(CURLOPT_USERAGENT, "microcodex") ||
!setOption(CURLOPT_CONNECTTIMEOUT, timeout_seconds) ||
!setOption(CURLOPT_TIMEOUT, timeout_seconds) || !setOption(CURLOPT_NOSIGNAL, 1L)) {
return std::unexpected("Could not configure OAuth token request");
}
const CURLcode result = curl_easy_perform(curl.get());
curl_easy_getinfo(curl.get(), CURLINFO_RESPONSE_CODE, &response.status);
if (!response.callback_error.empty()) {
return std::unexpected(response.callback_error);
}
if (result != CURLE_OK) {
const std::string detail = curl_error[0] == '\0'
? std::string(curl_easy_strerror(result))
: std::string(curl_error.data());
return std::unexpected("OAuth token request failed: " + detail);
}
return response;
}
// Extract only the documented error fields. Never echo a successful token
// response or arbitrary body because those may contain credentials.
std::string tokenEndpointError(const std::string_view body, const long status) {
auto description = microcodex::json::jsonStringMember(body, "error_description");
if (description && description.value()) {
return "OAuth token endpoint returned HTTP " + std::to_string(status) + ": " +
description.value().value();
}
auto code = microcodex::json::jsonStringMember(body, "error");
if (code && code.value()) {
return "OAuth token endpoint returned HTTP " + std::to_string(status) + ": " +
code.value().value();
}
return "OAuth token endpoint returned HTTP " + std::to_string(status);
}
// Refresh responses may omit a token or explicitly return JSON null. Treat
// both forms as "keep the previous token" while rejecting other JSON types.
std::expected<std::optional<std::string>, std::string> optionalStringOrNull(const std::string_view object, const std::string_view name) {
auto member = microcodex::json::findJsonMember(object, name);
if (!member) {
return std::unexpected(member.error());
}
if (!member.value() || member.value().value() == "null") {
return std::optional<std::string>{};
}
auto value = microcodex::json::string(member.value().value());
if (!value) {
return std::unexpected("JSON member '" + std::string(name) +
"' is not a string or null");
}
return std::optional<std::string>{std::move(value.value())};
}
// The token endpoint authenticates the JWT; this routine only decodes its
// payload to obtain routing metadata for CodexApi. OpenAI namespaces these
// claims under "https://api.openai.com/auth" rather than at the JWT root.
std::expected<std::string, std::string> accountIdFromIdToken(const std::string_view id_token) {
const std::size_t first_dot = id_token.find('.');
const std::size_t second_dot = first_dot == std::string_view::npos
? std::string_view::npos
: id_token.find('.', first_dot + 1);
if (first_dot == std::string_view::npos || second_dot == std::string_view::npos ||
id_token.find('.', second_dot + 1) != std::string_view::npos || first_dot == 0 ||
second_dot == first_dot + 1 || second_dot + 1 == id_token.size()) {
return std::unexpected("OAuth token endpoint returned a malformed ID token");
}
auto payload = base64UrlDecode(id_token.substr(first_dot + 1, second_dot - first_dot - 1));
if (!payload) {
return std::unexpected("Could not decode OAuth ID token: " + payload.error());
}
auto auth =
microcodex::json::findJsonMember(payload.value(), "https://api.openai.com/auth");
if (!auth) {
return std::unexpected("Could not parse OAuth ID token: " + auth.error());
}
if (!auth.value()) {
return std::string{};
}
auto account_id =
microcodex::json::jsonStringMember(auth.value().value(), "chatgpt_account_id");
if (!account_id) {
return std::unexpected("Could not parse OAuth account ID: " + account_id.error());
}
return account_id.value().value_or(std::string{});
}
// Complete RFC 7636: the short-lived browser code is useless without the
// verifier kept inside OAuthLogin. The exact redirect URI must match the one
// sent to /oauth/authorize, including the callback port.
std::expected<microcodex::OAuthCredentials, std::string> exchangeAuthorizationCode(const microcodex::OAuthOptions &options, const std::string_view redirect_uri, const std::string_view code_verifier, const std::string_view code) {
const std::string endpoint = options.issuer + "/oauth/token";
const std::string form = "grant_type=authorization_code&code=" + percentEncode(code) +
"&redirect_uri=" + percentEncode(redirect_uri) +
"&client_id=" + percentEncode(options.client_id) +
"&code_verifier=" + percentEncode(code_verifier);
auto response = postBody(endpoint, form, "Content-Type: application/x-www-form-urlencoded",
options.token_request_timeout_seconds);
if (!response) {
return std::unexpected(response.error());
}
if (response->status < 200 || response->status >= 300) {
return std::unexpected(tokenEndpointError(response->body, response->status));
}
auto access_token = microcodex::json::requiredJsonString(response->body, "access_token");
auto id_token = microcodex::json::requiredJsonString(response->body, "id_token");
auto refresh_token = microcodex::json::requiredJsonString(response->body, "refresh_token");
if (!access_token || !id_token || !refresh_token) {
return std::unexpected("OAuth token endpoint returned an incomplete token set");
}
if (access_token->empty() || id_token->empty() || refresh_token->empty()) {
return std::unexpected("OAuth token endpoint returned an empty token");
}
auto account_id = accountIdFromIdToken(id_token.value());
if (!account_id) {
return std::unexpected(account_id.error());
}
return microcodex::OAuthCredentials{
std::move(access_token.value()), std::move(account_id.value()),
std::move(id_token.value()), std::move(refresh_token.value())};
}
std::expected<void, std::string> validateOAuthOptions(const microcodex::OAuthOptions &options) {
if (options.issuer.empty() || options.client_id.empty() || options.originator.empty()) {
return std::unexpected("OAuth issuer, client ID, and originator cannot be empty");
}
if (containsNewline(options.issuer) || containsNewline(options.client_id) ||
containsNewline(options.originator)) {
return std::unexpected("OAuth options cannot contain a newline");
}
if ((!options.issuer.starts_with("https://") && !options.issuer.starts_with("http://")) ||
options.issuer.find_first_of("?#") != std::string::npos) {
return std::unexpected("OAuth issuer must be an HTTP(S) origin "
"without query or fragment");
}
if (options.token_request_timeout_seconds <= 0) {
return std::unexpected("OAuth token request timeout must be greater than zero");
}
return {};
}
std::string normalizedIssuer(std::string issuer) {
while (issuer.size() > std::string_view("https://").size() && issuer.ends_with('/')) {
issuer.pop_back();
}
return issuer;
}
// OpenAI returns the polling interval as a JSON string. Parse the complete
// value and cap it at the lifetime of a device login so a malformed response
// cannot make the CLI sleep for an unreasonable amount of time.
std::expected<std::chrono::seconds, std::string> parsePollingInterval(const std::string_view text) {
std::chrono::seconds::rep seconds = 0;
const auto [end, error] = std::from_chars(text.data(), text.data() + text.size(), seconds);
if (error != std::errc{} || end != text.data() + text.size() || seconds < 0 ||
seconds > std::chrono::minutes(15).count() * 60) {
return std::unexpected("Device authorization returned an invalid polling interval");
}
return std::chrono::seconds(seconds);
}
// Keep these parameters in lockstep with the upstream Codex login flow. The
// offline_access scope is what permits refreshes in later sessions.
std::string buildAuthorizationUrl(const microcodex::OAuthOptions &options, const std::string_view redirect_uri, const std::string_view code_challenge, const std::string_view state) {
const std::array<std::pair<std::string_view, std::string_view>, 10> parameters{{
{"response_type", "code"},
{"client_id", options.client_id},
{"redirect_uri", redirect_uri},
{"scope", "openid profile email offline_access api.connectors.read "
"api.connectors.invoke"},
{"code_challenge", code_challenge},
{"code_challenge_method", "S256"},
{"id_token_add_organizations", "true"},
{"codex_cli_simplified_flow", "true"},
{"state", state},
{"originator", options.originator},
}};
std::string url = options.issuer + "/oauth/authorize?";
for (std::size_t position = 0; position < parameters.size(); ++position) {
if (position != 0) {
url += '&';
}
url += parameters[position].first;
url += '=';
url += percentEncode(parameters[position].second);
}
return url;
}
std::expected<void, std::string> validateCredentials(const microcodex::OAuthCredentials &credentials) {
if (credentials.access_token.empty() || credentials.id_token.empty() ||
credentials.refresh_token.empty()) {
return std::unexpected("OAuth access token, ID token, and refresh "
"token cannot be empty");
}
return {};
}
std::string utcTimestamp() {
const std::time_t now = std::time(nullptr);
std::tm utc{};
gmtime_r(&now, &utc);
std::array<char, 32> text{};
std::strftime(text.data(), text.size(), "%Y-%m-%dT%H:%M:%SZ", &utc);
return text.data();
}
// Write beside the final file, fsync it, then rename it into place. Because
// rename is atomic on one filesystem, a crash can leave either the old or
// new auth.json but never a half-written credential file. O_EXCL plus 0600
// also prevents following a pre-created temporary-file symlink.
std::expected<void, std::string> writeFileAtomically(const std::filesystem::path &path, const std::string_view contents) {
std::error_code filesystem_error;
const std::filesystem::path parent = path.has_parent_path() ? path.parent_path() : ".";
const bool created = std::filesystem::create_directories(parent, filesystem_error);
if (filesystem_error) {
return std::unexpected("Could not create OAuth credentials directory: " +
filesystem_error.message());
}
if (created) {
std::filesystem::permissions(parent, std::filesystem::perms::owner_all,
std::filesystem::perm_options::replace, filesystem_error);
if (filesystem_error) {
return std::unexpected("Could not protect OAuth credentials directory: " +
filesystem_error.message());
}
}
std::filesystem::path temporary;
FileDescriptor file;
for (int attempt = 0; attempt < 10; ++attempt) {
temporary = path.string() + ".tmp." + randomBase64Url(9);
file.reset(::open(temporary.c_str(), O_WRONLY | O_CREAT | O_EXCL, 0600));
if (file) {
break;
}
if (errno != EEXIST) {
return std::unexpected(systemError("Could not create temporary credentials file"));
}
}
if (!file) {
return std::unexpected("Could not choose a temporary credentials filename");
}
const auto discardTemporary = [&temporary]() {
std::error_code ignored;
std::filesystem::remove(temporary, ignored);
};
std::size_t written = 0;
while (written < contents.size()) {
const ssize_t count =
::write(file.get(), contents.data() + written, contents.size() - written);
if (count < 0 && errno == EINTR) {
continue;
}
if (count <= 0) {
const std::string error = systemError("Could not write OAuth credentials");
file.reset();
discardTemporary();
return std::unexpected(error);
}
written += static_cast<std::size_t>(count);
}
if (::fsync(file.get()) < 0) {
const std::string error = systemError("Could not flush OAuth credentials");
file.reset();
discardTemporary();
return std::unexpected(error);
}
file.reset();
if (::rename(temporary.c_str(), path.c_str()) < 0) {
const std::string error = systemError("Could not install OAuth credentials file");
discardTemporary();
return std::unexpected(error);
}
return {};
}
// auth.json should be tiny. Bound reads even when the file changes after
// fstat(), so a corrupted or hostile file cannot allocate unlimited memory.
std::expected<std::optional<std::string>, std::string> readSmallFile(const std::filesystem::path &path) {
FileDescriptor file(::open(path.c_str(), O_RDONLY));
if (!file) {
if (errno == ENOENT) {
return std::optional<std::string>{};
}
return std::unexpected(systemError("Could not open OAuth credentials"));
}
struct stat information{};
if (::fstat(file.get(), &information) < 0) {
return std::unexpected(systemError("Could not inspect OAuth credentials"));
}
if (information.st_size < 0 ||
static_cast<std::uintmax_t>(information.st_size) > maximum_token_response_size) {
return std::unexpected("OAuth credentials file is too large");
}
std::string contents;
contents.reserve(static_cast<std::size_t>(information.st_size));
std::array<char, 4096> buffer{};
while (true) {
const ssize_t count = ::read(file.get(), buffer.data(), buffer.size());
if (count == 0) {
break;
}
if (count < 0) {
if (errno == EINTR) {
continue;
}
return std::unexpected(systemError("Could not read OAuth credentials"));
}
if (contents.size() + static_cast<std::size_t>(count) > maximum_token_response_size) {
return std::unexpected("OAuth credentials file is too large");
}
contents.append(buffer.data(), static_cast<std::size_t>(count));
}
return std::optional<std::string>{std::move(contents)};
}
} // namespace
namespace microcodex {
// Begin the remote-friendly login flow. The auth service returns a short
// code for the user and an opaque ID for this process; no localhost listener
// or client-generated PKCE state is needed.
std::expected<OAuthDeviceCode, std::string> startOAuthDeviceLogin(OAuthOptions options) {
options.issuer = normalizedIssuer(std::move(options.issuer));
auto validation = validateOAuthOptions(options);
if (!validation) {
return std::unexpected(validation.error());
}
std::string body = "{\"client_id\":";
json::appendJsonString(body, options.client_id);
body += '}';
auto response = postBody(options.issuer + "/api/accounts/deviceauth/usercode", body,
"Content-Type: application/json",
options.token_request_timeout_seconds);
if (!response) {
return std::unexpected(response.error());
}
// Device authorization is feature-gated by the issuer. A 404 here means
// the flow is unavailable, unlike a 404 from the polling endpoint below.
if (response->status == 404) {
return std::unexpected("Device authorization is not enabled for this OAuth issuer");
}
if (response->status < 200 || response->status >= 300) {
return std::unexpected("Device authorization request returned HTTP " +
std::to_string(response->status));
}
// Codex has received both user_code and the older usercode spelling, so
// accept either while keeping the current spelling in outgoing requests.
auto device_auth_id = json::requiredJsonString(response->body, "device_auth_id");
auto user_code = json::requiredJsonString(response->body, "user_code");
if (!user_code) {
user_code = json::requiredJsonString(response->body, "usercode");
}
auto interval_text = json::requiredJsonString(response->body, "interval");
if (!device_auth_id || !user_code || !interval_text) {
return std::unexpected("Device authorization returned an incomplete response");
}
auto interval = parsePollingInterval(*interval_text);
if (!interval) {
return std::unexpected(interval.error());
}
return OAuthDeviceCode{options.issuer + "/codex/device", std::move(*user_code),
std::move(*device_auth_id), *interval, std::move(options)};
}
// Wait for the browser-side authorization, then feed the returned code and
// verifier into the same token exchange used by localhost browser login.
std::expected<OAuthCredentials, std::string> finishOAuthDeviceLogin(
const OAuthDeviceCode &login, const std::chrono::seconds timeout) {
if (timeout <= std::chrono::seconds::zero()) {
return std::unexpected("Device authorization timeout must be greater than zero");
}
OAuthOptions options = login.options;
options.issuer = normalizedIssuer(std::move(options.issuer));
auto validation = validateOAuthOptions(options);
if (!validation) {
return std::unexpected(validation.error());
}
std::string body = "{\"device_auth_id\":";
json::appendJsonString(body, login.device_auth_id);
body += ",\"user_code\":";
json::appendJsonString(body, login.user_code);
body += '}';
const auto deadline = std::chrono::steady_clock::now() + timeout;
while (std::chrono::steady_clock::now() < deadline) {
auto response = postBody(options.issuer + "/api/accounts/deviceauth/token", body,
"Content-Type: application/json",
options.token_request_timeout_seconds);
if (!response) {
return std::unexpected(response.error());
}
// OpenAI uses both 403 and 404 to mean that the user has not finished
// in the browser yet. Respect the advertised interval, but never
// sleep past the one absolute deadline shared by all poll attempts.
if (response->status == 403 || response->status == 404) {
const auto now = std::chrono::steady_clock::now();
if (now >= deadline) {
break;
}
const auto remaining =
std::chrono::duration_cast<std::chrono::seconds>(deadline - now);
::sleep(static_cast<unsigned>(std::min(login.interval, remaining).count()));
continue;
}
if (response->status < 200 || response->status >= 300) {
return std::unexpected("Device authorization failed with HTTP " +
std::to_string(response->status));
}
// In this flow the auth service creates the PKCE pair and returns the
// verifier only after approval. Its hosted callback URI replaces the
// localhost redirect used by OAuthLogin.
auto code = json::requiredJsonString(response->body, "authorization_code");
auto verifier = json::requiredJsonString(response->body, "code_verifier");
if (!code || !verifier) {
return std::unexpected("Device authorization returned an incomplete response");
}
return exchangeAuthorizationCode(options, options.issuer + "/deviceauth/callback",
*verifier, *code);