8
8
9
9
The script should exit with a non-zero code if the user fails to navigate the
10
10
auth flow.
11
+
12
+ To test this script locally without overwriting your existing auth.json file:
13
+
14
+ ```
15
+ rm -rf /tmp/codex_home && mkdir /tmp/codex_home
16
+ CODEX_HOME=/tmp/codex_home python3 codex-rs/login/src/login_with_chatgpt.py
17
+ ```
11
18
"""
12
19
13
20
from __future__ import annotations
23
30
import secrets
24
31
import sys
25
32
import threading
33
+ import time
26
34
import urllib .parse
27
35
import urllib .request
28
36
import webbrowser
29
37
from dataclasses import dataclass
38
+ from typing import Any , Dict # for type hints
30
39
31
40
# Required port for OAuth client.
32
41
REQUIRED_PORT = 1455
@@ -244,12 +253,8 @@ def _exchange_code_for_api_key(self, code: str) -> tuple[AuthBundle, str]:
244
253
if len (access_token_parts ) != 3 :
245
254
raise ValueError ("Invalid access token" )
246
255
247
- id_token_claims = json .loads (
248
- base64 .urlsafe_b64decode (id_token_parts [1 ] + "==" ).decode ("utf-8" )
249
- )
250
- access_token_claims = json .loads (
251
- base64 .urlsafe_b64decode (access_token_parts [1 ] + "==" ).decode ("utf-8" )
252
- )
256
+ id_token_claims = _decode_jwt_segment (id_token_parts [1 ])
257
+ access_token_claims = _decode_jwt_segment (access_token_parts [1 ])
253
258
254
259
token_claims = id_token_claims .get ("https://api.openai.com/auth" , {})
255
260
access_claims = access_token_claims .get ("https://api.openai.com/auth" , {})
@@ -313,7 +318,20 @@ def _exchange_code_for_api_key(self, code: str) -> tuple[AuthBundle, str]:
313
318
}
314
319
success_url = f"{ URL_BASE } /success?{ urllib .parse .urlencode (success_url_query )} "
315
320
316
- # TODO(mbolin): Port maybeRedeemCredits() to Python and call it here.
321
+ # Attempt to redeem complimentary API credits for eligible ChatGPT
322
+ # Plus / Pro subscribers. Any errors are logged but do not interrupt
323
+ # the login flow.
324
+
325
+ try :
326
+ maybe_redeem_credits (
327
+ issuer = self .server .issuer ,
328
+ client_id = self .server .client_id ,
329
+ id_token = token_data .id_token ,
330
+ refresh_token = token_data .refresh_token ,
331
+ codex_home = self .server .codex_home ,
332
+ )
333
+ except Exception as exc : # pragma: no cover – best-effort only
334
+ eprint (f"Unable to redeem ChatGPT subscriber API credits: { exc } " )
317
335
318
336
# Persist refresh_token/id_token for future use (redeem credits etc.)
319
337
last_refresh_str = (
@@ -417,6 +435,163 @@ def auth_url(self) -> str:
417
435
return f"{ self .issuer } /oauth/authorize?" + urllib .parse .urlencode (params )
418
436
419
437
438
+ def maybe_redeem_credits (
439
+ * ,
440
+ issuer : str ,
441
+ client_id : str ,
442
+ id_token : str | None ,
443
+ refresh_token : str ,
444
+ codex_home : str ,
445
+ ) -> None :
446
+ """Attempt to redeem complimentary API credits for ChatGPT subscribers.
447
+
448
+ The operation is best-effort: any error results in a warning being printed
449
+ and the function returning early without raising.
450
+ """
451
+ id_claims : Dict [str , Any ] | None = parse_id_token_claims (id_token or "" )
452
+
453
+ # Refresh expired ID token, if possible
454
+ token_expired = True
455
+ if id_claims and isinstance (id_claims .get ("exp" ), int ):
456
+ token_expired = _current_timestamp_ms () >= int (id_claims ["exp" ]) * 1000
457
+
458
+ if token_expired :
459
+ eprint ("Refreshing credentials..." )
460
+ new_refresh_token : str | None = None
461
+ new_id_token : str | None = None
462
+
463
+ try :
464
+ payload = json .dumps (
465
+ {
466
+ "client_id" : client_id ,
467
+ "grant_type" : "refresh_token" ,
468
+ "refresh_token" : refresh_token ,
469
+ "scope" : "openid profile email" ,
470
+ }
471
+ ).encode ()
472
+
473
+ req = urllib .request .Request (
474
+ url = "https://auth.openai.com/oauth/token" ,
475
+ data = payload ,
476
+ method = "POST" ,
477
+ headers = {"Content-Type" : "application/json" },
478
+ )
479
+
480
+ with urllib .request .urlopen (req ) as resp :
481
+ refresh_data = json .loads (resp .read ().decode ())
482
+ new_id_token = refresh_data .get ("id_token" )
483
+ new_id_claims = parse_id_token_claims (new_id_token or "" )
484
+ new_refresh_token = refresh_data .get ("refresh_token" )
485
+ except Exception as err :
486
+ eprint ("Unable to refresh ID token via token-exchange:" , err )
487
+ return
488
+
489
+ if not new_id_token or not new_refresh_token :
490
+ return
491
+
492
+ # Update auth.json with new tokens.
493
+ try :
494
+ auth_dir = codex_home
495
+ auth_path = os .path .join (auth_dir , "auth.json" )
496
+ with open (auth_path , "r" , encoding = "utf-8" ) as fp :
497
+ existing = json .load (fp )
498
+
499
+ tokens = existing .setdefault ("tokens" , {})
500
+ tokens ["id_token" ] = new_id_token
501
+ # Note this does not touch the access_token?
502
+ tokens ["refresh_token" ] = new_refresh_token
503
+ tokens ["last_refresh" ] = (
504
+ datetime .datetime .now (datetime .timezone .utc )
505
+ .isoformat ()
506
+ .replace ("+00:00" , "Z" )
507
+ )
508
+
509
+ with open (auth_path , "w" , encoding = "utf-8" ) as fp :
510
+ if hasattr (os , "fchmod" ):
511
+ os .fchmod (fp .fileno (), 0o600 )
512
+ json .dump (existing , fp , indent = 2 )
513
+ except Exception as err :
514
+ eprint ("Unable to update refresh token in auth file:" , err )
515
+
516
+ if not new_id_claims :
517
+ # Still couldn't parse claims.
518
+ return
519
+
520
+ id_token = new_id_token
521
+ id_claims = new_id_claims
522
+
523
+ # Done refreshing credentials: now try to redeem credits.
524
+ if not id_token :
525
+ eprint ("No ID token available, cannot redeem credits." )
526
+ return
527
+
528
+ auth_claims = id_claims .get ("https://api.openai.com/auth" , {})
529
+
530
+ # Subscription eligibility check (Plus or Pro, >7 days active)
531
+ sub_start_str = auth_claims .get ("chatgpt_subscription_active_start" )
532
+ if isinstance (sub_start_str , str ):
533
+ try :
534
+ sub_start_ts = datetime .datetime .fromisoformat (sub_start_str .rstrip ("Z" ))
535
+ if datetime .datetime .now (
536
+ datetime .timezone .utc
537
+ ) - sub_start_ts < datetime .timedelta (days = 7 ):
538
+ eprint (
539
+ "Sorry, your subscription must be active for more than 7 days to redeem credits."
540
+ )
541
+ return
542
+ except ValueError :
543
+ # Malformed; ignore
544
+ pass
545
+
546
+ completed_onboarding = bool (auth_claims .get ("completed_platform_onboarding" ))
547
+ is_org_owner = bool (auth_claims .get ("is_org_owner" ))
548
+ needs_setup = not completed_onboarding and is_org_owner
549
+ plan_type = auth_claims .get ("chatgpt_plan_type" )
550
+
551
+ if needs_setup or plan_type not in {"plus" , "pro" }:
552
+ eprint ("Only users with Plus or Pro subscriptions can redeem free API credits." )
553
+ return
554
+
555
+ api_host = (
556
+ "https://api.openai.com"
557
+ if issuer == "https://auth.openai.com"
558
+ else "https://api.openai.org"
559
+ )
560
+
561
+ try :
562
+ redeem_payload = json .dumps ({"id_token" : id_token }).encode ()
563
+ req = urllib .request .Request (
564
+ url = f"{ api_host } /v1/billing/redeem_credits" ,
565
+ data = redeem_payload ,
566
+ method = "POST" ,
567
+ headers = {"Content-Type" : "application/json" },
568
+ )
569
+
570
+ with urllib .request .urlopen (req ) as resp :
571
+ redeem_data = json .loads (resp .read ().decode ())
572
+
573
+ granted = redeem_data .get ("granted_chatgpt_subscriber_api_credits" , 0 )
574
+ if granted and granted > 0 :
575
+ eprint (
576
+ f"""Thanks for being a ChatGPT { 'Plus' if plan_type == 'plus' else 'Pro' } subscriber!
577
+ If you haven't already redeemed, you should receive { '$5' if plan_type == 'plus' else '$50' } in API credits.
578
+
579
+ Credits: https://platform.openai.com/settings/organization/billing/credit-grants
580
+ More info: https://help.openai.com/en/articles/11381614""" ,
581
+ )
582
+ else :
583
+ eprint (
584
+ f"""It looks like no credits were granted:
585
+
586
+ { json .dumps (redeem_data , indent = 2 )}
587
+
588
+ Credits: https://platform.openai.com/settings/organization/billing/credit-grants
589
+ More info: https://help.openai.com/en/articles/11381614"""
590
+ )
591
+ except Exception as err :
592
+ eprint ("Credit redemption request failed:" , err )
593
+
594
+
420
595
def _generate_pkce () -> PkceCodes :
421
596
"""Generate PKCE *code_verifier* and *code_challenge* (S256)."""
422
597
code_verifier = secrets .token_hex (64 )
@@ -429,6 +604,45 @@ def eprint(*args, **kwargs) -> None:
429
604
print (* args , file = sys .stderr , ** kwargs )
430
605
431
606
607
+ # Parse ID-token claims (if provided)
608
+ #
609
+ # interface IDTokenClaims {
610
+ # "exp": number; // specifically, an int
611
+ # "https://api.openai.com/auth": {
612
+ # organization_id: string;
613
+ # project_id: string;
614
+ # completed_platform_onboarding: boolean;
615
+ # is_org_owner: boolean;
616
+ # chatgpt_subscription_active_start: string;
617
+ # chatgpt_subscription_active_until: string;
618
+ # chatgpt_plan_type: string;
619
+ # };
620
+ # }
621
+ def parse_id_token_claims (id_token : str ) -> Dict [str , Any ] | None :
622
+ if id_token :
623
+ parts = id_token .split ("." )
624
+ if len (parts ) == 3 :
625
+ return _decode_jwt_segment (parts [1 ])
626
+ return None
627
+
628
+
629
+ def _decode_jwt_segment (segment : str ) -> Dict [str , Any ]:
630
+ """Return the decoded JSON payload from a JWT segment.
631
+
632
+ Adds required padding for urlsafe_b64decode.
633
+ """
634
+ padded = segment + "=" * (- len (segment ) % 4 )
635
+ try :
636
+ data = base64 .urlsafe_b64decode (padded .encode ())
637
+ return json .loads (data .decode ())
638
+ except Exception :
639
+ return {}
640
+
641
+
642
+ def _current_timestamp_ms () -> int :
643
+ return int (time .time () * 1000 )
644
+
645
+
432
646
LOGIN_SUCCESS_HTML = """<!DOCTYPE html>
433
647
<html lang="en">
434
648
<head>
0 commit comments