88
99The script should exit with a non-zero code if the user fails to navigate the
1010auth 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+ ```
1118"""
1219
1320from __future__ import annotations
2330import secrets
2431import sys
2532import threading
33+ import time
2634import urllib .parse
2735import urllib .request
2836import webbrowser
2937from dataclasses import dataclass
38+ from typing import Any , Dict # for type hints
3039
3140# Required port for OAuth client.
3241REQUIRED_PORT = 1455
@@ -244,12 +253,8 @@ def _exchange_code_for_api_key(self, code: str) -> tuple[AuthBundle, str]:
244253 if len (access_token_parts ) != 3 :
245254 raise ValueError ("Invalid access token" )
246255
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 ])
253258
254259 token_claims = id_token_claims .get ("https://api.openai.com/auth" , {})
255260 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]:
313318 }
314319 success_url = f"{ URL_BASE } /success?{ urllib .parse .urlencode (success_url_query )} "
315320
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 } " )
317335
318336 # Persist refresh_token/id_token for future use (redeem credits etc.)
319337 last_refresh_str = (
@@ -417,6 +435,163 @@ def auth_url(self) -> str:
417435 return f"{ self .issuer } /oauth/authorize?" + urllib .parse .urlencode (params )
418436
419437
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+
420595def _generate_pkce () -> PkceCodes :
421596 """Generate PKCE *code_verifier* and *code_challenge* (S256)."""
422597 code_verifier = secrets .token_hex (64 )
@@ -429,6 +604,45 @@ def eprint(*args, **kwargs) -> None:
429604 print (* args , file = sys .stderr , ** kwargs )
430605
431606
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+
432646LOGIN_SUCCESS_HTML = """<!DOCTYPE html>
433647<html lang="en">
434648 <head>
0 commit comments