fix(auth): distinguish safe ChatGPT token verification failures [skip ci]

Replace the generic JWT decode rejection with static signature, issuer,
audience, expiry, not-before and required-claim diagnostics. Claim names
are restricted to a fixed list; raw library errors and token values are
never interpolated. Preserve all signature/time/issuer/audience/nonce and
account checks, registration activation and plan-scope admission.

Update the existing signed identity case to pin the diagnostics, including
future not-before rejection. English/Chinese provider guidance separates
device-clock advice from plan permission and asks only for safe error text.

Verified: five existing SIWC cases passed; the same frozen batch's receipt
API case passed (six total, zero failures). Ordinary native24 macOS ARM64
CLI built with all 4,249 inputs unchanged and no test-support feature.
Windows report's exact rejected check remains unknown; no Windows fix,
package/release or live ChatGPT account acceptance is claimed.

Refs #7203, CW-7800.
This commit is contained in:
CodeWhale Bot committed 2026-11-03 02:17:08 -08:00
1 parent a1adf81681
commit 8a37a91312
4 files changed
+75 -20

No files matched your search

+64 -19
View File
@@ -20,7 +20,9 @@ use anyhow::{Context, Result, bail};
use base64::Engine as _;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use codewhale_config::ExternalCredentialReadGrant;
use jsonwebtoken::{Algorithm, DecodingKey, Validation, decode, decode_header, jwk::JwkSet};
use jsonwebtoken::{
Algorithm, DecodingKey, Validation, decode, decode_header, errors::ErrorKind, jwk::JwkSet,
};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use sha2::{Digest, Sha256};
@@ -2579,8 +2581,39 @@ fn verify_chatgpt_jwt<T: serde::de::DeserializeOwned + Clone>(
validation.validate_nbf = true;
validation.leeway = 0;
let claims = decode::<Value>(token, &key, &validation)
.map_err(|_| {
anyhow::anyhow!("ChatGPT token signature or identity claims failed verification")
.map_err(|error| match error.kind() {
ErrorKind::InvalidSignature => {
anyhow::anyhow!("ChatGPT token signature verification failed")
}
ErrorKind::ExpiredSignature => anyhow::anyhow!(
"ChatGPT token has expired; check your device date and time, then sign in again"
),
ErrorKind::ImmatureSignature => anyhow::anyhow!(
"ChatGPT token is not valid yet; synchronize your device clock, then sign in again"
),
ErrorKind::InvalidIssuer => {
anyhow::anyhow!("ChatGPT token issuer does not match the expected issuer")
}
ErrorKind::InvalidAudience => {
anyhow::anyhow!("ChatGPT token audience does not match this registration")
}
ErrorKind::InvalidSubject => {
anyhow::anyhow!("ChatGPT token subject does not match the expected account")
}
ErrorKind::MissingRequiredClaim(claim)
if ["exp", "iss", "aud", "sub"].contains(&claim.as_str()) =>
{
anyhow::anyhow!("ChatGPT token is missing required {claim} claim")
}
ErrorKind::InvalidClaimFormat(claim)
if ["exp", "iss", "aud", "sub", "nbf"].contains(&claim.as_str()) =>
{
anyhow::anyhow!("ChatGPT token has an invalid {claim} claim format")
}
ErrorKind::InvalidAlgorithm | ErrorKind::UnsupportedAlgorithm => anyhow::anyhow!(
"ChatGPT token signature algorithm does not match the verification key"
),
_ => anyhow::anyhow!("ChatGPT token format or identity claims failed verification"),
})?
.claims;
let exact_audience = claims.get("aud").is_some_and(|aud| {
@@ -8328,35 +8361,47 @@ consent_version = 1
let claims = signer.id_claims();
let signed = signer.token(claims.clone());
assert_eq!(verify(&signed).unwrap().subject, "test-sub");
for (field, wrong) in [
("iss", Value::from("https://other.invalid")),
("aud", Value::from("oaiapp_other")),
for (field, wrong, expected) in [
("iss", Value::from("https://other.invalid"), "issuer"),
("aud", Value::from("oaiapp_other"), "audience"),
(
"aud",
serde_json::json!(["oaiapp_codewhale_test", "another-audience"]),
"audience",
),
("exp", Value::from(1)),
("nonce", Value::from("another-nonce")),
("sub", Value::from("another-subject")),
("sub", Value::from("")),
("exp", Value::from(1), "expired"),
(
"nbf",
Value::from(now_unix_secs().unwrap() + 3600),
"not valid yet",
),
("nonce", Value::from("another-nonce"), "nonce"),
("sub", Value::from("another-subject"), "different account"),
("sub", Value::from(""), "no subject"),
] {
let mut changed = claims.clone();
changed[field] = wrong;
assert!(
verify(&signer.token(changed)).is_err(),
"accepted invalid {field}"
);
let error = verify(&signer.token(changed)).unwrap_err().to_string();
assert!(error.contains(expected), "invalid {field}: {error}");
}
for required in ["iss", "aud", "exp", "sub", "nonce"] {
let mut changed = claims.clone();
changed.as_object_mut().unwrap().remove(required);
assert!(
verify(&signer.token(changed)).is_err(),
"accepted missing {required}"
);
let error = verify(&signer.token(changed)).unwrap_err().to_string();
let expected = if required == "nonce" {
"nonce did not match".to_string()
} else {
format!("missing required {required} claim")
};
assert!(error.contains(&expected), "missing {required}: {error}");
}
let other_signer = SiwcSigningFixture::new();
assert!(verify(&other_signer.token(claims.clone())).is_err());
assert!(
verify(&other_signer.token(claims.clone()))
.unwrap_err()
.to_string()
.contains("signature verification failed")
);
let parts: Vec<_> = signed.split('.').collect();
let bad_payload = URL_SAFE_NO_PAD.encode(br#"{"sub":"attacker"}"#);
assert!(verify(&format!("{}.{}.{}", parts[0], bad_payload, parts[2])).is_err());
+8
View File
@@ -238,6 +238,14 @@ inference; choose another provider explicitly if you want another billing path.
This experimental implementation retains one active registration. A picker
for multiple saved ChatGPT accounts is not implemented yet.
If the browser succeeds but token verification fails, the error identifies the
failed check: signature, issuer, audience, expiry, not-before time, or a
missing required claim. For an expired or not-yet-valid token, synchronize
your device date and time and start a new sign-in. Other verification failures
are not evidence of a plan limitation. Report the error text and Codewhale
version; never share tokens, callback codes, credential files or decoded claims.
A failed verification does not activate the new registration.
Model discovery uses your granted bearer token at
`GET https://api.openai.com/v1/models`; inference uses the public
`POST https://api.openai.com/v1/responses` endpoint. The model picker keeps
+1 -1
View File
@@ -830,7 +830,7 @@ owner = ".github/workflows/release-artifacts.yml"
[[feature]]
id = "chatgpt-plan-sign-in"
name = "Sign in with ChatGPT"
summary = "Uses official OAuth and the selected account catalog for eligible ChatGPT plan requests in the local OSS app."
summary = "Uses official OAuth and the selected account catalog for eligible ChatGPT plan requests, with distinct token-verification errors."
status = "experimental"
surfaces = { tui = "preview", api = "partial", web = "none" }
since = "0.10.1"
+2
View File
@@ -107,6 +107,8 @@ codewhale --provider openai-codex
`codewhale auth status --provider openai-codex` 和 `codewhale auth list` 会在 ID 令牌含有邮箱时显示账户标签;套餐和工作区信息只在相应字段存在时显示。标签用于识别账户,不能证明套餐权限;没有邮箱标签也不代表已经退出登录。状态检查只读取本地凭据,不向签发方发起请求:访问令牌已过期但仍存有刷新令牌时,会被视为结构上可用,这不保证续期一定成功。登录成功后,会显示已知的账户标签,并说明是否替换了先前的登录。
浏览器登录成功但令牌验证失败时,错误会区分签名、签发方、受众、过期时间、生效时间或缺少必需声明。令牌已过期或尚未生效时,请同步设备日期和时间后重新登录。其他验证失败不能证明套餐不符合条件。反馈时只提供错误文本和 Codewhale 版本,不要分享令牌、回调授权码、凭据文件或解码后的声明。验证失败不会激活新的注册。
模型目录使用已获授权的令牌请求 `GET https://api.openai.com/v1/models`;推理使用公共端点 `POST https://api.openai.com/v1/responses`。选择器保留官方显示名称和顺序,仅列出 `visibility: list` 的模型。目录不含凭据,并按已验证的签发方、签发的客户端 ID 和账户主体隔离;模型列表不能证明上下文窗口或思考档位。目录缺失、无效或超过 24 小时时,选择器不会列出任何账户模型。Codex CLI 导入令牌和旧版进程令牌变量不能授权这条官方路由。
符合条件的请求消耗 ChatGPT 套餐额度或点数。Plus 的五小时额度在各应用之间共享,不会为每个应用单独分配;这项五小时限制不适用于 Pro。应用还可能有各自的使用上限。可在 [ChatGPT 设置 → 使用情况](https://chatgpt.com/settings/usage) 查看额度和授权。额度耗尽时,Codewhale 不会自动换用 API 密钥或其他提供商。