Skip to content

Commit 87f5fe6

Browse files
author
root
committed
Add ChatGPT login flow
1 parent 0f05df3 commit 87f5fe6

File tree

4 files changed

+434
-3
lines changed

4 files changed

+434
-3
lines changed
Lines changed: 337 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,337 @@
1+
package com.tom.rv2ide.artificial.login
2+
3+
import android.app.Activity
4+
import android.content.ActivityNotFoundException
5+
import android.content.Context
6+
import android.content.Intent
7+
import android.net.Uri
8+
import kotlinx.coroutines.Dispatchers
9+
import kotlinx.coroutines.coroutineContext
10+
import kotlinx.coroutines.ensureActive
11+
import kotlinx.coroutines.withContext
12+
import kotlinx.coroutines.withTimeout
13+
import okhttp3.MediaType.Companion.toMediaType
14+
import okhttp3.OkHttpClient
15+
import okhttp3.Request
16+
import okhttp3.RequestBody.Companion.toRequestBody
17+
import org.json.JSONObject
18+
import java.io.BufferedReader
19+
import java.io.IOException
20+
import java.io.InputStreamReader
21+
import java.io.OutputStream
22+
import java.io.OutputStreamWriter
23+
import java.net.BindException
24+
import java.net.InetAddress
25+
import java.net.InetSocketAddress
26+
import java.net.ServerSocket
27+
import java.net.Socket
28+
import java.net.SocketTimeoutException
29+
import java.net.URLEncoder
30+
import java.nio.charset.StandardCharsets
31+
import java.security.MessageDigest
32+
import java.security.SecureRandom
33+
import java.util.Base64
34+
import java.util.concurrent.TimeUnit
35+
36+
class CodexLoginManager {
37+
companion object {
38+
private const val CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
39+
private const val AUTHORIZATION_URL = "https://auth.openai.com/oauth/authorize"
40+
private const val TOKEN_URL = "https://auth.openai.com/oauth/token"
41+
private const val CALLBACK_PATH = "/auth/callback"
42+
private const val LOOPBACK_HOST = "127.0.0.1"
43+
private const val DEFAULT_PORT = 1455
44+
private const val CALLBACK_TIMEOUT_MS = 180_000L
45+
private const val RESPONSE_BODY_CHARSET = "utf-8"
46+
private const val ORIGINATOR_VALUE = "codex_cli_rs"
47+
private val SCOPE_STRING = listOf(
48+
"openid",
49+
"profile",
50+
"email",
51+
"offline_access",
52+
"api.connectors.read",
53+
"api.connectors.invoke"
54+
).joinToString(" ")
55+
}
56+
57+
private val httpClient = OkHttpClient.Builder()
58+
.connectTimeout(30, TimeUnit.SECONDS)
59+
.readTimeout(30, TimeUnit.SECONDS)
60+
.writeTimeout(30, TimeUnit.SECONDS)
61+
.build()
62+
63+
@Throws(IOException::class)
64+
suspend fun loginWithChatGPT(context: Context): String {
65+
val pkce = createPkceCodes()
66+
val state = generateState()
67+
val (serverSocket, actualPort) = createServerSocket()
68+
val redirectUri = "http://$LOOPBACK_HOST:$actualPort$CALLBACK_PATH"
69+
val authUrl = buildAuthorizationUrl(redirectUri, pkce, state)
70+
71+
try {
72+
withContext(Dispatchers.Main) {
73+
openBrowser(context, authUrl)
74+
}
75+
76+
val callback = withTimeout(CALLBACK_TIMEOUT_MS) {
77+
waitForCallback(serverSocket, state)
78+
}
79+
80+
callback.error?.let {
81+
throw IllegalStateException(callback.errorDescription ?: it)
82+
}
83+
84+
val code = callback.code ?: throw IllegalStateException("Missing authorization code from ChatGPT login.")
85+
val tokens = exchangeCodeForTokens(code, redirectUri, pkce)
86+
return exchangeIdTokenForApiKey(tokens.idToken)
87+
} finally {
88+
try {
89+
serverSocket.close()
90+
} catch (_: IOException) {
91+
}
92+
}
93+
}
94+
95+
private fun createServerSocket(): Pair<ServerSocket, Int> {
96+
val server = ServerSocket()
97+
server.reuseAddress = true
98+
try {
99+
server.bind(InetSocketAddress(InetAddress.getByName(LOOPBACK_HOST), DEFAULT_PORT))
100+
} catch (bindException: BindException) {
101+
server.bind(InetSocketAddress(InetAddress.getByName(LOOPBACK_HOST), 0))
102+
}
103+
return server to server.localPort
104+
}
105+
106+
private fun buildAuthorizationUrl(redirectUri: String, pkce: PkceCodes, state: String): String {
107+
val params = listOf(
108+
"response_type" to "code",
109+
"client_id" to CLIENT_ID,
110+
"redirect_uri" to redirectUri,
111+
"scope" to SCOPE_STRING,
112+
"code_challenge" to pkce.codeChallenge,
113+
"code_challenge_method" to "S256",
114+
"id_token_add_organizations" to "true",
115+
"codex_cli_simplified_flow" to "true",
116+
"state" to state,
117+
"originator" to ORIGINATOR_VALUE
118+
)
119+
120+
val encoded = params.joinToString("&") { "${it.first}=${urlEncode(it.second)}" }
121+
return "$AUTHORIZATION_URL?$encoded"
122+
}
123+
124+
private fun urlEncode(value: String): String {
125+
return URLEncoder.encode(value, StandardCharsets.UTF_8.name()).replace("+", "%20")
126+
}
127+
128+
private fun openBrowser(context: Context, authUrl: String) {
129+
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(authUrl))
130+
if (context !is Activity) {
131+
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
132+
}
133+
try {
134+
context.startActivity(intent)
135+
} catch (error: ActivityNotFoundException) {
136+
throw IllegalStateException("No browser found to complete the ChatGPT login.", error)
137+
}
138+
}
139+
140+
private suspend fun waitForCallback(server: ServerSocket, expectedState: String): CallbackResult {
141+
return withContext(Dispatchers.IO) {
142+
server.soTimeout = 1_000
143+
while (true) {
144+
coroutineContext.ensureActive()
145+
try {
146+
val socket = server.accept()
147+
val callback = handleSocket(socket, expectedState)
148+
if (callback != null) {
149+
return@withContext callback
150+
}
151+
} catch (socketTimeout: SocketTimeoutException) {
152+
continue
153+
}
154+
}
155+
}
156+
}
157+
158+
private fun handleSocket(socket: Socket, expectedState: String): CallbackResult? {
159+
socket.use {
160+
val reader = BufferedReader(InputStreamReader(it.getInputStream()))
161+
val requestLine = reader.readLine() ?: return null
162+
val segments = requestLine.split(" ")
163+
if (segments.size < 2) {
164+
drainRequest(reader)
165+
sendHttpResponse(it.getOutputStream(), 400, "Invalid request")
166+
return null
167+
}
168+
169+
val target = segments[1]
170+
val uri = Uri.parse("http://$LOOPBACK_HOST$target")
171+
val path = uri.path ?: ""
172+
drainRequest(reader)
173+
174+
if (path != CALLBACK_PATH) {
175+
sendHttpResponse(it.getOutputStream(), 404, "<html><body><h1>Not found</h1></body></html>")
176+
return null
177+
}
178+
179+
val code = uri.getQueryParameter("code")
180+
val state = uri.getQueryParameter("state")
181+
val error = uri.getQueryParameter("error")
182+
val errorDescription = uri.getQueryParameter("error_description")
183+
val body = when {
184+
error != null -> buildErrorPage(errorDescription ?: "An error occurred")
185+
code != null -> buildSuccessPage()
186+
else -> buildErrorPage("Missing authorization code")
187+
}
188+
sendHttpResponse(it.getOutputStream(), 200, body)
189+
190+
if (error != null) {
191+
return CallbackResult(null, state, error, errorDescription)
192+
}
193+
194+
if (state != expectedState) {
195+
return CallbackResult(null, state, "State mismatch", "The login response does not match the request state.")
196+
}
197+
198+
return CallbackResult(code, state, null, null)
199+
}
200+
}
201+
202+
private fun drainRequest(reader: BufferedReader) {
203+
while (true) {
204+
val line = reader.readLine() ?: break
205+
if (line.isEmpty()) {
206+
break
207+
}
208+
}
209+
}
210+
211+
private fun sendHttpResponse(output: OutputStream, statusCode: Int, body: String) {
212+
val payload = body.toByteArray(StandardCharsets.UTF_8)
213+
OutputStreamWriter(output, StandardCharsets.UTF_8).use { writer ->
214+
writer.write("HTTP/1.1 $statusCode ${statusText(statusCode)}\r\n")
215+
writer.write("Content-Type: text/html; charset=$RESPONSE_BODY_CHARSET\r\n")
216+
writer.write("Content-Length: ${payload.size}\r\n")
217+
writer.write("Connection: close\r\n")
218+
writer.write("\r\n")
219+
writer.flush()
220+
output.write(payload)
221+
output.flush()
222+
}
223+
}
224+
225+
private fun statusText(code: Int): String = when (code) {
226+
200 -> "OK"
227+
400 -> "Bad Request"
228+
404 -> "Not Found"
229+
else -> "OK"
230+
}
231+
232+
233+
private fun buildErrorPage(message: String): String {
234+
return """
235+
|<html>
236+
|<head><title>Login error</title></head>
237+
|<body>
238+
| <h1>Unable to complete login</h1>
239+
| <p>${message}</p>
240+
|</body>
241+
|</html>
242+
|""".trimMargin()
243+
}
244+
245+
private suspend fun exchangeCodeForTokens(code: String, redirectUri: String, pkce: PkceCodes): ExchangedTokens {
246+
return withContext(Dispatchers.IO) {
247+
val body = "grant_type=authorization_code&code=${urlEncode(code)}&redirect_uri=${urlEncode(redirectUri)}&client_id=${urlEncode(CLIENT_ID)}&code_verifier=${urlEncode(pkce.codeVerifier)}"
248+
val request = Request.Builder()
249+
.url(TOKEN_URL)
250+
.header("Content-Type", "application/x-www-form-urlencoded")
251+
.post(body.toRequestBody("application/x-www-form-urlencoded".toMediaType()))
252+
.build()
253+
254+
val response = httpClient.newCall(request).execute()
255+
response.use { resp ->
256+
val responseBody = resp.body?.string().orEmpty()
257+
if (!resp.isSuccessful) {
258+
throw IOException("Token exchange failed: ${resp.code} - $responseBody")
259+
}
260+
val json = JSONObject(responseBody)
261+
val idToken = json.optString("id_token")
262+
val accessToken = json.optString("access_token")
263+
val refreshToken = json.optString("refresh_token")
264+
if (idToken.isBlank()) {
265+
throw IllegalStateException("Token exchange did not return an id_token")
266+
}
267+
return@withContext ExchangedTokens(idToken, accessToken, refreshToken)
268+
}
269+
}
270+
}
271+
272+
private suspend fun exchangeIdTokenForApiKey(idToken: String): String {
273+
return withContext(Dispatchers.IO) {
274+
val form = "grant_type=${urlEncode("urn:ietf:params:oauth:grant-type:token-exchange")}&client_id=${urlEncode(CLIENT_ID)}&requested_token=${urlEncode("openai-api-key")}&subject_token=${urlEncode(idToken)}&subject_token_type=${urlEncode("urn:ietf:params:oauth:token-type:id_token")}"
275+
val request = Request.Builder()
276+
.url(TOKEN_URL)
277+
.header("Content-Type", "application/x-www-form-urlencoded")
278+
.post(form.toRequestBody("application/x-www-form-urlencoded".toMediaType()))
279+
.build()
280+
281+
val response = httpClient.newCall(request).execute()
282+
response.use { resp ->
283+
val body = resp.body?.string().orEmpty()
284+
if (!resp.isSuccessful) {
285+
throw IOException("API key exchange failed: ${resp.code} - $body")
286+
}
287+
val json = JSONObject(body)
288+
return@withContext json.getString("access_token")
289+
}
290+
}
291+
}
292+
293+
private fun generateState(): String {
294+
val bytes = ByteArray(32)
295+
SecureRandom().nextBytes(bytes)
296+
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes)
297+
}
298+
private fun createPkceCodes(): PkceCodes {
299+
val random = SecureRandom()
300+
val verifierBytes = ByteArray(32)
301+
random.nextBytes(verifierBytes)
302+
val verifier = Base64.getUrlEncoder().withoutPadding().encodeToString(verifierBytes)
303+
val digest = MessageDigest.getInstance("SHA-256").digest(verifier.toByteArray(StandardCharsets.US_ASCII))
304+
val challenge = Base64.getUrlEncoder().withoutPadding().encodeToString(digest)
305+
return PkceCodes(verifier, challenge)
306+
}
307+
308+
private data class CallbackResult(
309+
val code: String?,
310+
val state: String?,
311+
val error: String?,
312+
val errorDescription: String?
313+
)
314+
315+
private data class ExchangedTokens(
316+
val idToken: String,
317+
val accessToken: String,
318+
val refreshToken: String
319+
)
320+
321+
private data class PkceCodes(
322+
val codeVerifier: String,
323+
val codeChallenge: String
324+
)
325+
326+
private fun buildSuccessPage(): String {
327+
return """
328+
|<html>
329+
|<head><meta charset=\"utf-8\"/><title>Login complete</title></head>
330+
|<body>
331+
| <h1>Login completed</h1>
332+
| <p>The chat login completed successfully. Close this tab to return to the IDE.</p>
333+
|</body>
334+
|</html>
335+
|""".trimMargin()
336+
}
337+
}

0 commit comments

Comments
 (0)