commit 49f028850694dfc06949b1cf6b18d04147883de6
Author: Coder Agent <coder@agents.omni>
Date: Sat Apr 11 18:31:23 2026
agent auth: encode Anthropic OAuth redirect URI
Fix Claude OAuth URL generation to use percent-encoded query params.
Previously we built the Anthropic authorize URL by string concat.
That left redirect_uri as raw https://... in the query string.
On iOS Telegram /login flows, URL rewriting could mutate nested
https:// to x-safari-https://, causing Anthropic to reject auth.
Now Anthropic auth URL generation uses URI.renderSimpleQuery,
matching the Codex flow. This keeps redirect_uri encoded.
Also add a regression test to assert redirect_uri is encoded.
Task-Id: t-777
diff --git a/Omni/Agent/Auth.hs b/Omni/Agent/Auth.hs
index e777a71c..2100d990 100644
--- a/Omni/Agent/Auth.hs
+++ b/Omni/Agent/Auth.hs
@@ -81,7 +81,11 @@ test =
Test.unit "credentialsFile returns valid path" <| do
path <- credentialsFile
-- Should end with auth.json
- FilePath.takeFileName path Test.@=? "auth.json"
+ FilePath.takeFileName path Test.@=? "auth.json",
+ Test.unit "Anthropic auth URL encodes redirect URI" <| do
+ let authUrl = buildAnthropicAuthUrl "state-test" "challenge-test"
+ Test.assertBool "redirect_uri should be percent-encoded" ("redirect_uri=https%3A%2F%2Fconsole.anthropic.com%2Foauth%2Fcode%2Fcallback" `Text.isInfixOf` authUrl)
+ Test.assertBool "redirect_uri should not be raw in query string" (not ("redirect_uri=https://console.anthropic.com/oauth/code/callback" `Text.isInfixOf` authUrl))
]
-- | OAuth credentials stored on disk
@@ -139,7 +143,7 @@ redirectUri :: Text
redirectUri = "https://console.anthropic.com/oauth/code/callback"
scopes :: Text
-scopes = "org:create_api_key%20user:profile%20user:inference"
+scopes = "org:create_api_key user:profile user:inference"
-- | OpenAI Codex OAuth constants
codexClientId :: Text
@@ -218,11 +222,9 @@ base64UrlEncode bs = Text.pack <| go (BS.unpack bs)
i3 = fromIntegral (c .&. 0x3F)
in [idx i0, idx i1, idx i2, idx i3] ++ go rest
--- | Generate Anthropic OAuth URL for non-interactive flow (e.g., Telegram bot)
--- Returns (authUrl, verifier) - caller must store verifier to exchange code later
-generateAnthropicAuthUrl :: IO (Text, Text)
-generateAnthropicAuthUrl = do
- (verifier, challenge) <- generatePKCE
+-- | Build Anthropic OAuth URL with correctly percent-encoded query params.
+buildAnthropicAuthUrl :: Text -> Text -> Text
+buildAnthropicAuthUrl authState challenge =
let params =
[ ("code", "true"),
("client_id", clientId),
@@ -231,10 +233,17 @@ generateAnthropicAuthUrl = do
("scope", scopes),
("code_challenge", challenge),
("code_challenge_method", "S256"),
- ("state", verifier)
+ ("state", authState)
]
- paramStr = Text.intercalate "&" <| map (\(k, v) -> k <> "=" <> v) params
- authUrl = authorizeUrl <> "?" <> paramStr
+ query = URI.renderSimpleQuery False <| map (Bifunctor.bimap TE.encodeUtf8 TE.encodeUtf8) params
+ in authorizeUrl <> "?" <> TE.decodeUtf8 query
+
+-- | Generate Anthropic OAuth URL for non-interactive flow (e.g., Telegram bot)
+-- Returns (authUrl, verifier) - caller must store verifier to exchange code later
+generateAnthropicAuthUrl :: IO (Text, Text)
+generateAnthropicAuthUrl = do
+ (verifier, challenge) <- generatePKCE
+ let authUrl = buildAnthropicAuthUrl verifier challenge
pure (authUrl, verifier)
-- | Exchange Anthropic authorization code for tokens (non-interactive)
@@ -270,18 +279,7 @@ loginAnthropic = do
(verifier, challenge) <- generatePKCE
-- Build auth URL
- let params =
- [ ("code", "true"),
- ("client_id", clientId),
- ("response_type", "code"),
- ("redirect_uri", redirectUri),
- ("scope", scopes),
- ("code_challenge", challenge),
- ("code_challenge_method", "S256"),
- ("state", verifier)
- ]
- paramStr = Text.intercalate "&" <| map (\(k, v) -> k <> "=" <> v) params
- authUrl = authorizeUrl <> "?" <> paramStr
+ let authUrl = buildAnthropicAuthUrl verifier challenge
-- Check if we can read from stdin (must be a terminal for interactive login)
isTerm <- Terminal.queryTerminal PosixIO.stdInput