diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 0000000..cf3c081 --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,6 @@ +.gradle/ +build/ +*.tsbuildinfo +local.properties +*.log +.kotlin/ diff --git a/android/README.md b/android/README.md new file mode 100644 index 0000000..f2490e6 --- /dev/null +++ b/android/README.md @@ -0,0 +1,65 @@ +# Login with ChatGPT — Android + +On-device, serverless Android port of the [Login with ChatGPT](../README.md) SDK. +Users sign in with **their own** ChatGPT subscription via OpenAI's device-code +OAuth flow, and the app calls Codex models billed to that user — no OpenAI API key, +and no backend to host. Tokens live on the device in the Android Keystore. + +## Modules + +| Module | What it is | +| --- | --- | +| [`lwc-core`](./lwc-core) | Pure Kotlin/JVM engine — device flow, token refresh, JWT parsing, Codex streaming. No Android deps; unit-tested on the JVM. Port of the TS `-core` package. | +| `lwc-android` | Android library — `KeystoreTokenStore` (encrypted at rest), a Custom Tab launcher, and the `LoginWithChatGPT` facade. | +| `sample` | Minimal Compose app: sign-in screen → device code → streamed chat. | + +## Build & run + +Uses the Gradle wrapper (8.11.1) — the JDK 17 toolchain and Android SDK are picked +up from `JAVA_HOME` / `local.properties`. + +On a low-RAM machine (≤8 GB), add these to your user `~/.gradle/gradle.properties` +to keep the build inside one small JVM: + +```properties +org.gradle.jvmargs=-Xmx1024m -XX:MaxMetaspaceSize=512m +org.gradle.workers.max=1 +kotlin.compiler.execution.strategy=in-process +``` + +```bash +# Run the pure-JVM unit tests (body normalization + JWT parsing) +./gradlew :lwc-core:test + +# Stage-0 spike: full device login + streamed reply against a REAL ChatGPT account +./gradlew :lwc-core:run --args="Say hello in one short sentence." + +# Build the sample APK +./gradlew :sample:assembleDebug + +# Install + launch on a running emulator/device +./gradlew :sample:installDebug +adb shell am start -n com.opencoredev.loginwithchatgpt.sample/.MainActivity +``` + +## Using the library in your own app + +```kotlin +val lwc = LoginWithChatGPT(context) // Keystore-backed by default + +// Sign in +val device = lwc.startDeviceLogin() +lwc.openVerification(device) // Custom Tab; user enters device.userCode +while (lwc.poll(device) is DevicePollResult.Pending) delay(device.interval * 1000L) + +// Call a model — billed to the signed-in user's ChatGPT plan +lwc.chat(buildJsonObject { put("model", "gpt-5.5"); put("input", "Hi") }) + .collect { delta -> /* append streamed text */ } +``` + +## Security & status + +Tokens are encrypted at rest via Android-Keystore-backed `EncryptedSharedPreferences` +(see [`lwc-core/README`](./lwc-core/README.md) for the trust-boundary notes). This +rides OpenAI's unofficial Codex OAuth client, so it may break if OpenAI changes an +endpoint — every URL/id is overridable through `ChatGPTConfig`. diff --git a/android/build.gradle.kts b/android/build.gradle.kts new file mode 100644 index 0000000..d095d72 --- /dev/null +++ b/android/build.gradle.kts @@ -0,0 +1,9 @@ +// Root build: declare plugin versions once; modules apply them without versions. +plugins { + id("com.android.application") version "8.7.3" apply false + id("com.android.library") version "8.7.3" apply false + id("org.jetbrains.kotlin.android") version "2.0.21" apply false + id("org.jetbrains.kotlin.jvm") version "2.0.21" apply false + id("org.jetbrains.kotlin.plugin.compose") version "2.0.21" apply false + id("org.jetbrains.kotlin.plugin.serialization") version "2.0.21" apply false +} diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..55b6c89 --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,4 @@ +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +org.gradle.caching=true +kotlin.code.style=official +android.useAndroidX=true diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..b1b8ef5 Binary files /dev/null and b/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..e3343bc --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,9 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip +networkTimeout=10000 +retries=0 +retryBackOffMs=500 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/android/gradlew b/android/gradlew new file mode 100644 index 0000000..b9bb139 --- /dev/null +++ b/android/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/android/gradlew.bat b/android/gradlew.bat new file mode 100644 index 0000000..24c62d5 --- /dev/null +++ b/android/gradlew.bat @@ -0,0 +1,82 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:execute +@rem Setup the command line + + + +@rem Execute Gradle +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel + +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/android/lwc-android/build.gradle.kts b/android/lwc-android/build.gradle.kts new file mode 100644 index 0000000..62e6e54 --- /dev/null +++ b/android/lwc-android/build.gradle.kts @@ -0,0 +1,31 @@ +plugins { + id("com.android.library") + id("org.jetbrains.kotlin.android") +} + +android { + namespace = "com.opencoredev.loginwithchatgpt.android" + compileSdk = 36 + + defaultConfig { + minSdk = 24 + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } +} + +kotlin { + jvmToolchain(17) +} + +dependencies { + api(project(":lwc-core")) + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0") + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3") + implementation("androidx.security:security-crypto:1.1.0-alpha06") + implementation("androidx.browser:browser:1.8.0") + implementation("androidx.core:core-ktx:1.13.1") +} diff --git a/android/lwc-android/src/main/kotlin/com/opencoredev/loginwithchatgpt/android/KeystoreTokenStore.kt b/android/lwc-android/src/main/kotlin/com/opencoredev/loginwithchatgpt/android/KeystoreTokenStore.kt new file mode 100644 index 0000000..edf14da --- /dev/null +++ b/android/lwc-android/src/main/kotlin/com/opencoredev/loginwithchatgpt/android/KeystoreTokenStore.kt @@ -0,0 +1,63 @@ +package com.opencoredev.loginwithchatgpt.android + +import android.content.Context +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKey +import com.opencoredev.loginwithchatgpt.ChatGPTTokens +import com.opencoredev.loginwithchatgpt.TokenStore +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +/** + * [TokenStore] backed by Android-Keystore-encrypted SharedPreferences. The user's + * ChatGPT access/refresh tokens are encrypted at rest; the master key lives in the + * hardware-backed Keystore and never leaves it. + */ +class KeystoreTokenStore( + context: Context, + private val fileName: String = "lwc_tokens", +) : TokenStore { + private val appContext = context.applicationContext + private val json = Json { ignoreUnknownKeys = true } + + private val prefs by lazy { + try { + createPrefs() + } catch (_: Exception) { + // The file exists but can't be decrypted — e.g. restored from a + // backup onto a device whose Keystore lacks the original master + // key. Wipe it and start clean (user re-authenticates) rather + // than crashing every launch. + appContext.deleteSharedPreferences(fileName) + createPrefs() + } + } + + private fun createPrefs() = EncryptedSharedPreferences.create( + appContext, + fileName, + MasterKey.Builder(appContext).setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build(), + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, + ) + + override suspend fun load(): ChatGPTTokens? = withContext(Dispatchers.IO) { + runCatching { prefs.getString(KEY, null) }.getOrNull() + ?.let { runCatching { json.decodeFromString(it) }.getOrNull() } + } + + override suspend fun save(tokens: ChatGPTTokens): Unit = withContext(Dispatchers.IO) { + prefs.edit().putString(KEY, json.encodeToString(tokens)).apply() + } + + override suspend fun clear(): Unit = withContext(Dispatchers.IO) { + prefs.edit().remove(KEY).apply() + } + + private companion object { + const val KEY = "tokens" + } +} diff --git a/android/lwc-android/src/main/kotlin/com/opencoredev/loginwithchatgpt/android/LoginWithChatGPT.kt b/android/lwc-android/src/main/kotlin/com/opencoredev/loginwithchatgpt/android/LoginWithChatGPT.kt new file mode 100644 index 0000000..fc56e64 --- /dev/null +++ b/android/lwc-android/src/main/kotlin/com/opencoredev/loginwithchatgpt/android/LoginWithChatGPT.kt @@ -0,0 +1,87 @@ +package com.opencoredev.loginwithchatgpt.android + +import android.content.Context +import com.opencoredev.loginwithchatgpt.ChatGPTConfig +import com.opencoredev.loginwithchatgpt.ChatGPTUser +import com.opencoredev.loginwithchatgpt.CodexAuth +import com.opencoredev.loginwithchatgpt.CodexResponsesOptions +import com.opencoredev.loginwithchatgpt.DeviceCode +import com.opencoredev.loginwithchatgpt.DevicePollResult +import com.opencoredev.loginwithchatgpt.LoginStatus +import com.opencoredev.loginwithchatgpt.ResolvedConfig +import com.opencoredev.loginwithchatgpt.TokenStore +import com.opencoredev.loginwithchatgpt.codexResponses +import com.opencoredev.loginwithchatgpt.ensureFreshTokens +import com.opencoredev.loginwithchatgpt.exchangeDeviceAuthorization +import com.opencoredev.loginwithchatgpt.listCodexModels +import com.opencoredev.loginwithchatgpt.parseUser +import com.opencoredev.loginwithchatgpt.pollDeviceCode +import com.opencoredev.loginwithchatgpt.requestDeviceCode +import com.opencoredev.loginwithchatgpt.resolveConfig +import kotlinx.coroutines.flow.Flow +import kotlinx.serialization.json.JsonObject + +/** + * The on-device facade: wires the pure-Kotlin engine to a Keystore-backed token + * store and a Custom Tab launcher. One instance owns a signed-in ChatGPT session. + * + * Typical flow: + * ``` + * val lwc = LoginWithChatGPT(context) + * val device = lwc.startDeviceLogin() + * lwc.openVerification(device) // user enters device.userCode + * while (lwc.poll(device) is DevicePollResult.Pending) delay(device.interval * 1000L) + * lwc.chat(buildJsonObject { put("model", "gpt-5.5"); put("input", "Hi") }).collect { print(it) } + * ``` + */ +class LoginWithChatGPT( + context: Context, + config: ChatGPTConfig = ChatGPTConfig(), + private val store: TokenStore = KeystoreTokenStore(context), +) { + private val appContext = context.applicationContext + private val resolved: ResolvedConfig = resolveConfig(config) + + /** Coarse status derived from stored credentials. */ + suspend fun status(): LoginStatus { + val tokens = store.load() ?: return LoginStatus.UNAUTHENTICATED + return if (tokens.accessToken.isNotEmpty()) LoginStatus.AUTHENTICATED else LoginStatus.UNAUTHENTICATED + } + + /** The signed-in user's public profile, or null if signed out. */ + suspend fun currentUser(): ChatGPTUser? = store.load()?.let { parseUser(it.idToken) } + + /** Starts a device login; show [DeviceCode.userCode] and send the user to verify. */ + suspend fun startDeviceLogin(): DeviceCode = requestDeviceCode(resolved) + + /** Opens the verification page in a Custom Tab. */ + fun openVerification(device: DeviceCode) = VerificationLauncher.open(appContext, device.verificationUrl) + + /** Polls once. On authorization, exchanges + persists tokens and returns Authorized. */ + suspend fun poll(device: DeviceCode): DevicePollResult { + val result = pollDeviceCode(resolved, device) + if (result is DevicePollResult.Authorized) { + store.save(exchangeDeviceAuthorization(resolved, result)) + } + return result + } + + /** Clears stored credentials. */ + suspend fun logout() = store.clear() + + /** The account's currently available Codex model slugs. */ + suspend fun models(): List = listCodexModels(resolved, freshAuth()) + + /** Streams a Codex `/responses` completion as assistant-text deltas. */ + fun chat(body: JsonObject, options: CodexResponsesOptions = CodexResponsesOptions()): Flow = + codexResponses(resolved, getAuth = { freshAuth() }, body = body, options = options) + + /** Refreshes tokens if needed (persisting the result) and returns request auth. */ + private suspend fun freshAuth(): CodexAuth { + val tokens = ensureFreshTokens(resolved, store.load(), onRefresh = { store.save(it) }) + return CodexAuth( + accessToken = tokens.accessToken, + accountId = tokens.accountId ?: error("No ChatGPT account id available; sign in again."), + ) + } +} diff --git a/android/lwc-android/src/main/kotlin/com/opencoredev/loginwithchatgpt/android/VerificationLauncher.kt b/android/lwc-android/src/main/kotlin/com/opencoredev/loginwithchatgpt/android/VerificationLauncher.kt new file mode 100644 index 0000000..7683713 --- /dev/null +++ b/android/lwc-android/src/main/kotlin/com/opencoredev/loginwithchatgpt/android/VerificationLauncher.kt @@ -0,0 +1,19 @@ +package com.opencoredev.loginwithchatgpt.android + +import android.content.Context +import android.content.Intent +import android.net.Uri +import androidx.browser.customtabs.CustomTabsIntent + +/** Opens OpenAI's device-verification page in a Custom Tab (falls back to the browser). */ +object VerificationLauncher { + fun open(context: Context, url: String) { + val customTab = CustomTabsIntent.Builder() + .setShowTitle(true) + .build() + // Callers pass an application context; starting an activity from a + // non-Activity context requires NEW_TASK or Android throws. + customTab.intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + customTab.launchUrl(context, Uri.parse(url)) + } +} diff --git a/android/lwc-core/README.md b/android/lwc-core/README.md new file mode 100644 index 0000000..0409d4f --- /dev/null +++ b/android/lwc-core/README.md @@ -0,0 +1,53 @@ +# lwc-core + +Pure-Kotlin/JVM engine for **Login with ChatGPT** on Android — a port of the +TypeScript `@opencoredev/loginwithchatgpt-core` package. No Android dependencies, +so it runs on the JVM (and is unit-testable without a device). The `lwc-android` +module layers Keystore-backed token storage and a Custom Tab launcher on top. + +It lets an app sign a user in with **their own** ChatGPT subscription (Free/Plus/Pro) +via OpenAI's device-code OAuth flow, then call Codex models billed to that user — +the developer supplies no OpenAI API key. + +## What it does + +- **Device login** — `requestDeviceCode` → show the code → `pollDeviceCode` / + `waitForDeviceTokens` → tokens. No redirect URI or localhost listener needed, + which is what makes it work on mobile. +- **Token lifecycle** — `ensureFreshTokens` refreshes on expiry; `parseUser` + reads the public profile (email, name, plan) from the id token. +- **Model calls** — `codexResponses(...)` returns a `Flow` of streamed + assistant text; `listCodexModels(...)` returns the account's available models. + +## Quick start (JVM) + +```kotlin +val config = resolveConfig() // Codex defaults; every field overridable +val device = requestDeviceCode(config) +println("Open ${device.verificationUrl} and enter ${device.userCode}") +val tokens = waitForDeviceTokens(config, device) // blocks until authorized + +val auth = CodexAuth(tokens.accessToken, tokens.accountId!!) +codexResponses(config, getAuth = { auth }, body = buildJsonObject { + put("model", "gpt-5.5") + put("input", "Say hello.") +}).collect { print(it) } +``` + +Run the end-to-end spike against a real account: + +```bash +gradle :lwc-core:run --args="Say hello in one short sentence." +``` + +## Security / trust boundary + +Unlike the web SDK — where tokens stay on a server behind a proxy — this is an +**on-device** design. The user's `accessToken`/`refreshToken` live on their phone. +That is the same trust model as the Codex CLI and any "stay signed in" app: protect +them at rest. `lwc-android`'s `KeystoreTokenStore` does this with +Android-Keystore-backed `EncryptedSharedPreferences`. Never log tokens; only +`ChatGPTUser` (account id, email, name, plan) is safe to surface in the UI. + +This rides OpenAI's unofficial Codex OAuth client, so it could break if OpenAI +changes an endpoint — every URL/id is overridable via `ChatGPTConfig` to soften that. diff --git a/android/lwc-core/build.gradle.kts b/android/lwc-core/build.gradle.kts new file mode 100644 index 0000000..152ec80 --- /dev/null +++ b/android/lwc-core/build.gradle.kts @@ -0,0 +1,33 @@ +plugins { + // Versions are declared once in the root build (apply false). + kotlin("jvm") + kotlin("plugin.serialization") + application +} + +group = "com.opencoredev.loginwithchatgpt" +version = "0.1.0" + +dependencies { + // These types appear in the public API (OkHttpClient in config, Flow + // from codexResponses, JsonObject in params), so expose them transitively. + api("com.squareup.okhttp3:okhttp:4.12.0") + api("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3") + api("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0") + + testImplementation(kotlin("test")) + testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0") +} + +kotlin { + jvmToolchain(17) +} + +tasks.test { + useJUnitPlatform() +} + +application { + // Stage 0 spike entrypoint: `gradle :lwc-core:run` + mainClass.set("com.opencoredev.loginwithchatgpt.SpikeKt") +} diff --git a/android/lwc-core/src/main/kotlin/com/opencoredev/loginwithchatgpt/CodexTransport.kt b/android/lwc-core/src/main/kotlin/com/opencoredev/loginwithchatgpt/CodexTransport.kt new file mode 100644 index 0000000..80a77b0 --- /dev/null +++ b/android/lwc-core/src/main/kotlin/com/opencoredev/loginwithchatgpt/CodexTransport.kt @@ -0,0 +1,280 @@ +package com.opencoredev.loginwithchatgpt + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOn +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.addJsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put +import kotlinx.serialization.json.putJsonArray +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody + +/** Auth material required to call the Codex responses API. */ +data class CodexAuth(val accessToken: String, val accountId: String) + +/** Options controlling the shape of a `/responses` request. */ +data class CodexResponsesOptions( + val instructions: String? = null, + /** Reasoning effort. Defaults to `medium`. */ + val reasoningEffort: String? = null, + /** Reasoning summary mode. Defaults to `auto`. */ + val reasoningSummary: String? = null, + /** Text verbosity. Defaults to `medium`. */ + val textVerbosity: String? = null, + /** Default service tier, e.g. `fast` for eligible GPT-5.5/5.4 sessions. */ + val serviceTier: String? = null, +) + +private val json = Json { ignoreUnknownKeys = true } +private val jsonMedia = "application/json".toMediaType() + +/** + * Builds a `/responses` request body for a single user text prompt. The Codex + * backend requires `input` to be a **list** of message items (a bare string is + * rejected with `400 {"detail":"Input must be a list"}`), so wrap the text in the + * Responses-API `input_text` message shape. + */ +fun textPromptBody(model: String, prompt: String): JsonObject = buildJsonObject { + put("model", model) + putJsonArray("input") { + addJsonObject { + put("type", "message") + put("role", "user") + putJsonArray("content") { + addJsonObject { + put("type", "input_text") + put("text", prompt) + } + } + } + } +} + +/** + * Adapts a standard OpenAI responses payload for the ChatGPT-backed Codex + * endpoint, which runs **stateless** (`store: false`). Omitting any of these + * yields a stream with no assistant text: + * + * - `reasoning` must be configured (Codex models always reason). + * - `include` must request `reasoning.encrypted_content`. + * - input items must not carry server-side ids, and `item_reference` items are removed. + * - `max_output_tokens` / `max_completion_tokens` are rejected. + * + * Caller-provided values win over the defaults. Ported from `codex-transport.ts`. + */ +fun normalizeResponsesBody(body: JsonObject, options: CodexResponsesOptions = CodexResponsesOptions()): JsonObject { + val out = LinkedHashMap(body) + + if ((out["instructions"] as? JsonPrimitive)?.isString != true) { + out["instructions"] = JsonPrimitive(options.instructions ?: Constants.DEFAULT_CODEX_INSTRUCTIONS) + } + + // The ChatGPT backend requires stateless operation. + out["store"] = JsonPrimitive(false) + + // Reasoning is required; keep any caller-provided fields on top of the defaults. + val existingReasoning = out["reasoning"] as? JsonObject + out["reasoning"] = buildJsonObject { + put("effort", options.reasoningEffort ?: "medium") + put("summary", options.reasoningSummary ?: "auto") + existingReasoning?.forEach { (k, v) -> put(k, v) } + } + + val existingText = out["text"] as? JsonObject + out["text"] = buildJsonObject { + put("verbosity", options.textVerbosity ?: "medium") + existingText?.forEach { (k, v) -> put(k, v) } + } + + if ((out["service_tier"] as? JsonPrimitive)?.isString != true && options.serviceTier != null) { + out["service_tier"] = JsonPrimitive(options.serviceTier) + } + + // Ensure encrypted reasoning content is included. + val include = LinkedHashSet() + (out["include"] as? JsonArray)?.forEach { el -> + (el as? JsonPrimitive)?.takeIf { it.isString }?.let { include.add(it.content) } + } + include.add(Constants.REASONING_ENCRYPTED_CONTENT) + out["include"] = JsonArray(include.map { JsonPrimitive(it) }) + + (out["input"] as? JsonArray)?.let { out["input"] = filterCodexInput(it) } + + out.remove("max_output_tokens") + out.remove("max_completion_tokens") + return JsonObject(out) +} + +/** + * Strips server-side ids from input items and removes `item_reference` entries, + * which the stateless Codex API does not accept. + */ +fun filterCodexInput(input: JsonArray): JsonArray = + JsonArray( + input + .filter { !(it is JsonObject && (it["type"] as? JsonPrimitive)?.contentOrNull == "item_reference") } + .map { item -> + if (item is JsonObject && item.containsKey("id")) { + JsonObject(item.filterKeys { it != "id" }) + } else { + item + } + }, + ) + +/** + * Maps an incoming URL onto the Codex base URL, tolerating both absolute URLs and + * bare paths, and stripping a redundant `/v1` segment. + */ +fun resolveTargetUrl(input: String, codexBaseUrl: String): String { + val base = codexBaseUrl.toHttpUrl() + val basePath = base.encodedPath.trimEnd('/') + val parsed = if (Regex("^https?://").containsMatchIn(input)) { + input.toHttpUrl() + } else { + ("https://placeholder.invalid" + if (input.startsWith("/")) input else "/$input").toHttpUrl() + } + + var pathname = parsed.encodedPath + if (basePath.isNotEmpty() && pathname.startsWith("$basePath/")) pathname = pathname.substring(basePath.length) + if (pathname == "/v1") pathname = "/" else if (pathname.startsWith("/v1/")) pathname = pathname.substring(3) + if (!pathname.startsWith("/")) pathname = "/$pathname" + + // Rebuild via the base so scheme, host, AND port survive — string + // concatenation of scheme://host drops custom ports (TS uses `origin`). + return base.newBuilder() + .encodedPath("$basePath$pathname") + .encodedQuery(parsed.encodedQuery) + .build() + .toString() +} + +/** Ensures the `client_version` query param is present (the model gate depends on it). */ +fun withClientVersion(targetUrl: String, clientVersion: String): String { + if (clientVersion.isEmpty()) return targetUrl + val url = targetUrl.toHttpUrl() + if (url.queryParameter("client_version") != null) return targetUrl + return url.newBuilder().addQueryParameter("client_version", clientVersion).build().toString() +} + +/** + * Extracts model slugs from the shapes the ChatGPT backend has used for model + * lists. Unknown entries are ignored. Ported from `extractCodexModelSlugs`. + */ +fun extractCodexModelSlugs(value: JsonElement): List { + val seen = LinkedHashSet() + + fun visit(item: JsonElement) { + val candidate = when (item) { + is JsonPrimitive -> if (item.isString) item.content else null + is JsonObject -> listOf("slug", "id", "model", "name") + .firstNotNullOfOrNull { (item[it] as? JsonPrimitive)?.takeIf { p -> p.isString }?.content } + else -> null + } + val slug = candidate?.trim().orEmpty() + if (slug.isNotEmpty()) seen.add(slug) + } + + val candidateLists: List = when (value) { + is JsonArray -> listOf(value) + is JsonObject -> listOf("models", "data", "items", "available_models").mapNotNull { value[it] as? JsonArray } + else -> emptyList() + } + for (list in candidateLists) for (item in list) visit(item) + return seen.toList() +} + +private fun buildCodexRequest(config: ResolvedConfig, auth: CodexAuth, target: String): Request.Builder = + Request.Builder() + .url(target) + .header("Authorization", "Bearer ${auth.accessToken}") + .header("chatgpt-account-id", auth.accountId) + .header("OpenAI-Beta", "responses=experimental") + .header("originator", config.originator) + +/** + * Streams a Codex `/responses` completion, emitting assistant text deltas as they + * arrive. `getAuth` supplies fresh auth (wire it to a token store + [ensureFreshTokens]). + */ +fun codexResponses( + config: ResolvedConfig, + getAuth: suspend () -> CodexAuth, + body: JsonObject, + options: CodexResponsesOptions = CodexResponsesOptions(), +): Flow = flow { + val auth = getAuth() + // This helper streams, so the request must ask for SSE (`stream: true`); + // without it the Codex backend returns a single JSON body, not an event stream. + val normalized = JsonObject(normalizeResponsesBody(body, options) + ("stream" to JsonPrimitive(true))) + val target = withClientVersion( + resolveTargetUrl("${config.codexBaseUrl}/responses", config.codexBaseUrl), + config.clientVersion, + ) + val request = buildCodexRequest(config, auth, target) + .header("Accept", "text/event-stream") + .post(normalized.toString().toRequestBody(jsonMedia)) + .build() + + val debug = System.getenv("LWC_DEBUG") != null + + val response = config.httpClient.await(request) + if (debug) System.err.println("SSE| HTTP ${response.code} ${response.header("content-type")}") + if (!response.isSuccessful) { + val text = response.safeText() + response.close() + throw ChatGPTAuthError("responses_request_failed", "Codex /responses failed (${response.code}).", status = response.code, body = text) + } + + response.body?.source()?.use { source -> + while (!source.exhausted()) { + val line = source.readUtf8Line() ?: break + if (debug && line.isNotBlank()) System.err.println("SSE| ${line.take(400)}") + if (!line.startsWith("data:")) continue + val data = line.substring(5).trim() + if (data.isEmpty() || data == "[DONE]") continue + val event = try { + json.parseToJsonElement(data).jsonObject + } catch (_: Exception) { + continue + } + when (event["type"]?.jsonPrimitive?.contentOrNull) { + "response.output_text.delta" -> + event["delta"]?.jsonPrimitive?.contentOrNull?.let { emit(it) } + "response.failed", "error" -> + throw ChatGPTAuthError("responses_stream_error", "Codex stream reported: $data", body = data) + } + } + } +}.flowOn(Dispatchers.IO) + +/** Fetches the signed-in ChatGPT account's currently available Codex model slugs. */ +suspend fun listCodexModels(config: ResolvedConfig, auth: CodexAuth): List { + val target = withClientVersion( + resolveTargetUrl("${config.codexBaseUrl}/models", config.codexBaseUrl), + config.clientVersion, + ) + val request = buildCodexRequest(config, auth, target) + .header("Accept", "application/json") + .get() + .build() + + val response = config.httpClient.await(request) + response.use { + if (!it.isSuccessful) { + throw ChatGPTAuthError("models_request_failed", "Model list request failed (${it.code}).", status = it.code, body = it.safeText()) + } + return extractCodexModelSlugs(json.parseToJsonElement(it.safeText())) + } +} diff --git a/android/lwc-core/src/main/kotlin/com/opencoredev/loginwithchatgpt/Config.kt b/android/lwc-core/src/main/kotlin/com/opencoredev/loginwithchatgpt/Config.kt new file mode 100644 index 0000000..752576b --- /dev/null +++ b/android/lwc-core/src/main/kotlin/com/opencoredev/loginwithchatgpt/Config.kt @@ -0,0 +1,70 @@ +package com.opencoredev.loginwithchatgpt + +import okhttp3.OkHttpClient +import java.util.concurrent.TimeUnit + +/** + * Overridable configuration for every auth/transport call. All fields have + * sensible Codex defaults; override any of them so the SDK survives OpenAI + * moving an endpoint. Mirrors the TS `ChatGPTConfig`. + */ +data class ChatGPTConfig( + val clientId: String = Constants.DEFAULT_CLIENT_ID, + val issuer: String = Constants.DEFAULT_ISSUER, + val scope: String = Constants.DEFAULT_SCOPE, + val codexBaseUrl: String = Constants.DEFAULT_CODEX_BASE_URL, + val originator: String = Constants.DEFAULT_ORIGINATOR, + val clientVersion: String = Constants.DEFAULT_CLIENT_VERSION, + /** HTTP client. Override to inject interceptors, proxies, or test doubles. */ + val httpClient: OkHttpClient? = null, +) + +/** Fully-resolved configuration with all endpoint URLs derived from the issuer. */ +class ResolvedConfig internal constructor( + val clientId: String, + val issuer: String, + val scope: String, + val codexBaseUrl: String, + val originator: String, + val clientVersion: String, + val httpClient: OkHttpClient, +) { + /** OAuth token endpoint. */ + val tokenUrl: String = "$issuer/oauth/token" + + /** OAuth authorization endpoint. */ + val authorizeUrl: String = "$issuer/oauth/authorize" + + /** Device-auth API base. */ + val deviceApiBase: String = "$issuer/api/accounts" + + /** User-facing device verification page. */ + val deviceVerificationUrl: String = "$issuer/codex/device" + + /** Redirect URI used to exchange a device authorization code. */ + val deviceRedirectUri: String = "$issuer/deviceauth/callback" +} + +private fun stripTrailingSlash(value: String): String = value.trimEnd('/') + +/** Streaming-friendly default client: long read timeout for SSE, no call timeout. */ +private fun defaultHttpClient(): OkHttpClient = + OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(0, TimeUnit.MILLISECONDS) + .callTimeout(0, TimeUnit.MILLISECONDS) + .build() + +/** Applies defaults and derives every endpoint URL from the issuer. */ +fun resolveConfig(config: ChatGPTConfig = ChatGPTConfig()): ResolvedConfig { + val issuer = stripTrailingSlash(config.issuer) + return ResolvedConfig( + clientId = config.clientId, + issuer = issuer, + scope = config.scope, + codexBaseUrl = stripTrailingSlash(config.codexBaseUrl), + originator = config.originator, + clientVersion = config.clientVersion, + httpClient = config.httpClient ?: defaultHttpClient(), + ) +} diff --git a/android/lwc-core/src/main/kotlin/com/opencoredev/loginwithchatgpt/Constants.kt b/android/lwc-core/src/main/kotlin/com/opencoredev/loginwithchatgpt/Constants.kt new file mode 100644 index 0000000..f7d18a7 --- /dev/null +++ b/android/lwc-core/src/main/kotlin/com/opencoredev/loginwithchatgpt/Constants.kt @@ -0,0 +1,56 @@ +package com.opencoredev.loginwithchatgpt + +/** + * Wire-protocol constants for the ChatGPT (Codex) OAuth flow. + * + * These mirror the public OpenAI Codex CLI client. Logging in with them grants + * access to the end user's own ChatGPT plan (Free/Plus/Pro) — usage is billed to + * that user, never to the app developer. Every value is overridable through + * [ChatGPTConfig] so the SDK keeps working if OpenAI moves an endpoint. + * + * Ported from `packages/core/src/constants.ts`. + */ +object Constants { + /** Public OAuth client id used by the Codex CLI. */ + const val DEFAULT_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann" + + /** OAuth issuer / authorization server origin. */ + const val DEFAULT_ISSUER = "https://auth.openai.com" + + /** OAuth scopes required to obtain a refreshable ChatGPT session. */ + const val DEFAULT_SCOPE = "openid profile email offline_access" + + /** Base URL of the ChatGPT-backed Codex model API. */ + const val DEFAULT_CODEX_BASE_URL = "https://chatgpt.com/backend-api/codex" + + /** `originator` header/param value that identifies the client to OpenAI. */ + const val DEFAULT_ORIGINATOR = "codex_cli_rs" + + /** JWT claim namespace that carries ChatGPT account/plan metadata. */ + const val AUTH_CLAIM = "https://api.openai.com/auth" + + /** Device codes expire server-side ~15 minutes after issue. */ + const val DEVICE_CODE_TTL_MS = 15L * 60L * 1000L + + /** Default model used by the Codex responses API when the caller omits one. */ + const val DEFAULT_MODEL = "gpt-5.5" + + /** + * Codex client version sent as the `client_version` query parameter. The + * ChatGPT backend gates the available model set on this — omitting it (or + * sending a stale value) makes every model report as "not supported". Bump + * toward the current Codex CLI release if models disappear. + */ + const val DEFAULT_CLIENT_VERSION = "0.142.5" + + /** Default system instructions sent to the Codex responses API. */ + const val DEFAULT_CODEX_INSTRUCTIONS = + "You are a helpful assistant powered by the user's ChatGPT account. " + + "Answer the user's request directly and helpfully." + + /** + * The Codex backend runs stateless (`store: false`), so reasoning continuity + * is carried in encrypted reasoning content that must be explicitly requested. + */ + const val REASONING_ENCRYPTED_CONTENT = "reasoning.encrypted_content" +} diff --git a/android/lwc-core/src/main/kotlin/com/opencoredev/loginwithchatgpt/Device.kt b/android/lwc-core/src/main/kotlin/com/opencoredev/loginwithchatgpt/Device.kt new file mode 100644 index 0000000..218e8fc --- /dev/null +++ b/android/lwc-core/src/main/kotlin/com/opencoredev/loginwithchatgpt/Device.kt @@ -0,0 +1,146 @@ +package com.opencoredev.loginwithchatgpt + +import kotlinx.coroutines.delay +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody + +/** + * The device-authorization flow — the mobile-friendly path. Unlike the loopback + * PKCE flow it needs no redirect listener, so it works on phones, servers, and + * CLIs. The PKCE pair is returned by OpenAI in the poll response, so the client + * never computes one. + * + * Ported from `packages/core/src/device.ts`. + */ + +private val json = Json { ignoreUnknownKeys = true } +private val jsonMedia = "application/json".toMediaType() + +/** Requests a fresh device code from OpenAI. */ +suspend fun requestDeviceCode(config: ResolvedConfig, now: () -> Long = System::currentTimeMillis): DeviceCode { + val url = "${config.deviceApiBase}/deviceauth/usercode" + val payload = buildJsonObject { put("client_id", config.clientId) } + val request = Request.Builder() + .url(url) + .header("Accept", "application/json") + .post(payload.toString().toRequestBody(jsonMedia)) + .build() + + val response = try { + config.httpClient.await(request) + } catch (cause: Exception) { + throw ChatGPTAuthError("network_error", "Failed to reach the device authorization endpoint.", cause = cause) + } + response.use { + if (it.code == 404) { + throw ChatGPTAuthError( + "device_code_disabled", + "Device-code login is not enabled for this server. Verify the issuer URL or use the redirect flow.", + status = 404, + ) + } + if (!it.isSuccessful) { + throw ChatGPTAuthError("device_code_request_failed", "Device code request failed (${it.code}).", status = it.code, body = it.safeText()) + } + val raw = json.parseToJsonElement(it.safeText()).jsonObject + val deviceAuthId = raw["device_auth_id"]?.jsonPrimitive?.contentOrNull + val userCode = raw["user_code"]?.jsonPrimitive?.contentOrNull + ?: raw["usercode"]?.jsonPrimitive?.contentOrNull + if (deviceAuthId == null || userCode == null) { + throw ChatGPTAuthError("device_code_request_failed", "Device code response was missing required fields.") + } + return DeviceCode( + deviceAuthId = deviceAuthId, + userCode = userCode, + verificationUrl = config.deviceVerificationUrl, + interval = normalizeInterval(raw["interval"]), + expiresAt = now() + Constants.DEVICE_CODE_TTL_MS, + ) + } +} + +/** + * Polls once for device-authorization completion. Returns [DevicePollResult.Pending] + * while the user has not finished, or [DevicePollResult.Authorized] with the code + * and server-generated PKCE pair to exchange for tokens. + */ +suspend fun pollDeviceCode(config: ResolvedConfig, device: DeviceCode): DevicePollResult { + val url = "${config.deviceApiBase}/deviceauth/token" + val payload = buildJsonObject { + put("device_auth_id", device.deviceAuthId) + put("user_code", device.userCode) + } + val request = Request.Builder() + .url(url) + .header("Accept", "application/json") + .post(payload.toString().toRequestBody(jsonMedia)) + .build() + + val response = try { + config.httpClient.await(request) + } catch (cause: Exception) { + throw ChatGPTAuthError("network_error", "Failed to reach the device token endpoint.", cause = cause) + } + response.use { + // 403/404 are the documented "keep waiting" responses; 429 is a transient + // Cloudflare rate-limit/challenge on the polling endpoint — also retryable. + if (it.code == 403 || it.code == 404 || it.code == 429) return DevicePollResult.Pending + if (!it.isSuccessful) { + throw ChatGPTAuthError("token_exchange_failed", "Device authorization failed (${it.code}).", status = it.code, body = it.safeText()) + } + val raw = json.parseToJsonElement(it.safeText()).jsonObject + val code = raw["authorization_code"]?.jsonPrimitive?.contentOrNull + val verifier = raw["code_verifier"]?.jsonPrimitive?.contentOrNull + val challenge = raw["code_challenge"]?.jsonPrimitive?.contentOrNull + // A 200 without a code means it is still binding — treat as pending. + if (code == null || verifier == null || challenge == null) return DevicePollResult.Pending + return DevicePollResult.Authorized(authorizationCode = code, codeChallenge = challenge, codeVerifier = verifier) + } +} + +/** Exchanges a successful device poll for tokens. */ +suspend fun exchangeDeviceAuthorization(config: ResolvedConfig, poll: DevicePollResult.Authorized): ChatGPTTokens = + exchangeAuthorizationCode( + config, + code = poll.authorizationCode, + codeVerifier = poll.codeVerifier, + redirectUri = config.deviceRedirectUri, + ) + +/** + * Blocks until the user authorizes the device or the code expires. Intended for + * CLIs/spikes; a UI should drive [pollDeviceCode] from its own polling loop. + */ +suspend fun waitForDeviceTokens( + config: ResolvedConfig, + device: DeviceCode, + intervalMs: Long = device.interval * 1000L, + now: () -> Long = System::currentTimeMillis, + onPoll: ((attempt: Int) -> Unit)? = null, +): ChatGPTTokens { + var attempt = 0 + while (now() < device.expiresAt) { + onPoll?.invoke(++attempt) + when (val result = pollDeviceCode(config, device)) { + is DevicePollResult.Authorized -> return exchangeDeviceAuthorization(config, result) + DevicePollResult.Pending -> delay(intervalMs) + } + } + throw ChatGPTAuthError("authorization_expired", "Device authorization expired before the user completed sign-in.") +} + +private fun normalizeInterval(value: kotlinx.serialization.json.JsonElement?): Int { + val prim = value?.jsonPrimitive ?: return 5 + prim.intOrNull?.let { if (it > 0) return it } + prim.contentOrNull?.trim()?.toIntOrNull()?.let { if (it > 0) return it } + return 5 +} diff --git a/android/lwc-core/src/main/kotlin/com/opencoredev/loginwithchatgpt/Http.kt b/android/lwc-core/src/main/kotlin/com/opencoredev/loginwithchatgpt/Http.kt new file mode 100644 index 0000000..6b8d51d --- /dev/null +++ b/android/lwc-core/src/main/kotlin/com/opencoredev/loginwithchatgpt/Http.kt @@ -0,0 +1,38 @@ +package com.opencoredev.loginwithchatgpt + +import kotlinx.coroutines.suspendCancellableCoroutine +import okhttp3.Call +import okhttp3.Callback +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import java.io.IOException +import kotlin.coroutines.resumeWithException + +/** Suspending OkHttp call. Cancels the request when the coroutine is cancelled. */ +internal suspend fun OkHttpClient.await(request: Request): Response = + suspendCancellableCoroutine { cont -> + val call = newCall(request) + cont.invokeOnCancellation { runCatching { call.cancel() } } + call.enqueue( + object : Callback { + override fun onFailure(call: Call, e: IOException) { + if (cont.isCancelled) return + cont.resumeWithException(e) + } + + override fun onResponse(call: Call, response: Response) { + // Close the response if the coroutine was cancelled after + // delivery — otherwise the connection leaks. + cont.resume(response) { _ -> runCatching { response.close() } } + } + }, + ) + } + +/** Reads a response body as text, swallowing read errors. */ +internal fun Response.safeText(): String = try { + body?.string() ?: "" +} catch (_: Exception) { + "" +} diff --git a/android/lwc-core/src/main/kotlin/com/opencoredev/loginwithchatgpt/Jwt.kt b/android/lwc-core/src/main/kotlin/com/opencoredev/loginwithchatgpt/Jwt.kt new file mode 100644 index 0000000..87cada9 --- /dev/null +++ b/android/lwc-core/src/main/kotlin/com/opencoredev/loginwithchatgpt/Jwt.kt @@ -0,0 +1,61 @@ +package com.opencoredev.loginwithchatgpt + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.longOrNull +import java.util.Base64 + +private val lenientJson = Json { ignoreUnknownKeys = true; isLenient = true } + +/** Decodes a base64url segment (no padding) to a UTF-8 string. */ +internal fun base64UrlDecodeToString(segment: String): String = + String(Base64.getUrlDecoder().decode(padBase64Url(segment))) + +private fun padBase64Url(value: String): String { + val remainder = value.length % 4 + return if (remainder == 0) value else value + "=".repeat(4 - remainder) +} + +/** + * Decodes a JWT payload **without verifying its signature**. These tokens come + * straight from OpenAI's token endpoint over TLS, so we only read claims we + * already trust. Never use this to validate a token from an untrusted source. + */ +fun decodeJwt(token: String?): JsonObject? { + if (token == null) return null + val parts = token.split(".") + if (parts.size != 3 || parts[1].isEmpty()) return null + return try { + lenientJson.parseToJsonElement(base64UrlDecodeToString(parts[1])).jsonObject + } catch (_: Exception) { + null + } +} + +/** Extracts the `exp` claim as epoch milliseconds, or `null`. */ +fun getTokenExpiry(token: String?): Long? { + val exp = decodeJwt(token)?.get("exp")?.jsonPrimitive?.longOrNull ?: return null + return exp * 1000 +} + +/** Reads the ChatGPT account id from an id (or access) token. */ +fun deriveAccountId(token: String?): String? { + val auth = decodeJwt(token)?.get(Constants.AUTH_CLAIM) as? JsonObject ?: return null + return auth["chatgpt_account_id"]?.jsonPrimitive?.contentOrNull +} + +/** Builds a public [ChatGPTUser] profile from an id token. */ +fun parseUser(idToken: String?): ChatGPTUser? { + val claims = decodeJwt(idToken) ?: return null + val accountId = deriveAccountId(idToken) ?: return null + val auth = claims[Constants.AUTH_CLAIM] as? JsonObject + return ChatGPTUser( + accountId = accountId, + email = claims["email"]?.jsonPrimitive?.contentOrNull?.ifEmpty { null }, + name = claims["name"]?.jsonPrimitive?.contentOrNull?.ifEmpty { null }, + plan = auth?.get("chatgpt_plan_type")?.jsonPrimitive?.contentOrNull?.ifEmpty { null }, + ) +} diff --git a/android/lwc-core/src/main/kotlin/com/opencoredev/loginwithchatgpt/Models.kt b/android/lwc-core/src/main/kotlin/com/opencoredev/loginwithchatgpt/Models.kt new file mode 100644 index 0000000..03762e8 --- /dev/null +++ b/android/lwc-core/src/main/kotlin/com/opencoredev/loginwithchatgpt/Models.kt @@ -0,0 +1,77 @@ +package com.opencoredev.loginwithchatgpt + +import kotlinx.serialization.Serializable + +/** + * OAuth tokens for a signed-in ChatGPT user. + * + * [accessToken] is short-lived; [refreshToken] mints new access tokens. Both are + * secrets — on device they live in the Android Keystore-backed store. [accountId] + * is derived from the id token and is required on every model request. + */ +@Serializable +data class ChatGPTTokens( + val accessToken: String, + val refreshToken: String? = null, + val idToken: String? = null, + /** ChatGPT account id (`chatgpt_account_id` claim), sent as a request header. */ + val accountId: String? = null, + /** Epoch milliseconds at which [accessToken] expires, when known. */ + val expiresAt: Long? = null, +) + +/** Public profile derived from the id token — safe to show in the UI. */ +@Serializable +data class ChatGPTUser( + val accountId: String, + val email: String? = null, + val name: String? = null, + /** ChatGPT plan, e.g. `"free"`, `"plus"`, `"pro"`, when present in the token. */ + val plan: String? = null, +) + +/** + * A pending device-code login. Show [userCode] to the user and send them to + * [verificationUrl]; poll until they authorize. + */ +data class DeviceCode( + /** Opaque handle used when polling for completion. */ + val deviceAuthId: String, + /** Short human-enterable code (e.g. `7B0J-DPK78`). */ + val userCode: String, + /** URL the user opens to enter [userCode]. */ + val verificationUrl: String, + /** Minimum seconds to wait between polls. */ + val interval: Int, + /** Epoch milliseconds after which the code is no longer valid. */ + val expiresAt: Long, +) + +/** Result of a single device-token poll. */ +sealed interface DevicePollResult { + data object Pending : DevicePollResult + + data class Authorized( + val authorizationCode: String, + val codeChallenge: String, + val codeVerifier: String, + ) : DevicePollResult +} + +/** High-level status of a login session. */ +enum class LoginStatus { + UNAUTHENTICATED, + PENDING, + AUTHENTICATED, + EXPIRED, + ERROR, +} + +/** Structured auth/transport failure, mirroring the TS `ChatGPTAuthError`. */ +class ChatGPTAuthError( + val code: String, + message: String, + val status: Int? = null, + val body: String? = null, + cause: Throwable? = null, +) : Exception(message, cause) diff --git a/android/lwc-core/src/main/kotlin/com/opencoredev/loginwithchatgpt/OAuth.kt b/android/lwc-core/src/main/kotlin/com/opencoredev/loginwithchatgpt/OAuth.kt new file mode 100644 index 0000000..40201b3 --- /dev/null +++ b/android/lwc-core/src/main/kotlin/com/opencoredev/loginwithchatgpt/OAuth.kt @@ -0,0 +1,121 @@ +package com.opencoredev.loginwithchatgpt + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put +import okhttp3.FormBody +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody + +private val json = Json { ignoreUnknownKeys = true } +private val jsonMedia = "application/json".toMediaType() + +/** Normalizes OpenAI's token payload into [ChatGPTTokens]. */ +private fun toTokens(raw: JsonObject, previousRefreshToken: String? = null): ChatGPTTokens { + val accessToken = raw["access_token"]?.jsonPrimitive?.contentOrNull + ?: throw ChatGPTAuthError("token_exchange_failed", "Token response missing access_token.") + val idToken = raw["id_token"]?.jsonPrimitive?.contentOrNull + val expiresIn = raw["expires_in"]?.jsonPrimitive?.intOrNull + return ChatGPTTokens( + accessToken = accessToken, + refreshToken = raw["refresh_token"]?.jsonPrimitive?.contentOrNull ?: previousRefreshToken, + idToken = idToken, + accountId = deriveAccountId(idToken) ?: deriveAccountId(accessToken), + expiresAt = if (expiresIn != null) System.currentTimeMillis() + expiresIn * 1000L else getTokenExpiry(accessToken), + ) +} + +/** Exchanges an authorization code (+ PKCE verifier) for tokens. */ +suspend fun exchangeAuthorizationCode( + config: ResolvedConfig, + code: String, + codeVerifier: String, + redirectUri: String, +): ChatGPTTokens { + val form = FormBody.Builder() + .add("grant_type", "authorization_code") + .add("client_id", config.clientId) + .add("code", code) + .add("code_verifier", codeVerifier) + .add("redirect_uri", redirectUri) + .build() + val request = Request.Builder() + .url(config.tokenUrl) + .header("Accept", "application/json") + .post(form) + .build() + + val response = try { + config.httpClient.await(request) + } catch (cause: Exception) { + throw ChatGPTAuthError("network_error", "Failed to reach the token endpoint.", cause = cause) + } + response.use { + if (!it.isSuccessful) { + throw ChatGPTAuthError( + "token_exchange_failed", + "Authorization code exchange failed (${it.code}).", + status = it.code, + body = it.safeText(), + ) + } + return toTokens(json.parseToJsonElement(it.safeText()).jsonObject) + } +} + +/** Error codes OpenAI returns when a refresh token can no longer be used. */ +private val DEAD_REFRESH_ERRORS = setOf( + "refresh_token_expired", + "refresh_token_reused", + "refresh_token_invalidated", + "invalid_grant", +) + +/** Exchanges a refresh token for a fresh access token (and possibly a new refresh token). */ +suspend fun refreshTokens(config: ResolvedConfig, refreshToken: String): ChatGPTTokens { + val payload = buildJsonObject { + put("grant_type", "refresh_token") + put("refresh_token", refreshToken) + put("client_id", config.clientId) + put("scope", config.scope) + } + val request = Request.Builder() + .url(config.tokenUrl) + .header("Accept", "application/json") + .post(payload.toString().toRequestBody(jsonMedia)) + .build() + + val response = try { + config.httpClient.await(request) + } catch (cause: Exception) { + throw ChatGPTAuthError("network_error", "Failed to reach the token endpoint.", cause = cause) + } + response.use { + if (!it.isSuccessful) { + val text = it.safeText() + val errorCode = extractErrorCode(text) + if (errorCode != null && errorCode in DEAD_REFRESH_ERRORS) { + throw ChatGPTAuthError( + "refresh_token_invalid", + "Refresh token is no longer valid ($errorCode). The user must sign in again.", + status = it.code, + body = text, + ) + } + throw ChatGPTAuthError("token_refresh_failed", "Token refresh failed (${it.code}).", status = it.code, body = text) + } + return toTokens(json.parseToJsonElement(it.safeText()).jsonObject, refreshToken) + } +} + +private fun extractErrorCode(body: String): String? = try { + json.parseToJsonElement(body).jsonObject["error"]?.jsonPrimitive?.contentOrNull +} catch (_: Exception) { + null +} diff --git a/android/lwc-core/src/main/kotlin/com/opencoredev/loginwithchatgpt/Spike.kt b/android/lwc-core/src/main/kotlin/com/opencoredev/loginwithchatgpt/Spike.kt new file mode 100644 index 0000000..4fbd3b0 --- /dev/null +++ b/android/lwc-core/src/main/kotlin/com/opencoredev/loginwithchatgpt/Spike.kt @@ -0,0 +1,68 @@ +package com.opencoredev.loginwithchatgpt + +import kotlinx.coroutines.runBlocking + +/** + * Stage 0 de-risk spike (no UI). Runs the full on-device flow against a real + * ChatGPT account and prints the result: + * + * gradle :lwc-core:run --args="Say hello in one short sentence." + * + * Go/no-go gate: proves device login + a streamed /responses completion works + * from a non-CLI native client, and that listCodexModels returns the account's + * models — before any Android/Compose work. + */ +fun main(args: Array) = runBlocking { + val prompt = args.joinToString(" ").ifBlank { "Say hello in exactly one short sentence." } + val config = resolveConfig() + + println("Requesting device code…") + val device = requestDeviceCode(config) + println() + println(" 1. Open: ${device.verificationUrl}") + println(" 2. Enter code: ${device.userCode}") + println() + println("Waiting for authorization (code expires in ~15 min)…") + + val tokens = waitForDeviceTokens(config, device) { attempt -> + if (attempt % 5 == 0) println(" …still waiting (poll #$attempt)") + } + + val user = parseUser(tokens.idToken) + println() + println("Signed in: ${user?.email ?: "(unknown email)"} plan=${user?.plan ?: "?"} account=${tokens.accountId}") + + val auth = CodexAuth(accessToken = tokens.accessToken, accountId = tokens.accountId ?: error("no account id")) + + println() + println("Available models:") + val models = listCodexModels(config, auth) + models.forEach { println(" - $it") } + check(models.isNotEmpty()) { "listCodexModels returned no models" } + + val model = if (models.contains(Constants.DEFAULT_MODEL)) Constants.DEFAULT_MODEL else models.first() + + println() + println("Streaming a completion from '$model':") + println("Prompt: $prompt") + print("Reply: ") + val body = textPromptBody(model, prompt) + val sb = StringBuilder() + try { + codexResponses(config, getAuth = { auth }, body = body).collect { delta -> + sb.append(delta) + print(delta) + System.out.flush() + } + } catch (e: ChatGPTAuthError) { + println() + System.err.println("‼ /responses failed: code=${e.code} httpStatus=${e.status}") + System.err.println("‼ response body: ${e.body}") + System.err.println("(Re-run with LWC_DEBUG=1 to dump the raw stream.)") + throw e + } + println() + check(sb.isNotBlank()) { "Stream produced no assistant text (AI_NoOutputGenerated) — check body normalization/headers." } + println() + println("✔ Stage 0 passed: login + models + streamed reply all worked.") +} diff --git a/android/lwc-core/src/main/kotlin/com/opencoredev/loginwithchatgpt/TokenStore.kt b/android/lwc-core/src/main/kotlin/com/opencoredev/loginwithchatgpt/TokenStore.kt new file mode 100644 index 0000000..4f33398 --- /dev/null +++ b/android/lwc-core/src/main/kotlin/com/opencoredev/loginwithchatgpt/TokenStore.kt @@ -0,0 +1,21 @@ +package com.opencoredev.loginwithchatgpt + +import java.util.concurrent.atomic.AtomicReference + +/** + * Persists the signed-in user's tokens. The pure-Kotlin core ships an in-memory + * implementation; the `lwc-android` module provides a Keystore-backed one. + */ +interface TokenStore { + suspend fun load(): ChatGPTTokens? + suspend fun save(tokens: ChatGPTTokens) + suspend fun clear() +} + +/** Non-persistent store — process memory only. Useful for the spike and tests. */ +class InMemoryTokenStore(initial: ChatGPTTokens? = null) : TokenStore { + private val ref = AtomicReference(initial) + override suspend fun load(): ChatGPTTokens? = ref.get() + override suspend fun save(tokens: ChatGPTTokens) = ref.set(tokens) + override suspend fun clear() = ref.set(null) +} diff --git a/android/lwc-core/src/main/kotlin/com/opencoredev/loginwithchatgpt/Tokens.kt b/android/lwc-core/src/main/kotlin/com/opencoredev/loginwithchatgpt/Tokens.kt new file mode 100644 index 0000000..3a0075b --- /dev/null +++ b/android/lwc-core/src/main/kotlin/com/opencoredev/loginwithchatgpt/Tokens.kt @@ -0,0 +1,43 @@ +package com.opencoredev.loginwithchatgpt + +/** Refresh when the access token is within this window of expiring. */ +private const val EXPIRY_MARGIN_MS = 60L * 1000L + +/** `true` when the access token is missing, expired, or about to expire. */ +fun isAccessTokenExpired(tokens: ChatGPTTokens, now: () -> Long = System::currentTimeMillis): Boolean { + if (tokens.accessToken.isEmpty()) return true + val expiresAt = tokens.expiresAt ?: getTokenExpiry(tokens.accessToken) ?: return false + return expiresAt <= now() + EXPIRY_MARGIN_MS +} + +/** Ensures `accountId` is populated by deriving it from the tokens when missing. */ +private fun withAccountId(tokens: ChatGPTTokens): ChatGPTTokens { + if (tokens.accountId != null) return tokens + val accountId = deriveAccountId(tokens.idToken) ?: deriveAccountId(tokens.accessToken) + return if (accountId != null) tokens.copy(accountId = accountId) else tokens +} + +/** + * Returns tokens guaranteed fresh enough to make an API call, refreshing via the + * refresh token when needed and reporting the new tokens through [onRefresh]. + * Throws [ChatGPTAuthError] `not_authenticated` when nothing usable is available. + */ +suspend fun ensureFreshTokens( + config: ResolvedConfig, + tokens: ChatGPTTokens?, + force: Boolean = false, + now: () -> Long = System::currentTimeMillis, + onRefresh: (suspend (ChatGPTTokens) -> Unit)? = null, +): ChatGPTTokens { + if (tokens != null && tokens.accessToken.isNotEmpty() && !force && !isAccessTokenExpired(tokens, now)) { + return withAccountId(tokens) + } + val refreshToken = tokens?.refreshToken + if (refreshToken == null) { + if (tokens != null && tokens.accessToken.isNotEmpty()) return withAccountId(tokens) + throw ChatGPTAuthError("not_authenticated", "No ChatGPT credentials available. The user must sign in.") + } + val refreshed = withAccountId(refreshTokens(config, refreshToken)) + onRefresh?.invoke(refreshed) + return refreshed +} diff --git a/android/lwc-core/src/test/kotlin/com/opencoredev/loginwithchatgpt/CodexTransportTest.kt b/android/lwc-core/src/test/kotlin/com/opencoredev/loginwithchatgpt/CodexTransportTest.kt new file mode 100644 index 0000000..040cbfa --- /dev/null +++ b/android/lwc-core/src/test/kotlin/com/opencoredev/loginwithchatgpt/CodexTransportTest.kt @@ -0,0 +1,144 @@ +package com.opencoredev.loginwithchatgpt + +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** Ported from `packages/core/test/codex-transport.test.ts`. */ +class CodexTransportTest { + @Test + fun `normalizeResponsesBody adds all Codex stateless requirements`() { + val out = normalizeResponsesBody( + buildJsonObject { put("input", "hi"); put("max_output_tokens", 100) }, + CodexResponsesOptions(instructions = "sys"), + ) + assertEquals("sys", out["instructions"]?.jsonPrimitive?.contentOrNull) + assertEquals(false, out["store"]?.jsonPrimitive?.booleanOrNull) + val reasoning = out["reasoning"] as JsonObject + assertEquals("medium", reasoning["effort"]?.jsonPrimitive?.contentOrNull) + assertEquals("auto", reasoning["summary"]?.jsonPrimitive?.contentOrNull) + assertEquals("medium", (out["text"] as JsonObject)["verbosity"]?.jsonPrimitive?.contentOrNull) + val include = (out["include"] as JsonArray).map { it.jsonPrimitive.content } + assertTrue(include.contains("reasoning.encrypted_content")) + assertNull(out["max_output_tokens"]) + } + + @Test + fun `normalizeResponsesBody keeps caller instructions and merges reasoning overrides`() { + val out = normalizeResponsesBody( + buildJsonObject { + put("input", "hi") + put("instructions", "keep") + put("reasoning", buildJsonObject { put("effort", "high") }) + }, + CodexResponsesOptions(reasoningEffort = "low"), + ) + assertEquals("keep", out["instructions"]?.jsonPrimitive?.contentOrNull) + // caller-provided reasoning.effort wins over the option default + assertEquals("high", (out["reasoning"] as JsonObject)["effort"]?.jsonPrimitive?.contentOrNull) + assertEquals(false, out["store"]?.jsonPrimitive?.booleanOrNull) + } + + @Test + fun `normalizeResponsesBody accepts Codex service tier defaults`() { + val out = normalizeResponsesBody(buildJsonObject { put("input", "hi") }, CodexResponsesOptions(serviceTier = "fast")) + assertEquals("fast", out["service_tier"]?.jsonPrimitive?.contentOrNull) + + val callerTier = normalizeResponsesBody( + buildJsonObject { put("input", "hi"); put("service_tier", "flex") }, + CodexResponsesOptions(serviceTier = "fast"), + ) + assertEquals("flex", callerTier["service_tier"]?.jsonPrimitive?.contentOrNull) + } + + @Test + fun `normalizeResponsesBody strips input ids and drops item_reference`() { + val out = normalizeResponsesBody( + buildJsonObject { + put( + "input", + buildJsonArray { + add(buildJsonObject { put("id", "msg_1"); put("type", "message"); put("role", "user"); put("content", buildJsonArray {}) }) + add(buildJsonObject { put("type", "item_reference"); put("id", "ref_1") }) + }, + ) + }, + ) + val input = out["input"] as JsonArray + assertEquals(1, input.size) + val first = input[0] as JsonObject + assertFalse(first.containsKey("id")) + assertEquals("message", first["type"]?.jsonPrimitive?.contentOrNull) + } + + @Test + fun `resolveTargetUrl maps absolute and relative inputs onto the codex base`() { + val base = "https://chatgpt.com/backend-api/codex" + assertEquals("$base/responses", resolveTargetUrl("https://api.openai.com/v1/responses", base)) + assertEquals("$base/responses", resolveTargetUrl("/responses", base)) + assertEquals("$base/responses", resolveTargetUrl("$base/responses", base)) + } + + @Test + fun `resolveTargetUrl preserves custom ports in overridden base URLs`() { + // e.g. the Android emulator's host loopback, or a local proxy + val base = "http://10.0.2.2:8080/backend-api/codex" + assertEquals("$base/responses", resolveTargetUrl("/responses", base)) + assertEquals("$base/responses", resolveTargetUrl("https://api.openai.com/v1/responses", base)) + assertEquals("$base/models?x=1", resolveTargetUrl("/models?x=1", base)) + } + + @Test + fun `withClientVersion adds param when absent and preserves an explicit one`() { + val base = "https://chatgpt.com/backend-api/codex/responses" + assertTrue(withClientVersion(base, "0.142.5").contains("client_version=0.142.5")) + assertTrue(withClientVersion("$base?client_version=9.9.9", "0.142.5").contains("client_version=9.9.9")) + } + + @Test + fun `extractCodexModelSlugs supports known model-list wrappers`() { + assertEquals( + listOf("gpt-a", "gpt-b", "gpt-c"), + extractCodexModelSlugs( + buildJsonObject { + put("models", buildJsonArray { + add(buildJsonObject { put("slug", "gpt-a") }) + add(buildJsonObject { put("id", "gpt-b") }) + add(buildJsonObject { put("slug", "gpt-a") }) + add(buildJsonObject { put("slug", "") }) + }) + put("data", buildJsonArray { add(buildJsonObject { put("model", "gpt-c") }) }) + }, + ), + ) + assertEquals( + listOf("gpt-c"), + extractCodexModelSlugs( + buildJsonObject { + put("models", buildJsonArray {}) + put("data", buildJsonArray { add(buildJsonObject { put("model", "gpt-c") }) }) + }, + ), + ) + assertEquals( + listOf("gpt-d"), + extractCodexModelSlugs(buildJsonArray { add(buildJsonObject { put("name", "gpt-d") }) }), + ) + assertEquals( + listOf("gpt-5.5", "gpt-5.4"), + extractCodexModelSlugs(buildJsonObject { put("models", buildJsonArray { add(JsonPrimitive("gpt-5.5")); add(JsonPrimitive("gpt-5.4")) }) }), + ) + } +} diff --git a/android/lwc-core/src/test/kotlin/com/opencoredev/loginwithchatgpt/JwtTest.kt b/android/lwc-core/src/test/kotlin/com/opencoredev/loginwithchatgpt/JwtTest.kt new file mode 100644 index 0000000..dc59e02 --- /dev/null +++ b/android/lwc-core/src/test/kotlin/com/opencoredev/loginwithchatgpt/JwtTest.kt @@ -0,0 +1,82 @@ +package com.opencoredev.loginwithchatgpt + +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonObjectBuilder +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put +import java.util.Base64 +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +/** Ported from `packages/core/test/jwt.test.ts` (and `helpers.ts`). */ +class JwtTest { + private fun b64Url(s: String): String = + Base64.getUrlEncoder().withoutPadding().encodeToString(s.toByteArray()) + + /** Builds an unsigned JWT (`alg: none`) with the given claims. */ + private fun makeJwt(build: JsonObjectBuilder.() -> Unit): String { + val header = b64Url("""{"alg":"none","typ":"JWT"}""") + val body = b64Url(buildJsonObject(build).toString()) + return "$header.$body.sig" + } + + private fun makeIdToken(accountId: String? = "acct_123", email: String? = null, name: String? = null, plan: String? = null): String = + makeJwt { + if (email != null) put("email", email) + if (name != null) put("name", name) + put("exp", (System.currentTimeMillis() / 1000) + 3600) + put( + Constants.AUTH_CLAIM, + buildJsonObject { + put("chatgpt_account_id", accountId) + if (plan != null) put("chatgpt_plan_type", plan) + }, + ) + } + + @Test + fun `decodes a payload`() { + val token = makeJwt { put("hello", "world"); put("n", 1) } + val decoded = decodeJwt(token) as JsonObject + assertEquals("world", decoded["hello"]?.jsonPrimitive?.contentOrNull) + assertEquals(1, decoded["n"]?.jsonPrimitive?.intOrNull) + } + + @Test + fun `returns null for malformed tokens`() { + assertNull(decodeJwt("not-a-jwt")) + assertNull(decodeJwt(null)) + assertNull(decodeJwt("a.b")) + } + + @Test + fun `derives the ChatGPT account id from the auth claim`() { + assertEquals("acct_xyz", deriveAccountId(makeIdToken(accountId = "acct_xyz"))) + } + + @Test + fun `returns null account id when the claim is absent`() { + assertNull(deriveAccountId(makeJwt { put("sub", "u") })) + } + + @Test + fun `reads token expiry in milliseconds`() { + val token = makeJwt { put("exp", 2_000_000_000L) } + assertEquals(2_000_000_000_000L, getTokenExpiry(token)) + } + + @Test + fun `parses a public user profile`() { + val token = makeIdToken(accountId = "acct_1", email = "a@b.dev", name = "Ada", plan = "pro") + assertEquals(ChatGPTUser(accountId = "acct_1", email = "a@b.dev", name = "Ada", plan = "pro"), parseUser(token)) + } + + @Test + fun `returns null user when account id is missing`() { + assertNull(parseUser(makeJwt { put("email", "x@y.dev") })) + } +} diff --git a/android/sample/build.gradle.kts b/android/sample/build.gradle.kts new file mode 100644 index 0000000..13db3ab --- /dev/null +++ b/android/sample/build.gradle.kts @@ -0,0 +1,50 @@ +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") + id("org.jetbrains.kotlin.plugin.compose") +} + +android { + namespace = "com.opencoredev.loginwithchatgpt.sample" + compileSdk = 36 + + defaultConfig { + applicationId = "com.opencoredev.loginwithchatgpt.sample" + minSdk = 24 + targetSdk = 36 + versionCode = 1 + versionName = "0.1.0" + } + + buildFeatures { + compose = true + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + buildTypes { + getByName("release") { + isMinifyEnabled = false + } + } +} + +kotlin { + jvmToolchain(17) +} + +dependencies { + implementation(project(":lwc-android")) + + implementation(platform("androidx.compose:compose-bom:2024.10.01")) + implementation("androidx.compose.ui:ui") + implementation("androidx.compose.material3:material3") + implementation("androidx.compose.ui:ui-tooling-preview") + debugImplementation("androidx.compose.ui:ui-tooling") + implementation("androidx.activity:activity-compose:1.9.3") + implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7") + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3") +} diff --git a/android/sample/src/main/AndroidManifest.xml b/android/sample/src/main/AndroidManifest.xml new file mode 100644 index 0000000..c97f6ac --- /dev/null +++ b/android/sample/src/main/AndroidManifest.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + diff --git a/android/sample/src/main/kotlin/com/opencoredev/loginwithchatgpt/sample/MainActivity.kt b/android/sample/src/main/kotlin/com/opencoredev/loginwithchatgpt/sample/MainActivity.kt new file mode 100644 index 0000000..20b8ec4 --- /dev/null +++ b/android/sample/src/main/kotlin/com/opencoredev/loginwithchatgpt/sample/MainActivity.kt @@ -0,0 +1,220 @@ +package com.opencoredev.loginwithchatgpt.sample + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.opencoredev.loginwithchatgpt.ChatGPTUser +import com.opencoredev.loginwithchatgpt.Constants +import com.opencoredev.loginwithchatgpt.DeviceCode +import com.opencoredev.loginwithchatgpt.DevicePollResult +import com.opencoredev.loginwithchatgpt.android.LoginWithChatGPT +import com.opencoredev.loginwithchatgpt.textPromptBody +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val lwc = LoginWithChatGPT(applicationContext) + setContent { + MaterialTheme { + Surface(modifier = Modifier.fillMaxSize()) { + AppScreen(lwc) + } + } + } + } +} + +@Composable +private fun AppScreen(lwc: LoginWithChatGPT) { + val scope = rememberCoroutineScope() + var loading by remember { mutableStateOf(true) } + var user by remember { mutableStateOf(null) } + var device by remember { mutableStateOf(null) } + var error by remember { mutableStateOf(null) } + + LaunchedEffect(Unit) { + user = lwc.currentUser() + loading = false + } + + when { + loading -> Centered { CircularProgressIndicator() } + + user != null -> ChatScreen( + lwc = lwc, + user = user!!, + onLogout = { scope.launch { lwc.logout(); user = null } }, + ) + + device != null -> PendingScreen(device = device!!, error = error, onReopen = { lwc.openVerification(device!!) }) + + else -> LoginScreen(error = error, onLogin = { + error = null + scope.launch { + try { + val d = lwc.startDeviceLogin() + device = d + lwc.openVerification(d) + while (true) { + if (System.currentTimeMillis() > d.expiresAt) { + error = "Code expired — try again." + device = null + break + } + val result = lwc.poll(d) + if (result is DevicePollResult.Authorized) { + user = lwc.currentUser() + device = null + break + } + delay(d.interval * 1000L) + } + } catch (e: Exception) { + error = e.message ?: "Login failed." + device = null + } + } + }) + } +} + +@Composable +private fun LoginScreen(error: String?, onLogin: () -> Unit) { + Centered { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text("Login with ChatGPT", style = MaterialTheme.typography.headlineSmall) + Spacer(Modifier.height(8.dp)) + Text( + "Sign in with your own ChatGPT account. Models run on your plan — " + + "no API key, and usage is billed to you.", + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(24.dp)) + Button(onClick = onLogin) { Text("Sign in with ChatGPT") } + if (error != null) { + Spacer(Modifier.height(16.dp)) + Text(error, color = MaterialTheme.colorScheme.error, textAlign = TextAlign.Center) + } + } + } +} + +@Composable +private fun PendingScreen(device: DeviceCode, error: String?, onReopen: () -> Unit) { + Centered { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text("Enter this code on OpenAI", style = MaterialTheme.typography.titleMedium) + Spacer(Modifier.height(12.dp)) + Text(device.userCode, style = MaterialTheme.typography.displaySmall) + Spacer(Modifier.height(24.dp)) + CircularProgressIndicator() + Spacer(Modifier.height(16.dp)) + Text("Waiting for authorization…", style = MaterialTheme.typography.bodyMedium) + Spacer(Modifier.height(16.dp)) + OutlinedButton(onClick = onReopen) { Text("Reopen verification page") } + if (error != null) { + Spacer(Modifier.height(16.dp)) + Text(error, color = MaterialTheme.colorScheme.error) + } + } + } +} + +@Composable +private fun ChatScreen(lwc: LoginWithChatGPT, user: ChatGPTUser, onLogout: () -> Unit) { + val scope = rememberCoroutineScope() + var prompt by remember { mutableStateOf("") } + var reply by remember { mutableStateOf("") } + var streaming by remember { mutableStateOf(false) } + var error by remember { mutableStateOf(null) } + + Column(modifier = Modifier.fillMaxSize().padding(16.dp)) { + Text("Signed in as ${user.email ?: user.accountId}", style = MaterialTheme.typography.titleMedium) + Text( + "Plan: ${user.plan ?: "unknown"} · billed to this ChatGPT account", + style = MaterialTheme.typography.bodySmall, + ) + Spacer(Modifier.height(16.dp)) + + OutlinedTextField( + value = prompt, + onValueChange = { prompt = it }, + label = { Text("Ask something") }, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(12.dp)) + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Button( + onClick = { + if (prompt.isBlank() || streaming) return@Button + error = null + reply = "" + streaming = true + val body = textPromptBody(Constants.DEFAULT_MODEL, prompt) + scope.launch { + try { + lwc.chat(body).collect { reply += it } + } catch (e: Exception) { + error = e.message ?: "Request failed." + } finally { + streaming = false + } + } + }, + enabled = !streaming, + ) { Text(if (streaming) "Streaming…" else "Send") } + + OutlinedButton(onClick = onLogout) { Text("Log out") } + } + + Spacer(Modifier.height(16.dp)) + if (error != null) { + Text(error!!, color = MaterialTheme.colorScheme.error) + Spacer(Modifier.height(8.dp)) + } + Text( + reply, + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.fillMaxWidth().verticalScroll(rememberScrollState()), + ) + } +} + +@Composable +private fun Centered(content: @Composable () -> Unit) { + Box(modifier = Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) { + content() + } +} diff --git a/android/sample/src/main/res/values/themes.xml b/android/sample/src/main/res/values/themes.xml new file mode 100644 index 0000000..97c7d40 --- /dev/null +++ b/android/sample/src/main/res/values/themes.xml @@ -0,0 +1,5 @@ + + + +