Add OAuth login to text client (#8610)

This commit is contained in:
2026-07-15 11:56:42 -07:00
committed by GitHub
parent f2f81d1cda
commit 3a04d865b6
6 changed files with 371 additions and 21 deletions
+6 -8
View File
@@ -4,20 +4,18 @@ This guide covers operating the Eagle text client against a running Eagle server
## Authentication And Startup
The current bearer-token file is:
```
/private/tmp/eagle0-text-client-token.txt
```
If that file is absent or authentication fails, stop and ask the user for a current bearer token. Do not invent a token or use another user's credentials.
The text client can request and refresh its own tokens. GitHub is the recommended OAuth provider:
Start the production client with:
```bash
bazel run //src/main/scala/net/eagle0/eagle/text_client:eagle_text_client -- --tls --host prod.eagle0.net --port 443 --bearer-token-file /private/tmp/eagle0-text-client-token.txt
bazel run //src/main/scala/net/eagle0/eagle/text_client:eagle_text_client -- --tls --host prod.eagle0.net --port 443 --oauth
```
On the first run, or when no usable refresh token remains, the client prints a GitHub login URL. Paste that URL to the user and wait for them to authenticate in their browser. The callback returns to a temporary loopback listener in the text client, not to the Unity client. Never open the URL or choose an account on the user's behalf.
The client stores the access and refresh tokens in `~/.eagle0/text-client/` with owner-only permissions and refreshes the access token automatically. For an existing token location, pass both `--bearer-token-file <path>` and `--refresh-token-file <path>`. Use `--oauth-provider <provider>` only if the account uses `google`, `discord`, `apple`, `steam`, or `twitch` instead of the recommended `github` provider.
At the `eagle0>` prompt, use `lobby` to list running games and available new-game leaders. To resume a game, use:
```text
@@ -2,9 +2,13 @@ load("@rules_scala//scala:scala.bzl", "scala_binary", "scala_library")
scala_library(
name = "eagle_text_client_lib",
srcs = ["EagleTextClient.scala"],
srcs = [
"EagleTextClient.scala",
"TextClientAuth.scala",
],
visibility = ["//visibility:public"],
deps = [
"//src/main/protobuf/net/eagle0/eagle/api:auth_scala_grpc",
"//src/main/protobuf/net/eagle0/eagle/api:available_command_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/api:command_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/api:eagle_scala_grpc",
@@ -61,7 +61,7 @@ object EagleTextClient {
Metadata.Key.of("X-Eagle-Client-Type", Metadata.ASCII_STRING_MARSHALLER)
def main(args: Array[String]): Unit = {
val config = Config.fromArgs(args.toVector)
val config = TextClientAuth.authenticate(Config.fromArgs(args.toVector))
val client = new EagleTextClient(config)
client.run()
}
@@ -72,6 +72,9 @@ final case class Config(
port: Int = 40032,
useTls: Boolean = false,
bearerToken: Option[String] = None,
bearerTokenFile: Option[String] = None,
refreshTokenFile: Option[String] = None,
oauthProvider: Option[net.eagle0.eagle.api.auth.auth.OAuthProvider] = None,
warmupUser: Option[String] = Some("codex-text-client")
) {
val address: String = s"$host:$port"
@@ -81,11 +84,11 @@ object Config {
def fromArgs(args: Vector[String]): Config = {
def loop(remaining: Vector[String], config: Config): Config =
remaining.headOption match {
case None => config
case None => loadConfiguredBearerToken(config)
case Some("--host") => loop(remaining.drop(2), config.copy(host = requiredValue(remaining, "--host")))
case Some("--port") => loop(remaining.drop(2), config.copy(port = requiredValue(remaining, "--port").toInt))
case Some("--tls") => loop(remaining.tail, config.copy(useTls = true))
case Some("--bearer-token") =>
case Some("--bearer-token") =>
loop(
remaining.drop(2),
config.copy(
@@ -93,22 +96,48 @@ object Config {
warmupUser = None
)
)
case Some("--bearer-token-file") =>
case Some("--bearer-token-file") =>
loop(
remaining.drop(2),
config.copy(
bearerToken = Some(readBearerTokenFile(requiredValue(remaining, "--bearer-token-file"))),
bearerTokenFile = Some(requiredValue(remaining, "--bearer-token-file")),
warmupUser = None
)
)
case Some("--plaintext") => loop(remaining.tail, config.copy(useTls = false))
case Some("--warmup-user") =>
case Some("--refresh-token-file") =>
loop(
remaining.drop(2),
config.copy(
refreshTokenFile = Some(requiredValue(remaining, "--refresh-token-file")),
warmupUser = None
)
)
case Some("--oauth") =>
loop(
remaining.tail,
config.copy(
oauthProvider = Some(TextClientAuth.parseProvider("github")),
warmupUser = None
)
)
case Some("--oauth-provider") =>
loop(
remaining.drop(2),
config.copy(
oauthProvider = Some(
TextClientAuth.parseProvider(requiredValue(remaining, "--oauth-provider"))
),
warmupUser = None
)
)
case Some("--plaintext") => loop(remaining.tail, config.copy(useTls = false))
case Some("--warmup-user") =>
loop(remaining.drop(2), config.copy(warmupUser = Some(requiredValue(remaining, "--warmup-user"))))
case Some("--no-warmup-user") => loop(remaining.tail, config.copy(warmupUser = None))
case Some("--help") =>
case Some("--no-warmup-user") => loop(remaining.tail, config.copy(warmupUser = None))
case Some("--help") =>
println(Help.text)
sys.exit(0)
case Some(unknown) =>
case Some(unknown) =>
throw new IllegalArgumentException(s"Unknown option: $unknown")
}
@@ -118,6 +147,15 @@ object Config {
private def requiredValue(args: Vector[String], option: String): String =
args.drop(1).headOption.getOrElse(throw new IllegalArgumentException(s"$option requires a value"))
private def loadConfiguredBearerToken(config: Config): Config =
config.bearerTokenFile match {
case Some(path) if Files.exists(Paths.get(path)) =>
config.copy(bearerToken = Some(readBearerTokenFile(path)))
case Some(path) if config.oauthProvider.isEmpty && config.refreshTokenFile.isEmpty =>
throw new IllegalArgumentException(s"Bearer token file does not exist: $path")
case _ => config
}
private def readBearerTokenFile(path: String): String =
Files.readString(Paths.get(path)).trim
}
@@ -1873,6 +1911,9 @@ object Help {
| --plaintext
| --bearer-token <jwt-or-Bearer-header-value>
| --bearer-token-file <path>
| --refresh-token-file <path>
| --oauth (request a token with GitHub, recommended)
| --oauth-provider <provider> (github, google, discord, apple, steam, or twitch)
| --warmup-user <name>
| --no-warmup-user
|
@@ -1922,6 +1963,8 @@ object Help {
| commands-json
|
|Authenticated flow:
| bazel run //src/main/scala/net/eagle0/eagle/text_client:eagle_text_client -- --tls --host prod.eagle0.net --port 443 --bearer-token-file /path/to/token.txt
| bazel run //src/main/scala/net/eagle0/eagle/text_client:eagle_text_client -- --tls --host prod.eagle0.net --port 443 --oauth
| Open the printed GitHub URL. The client stores tokens under ~/.eagle0/text-client and refreshes them automatically.
| Use --oauth-provider only when the account uses a provider other than GitHub.
|""".stripMargin
}
@@ -0,0 +1,255 @@
package net.eagle0.eagle.text_client
import java.io.{BufferedReader, InputStreamReader}
import java.net.{InetAddress, ServerSocket, SocketException}
import java.nio.charset.StandardCharsets
import java.nio.file.{Files, Path, Paths, StandardOpenOption}
import java.nio.file.attribute.PosixFilePermissions
import java.time.Instant
import java.util.Base64
import scala.concurrent.duration.*
import scala.concurrent.Await
import scala.jdk.CollectionConverters.*
import scala.util.control.NonFatal
import scala.util.Try
import io.grpc.{ManagedChannel, ManagedChannelBuilder}
import net.eagle0.eagle.api.auth.auth.{
AuthGrpc,
CheckOAuthStatusRequest,
CheckOAuthStatusResponse,
GetOAuthUrlRequest,
OAuthProvider,
OAuthStatus,
RefreshTokenRequest
}
object TextClientAuth {
private val AccessTokenLeewaySeconds = 60L
private val OAuthPollInterval = 1.second
private val OAuthTimeout = 10.minutes
private val RpcTimeout = 30.seconds
private val ExpClaim = "\"exp\"\\s*:\\s*(\\d+)".r
private case class AuthTokens(accessToken: String, refreshToken: Option[String])
def authenticate(config: Config): Config = {
val accessTokenPath = resolvedAccessTokenPath(config)
val refreshTokenPath = resolvedRefreshTokenPath(config)
val refreshToken = readToken(refreshTokenPath)
val usableAccess = config.bearerToken.filter(accessTokenIsUsable(_))
if usableAccess.nonEmpty && refreshToken.isEmpty then config
else if config.oauthProvider.isEmpty && config.refreshTokenFile.isEmpty then config
else {
val channel = buildChannel(config)
try {
val stub = AuthGrpc.stub(channel)
val refreshed = refreshToken.flatMap { token =>
try {
val response = Await.result(stub.refreshToken(RefreshTokenRequest(refreshToken = token)), RpcTimeout)
Option(response.accessToken).filter(_.nonEmpty).map(AuthTokens(_, None))
} catch {
case NonFatal(error) =>
println(s"Stored refresh token was rejected: ${safeError(error)}")
None
}
}
val tokens = refreshed.orElse(usableAccess.map(AuthTokens(_, None))).getOrElse {
val provider = config.oauthProvider.getOrElse(
throw new IllegalArgumentException(
"Authentication must be renewed. Add --oauth (GitHub) or --oauth-provider <provider>."
)
)
requestOAuthTokens(stub, provider)
}
writeToken(accessTokenPath, tokens.accessToken)
tokens.refreshToken.foreach(writeToken(refreshTokenPath, _))
config.copy(
bearerToken = Some(tokens.accessToken),
bearerTokenFile = Some(accessTokenPath.toString),
refreshTokenFile = Some(refreshTokenPath.toString),
warmupUser = None
)
} finally {
channel.shutdownNow()
val _ = channel.awaitTermination(2, java.util.concurrent.TimeUnit.SECONDS)
}
}
end if
}
private[text_client] def parseProvider(value: String): OAuthProvider =
value.toLowerCase match {
case "github" => OAuthProvider.OAUTH_PROVIDER_GITHUB
case "google" => OAuthProvider.OAUTH_PROVIDER_GOOGLE
case "discord" => OAuthProvider.OAUTH_PROVIDER_DISCORD
case "apple" => OAuthProvider.OAUTH_PROVIDER_APPLE
case "steam" => OAuthProvider.OAUTH_PROVIDER_STEAM
case "twitch" => OAuthProvider.OAUTH_PROVIDER_TWITCH
case other =>
throw new IllegalArgumentException(
s"Unknown OAuth provider '$other'. Use github, google, discord, apple, steam, or twitch."
)
}
private[text_client] def accessTokenIsUsable(
token: String,
nowEpochSeconds: Long = Instant.now().getEpochSecond
): Boolean =
jwtExpiration(token).exists(_ > nowEpochSeconds + AccessTokenLeewaySeconds)
private def jwtExpiration(token: String): Option[Long] =
Try {
val jwt = token.stripPrefix("Bearer ")
val payload = jwt.split("\\.", -1)(1)
val json = new String(Base64.getUrlDecoder.decode(payload), StandardCharsets.UTF_8)
ExpClaim.findFirstMatchIn(json).map(_.group(1).toLong)
}.toOption.flatten
private def requestOAuthTokens(
stub: AuthGrpc.AuthStub,
provider: OAuthProvider
): AuthTokens = {
val callback = new LoopbackCallback
try {
val oauth = Await.result(
stub.getOAuthUrl(
GetOAuthUrlRequest(
provider = provider,
returnUrl = callback.returnUrl
)
),
RpcTimeout
)
callback.start()
println(s"Authentication required for ${providerName(provider)}. Open this URL in a browser:\n${oauth.authUrl}")
println("Waiting for browser authentication to complete...")
val deadline = OAuthTimeout.fromNow
var response = CheckOAuthStatusResponse(status = OAuthStatus.OAUTH_STATUS_PENDING)
while response.status == OAuthStatus.OAUTH_STATUS_PENDING && deadline.hasTimeLeft() do {
Thread.sleep(OAuthPollInterval.toMillis)
response = Await.result(
stub.checkOAuthStatus(CheckOAuthStatusRequest(state = oauth.state)),
RpcTimeout
)
}
response.status match {
case OAuthStatus.OAUTH_STATUS_SUCCESS =>
if response.accessToken.isEmpty then
throw new IllegalStateException("OAuth succeeded without an access token")
val displayName = response.user.map(_.displayName).filter(_.nonEmpty).getOrElse("user")
println(s"Authentication succeeded for $displayName.")
AuthTokens(
accessToken = response.accessToken,
refreshToken = Option(response.refreshToken).filter(_.nonEmpty)
)
case OAuthStatus.OAUTH_STATUS_FAILED =>
throw new IllegalStateException(s"OAuth failed: ${response.errorMessage}")
case OAuthStatus.OAUTH_STATUS_EXPIRED =>
throw new IllegalStateException("OAuth request expired; run the client again")
case OAuthStatus.OAUTH_STATUS_INVITATION_REQUIRED =>
throw new IllegalStateException("OAuth account requires an Eagle0 invitation")
case OAuthStatus.OAUTH_STATUS_PENDING =>
throw new IllegalStateException("OAuth request timed out; run the client again")
case status =>
throw new IllegalStateException(s"Unexpected OAuth status: $status")
}
} finally callback.close()
end try
}
private def buildChannel(config: Config): ManagedChannel = {
val builder = ManagedChannelBuilder.forAddress(config.host, config.port)
if config.useTls then builder.useTransportSecurity().build()
else builder.usePlaintext().build()
}
private def providerName(provider: OAuthProvider): String =
provider match {
case OAuthProvider.OAUTH_PROVIDER_GITHUB => "GitHub"
case OAuthProvider.OAUTH_PROVIDER_GOOGLE => "Google"
case OAuthProvider.OAUTH_PROVIDER_DISCORD => "Discord"
case OAuthProvider.OAUTH_PROVIDER_APPLE => "Apple"
case OAuthProvider.OAUTH_PROVIDER_STEAM => "Steam"
case OAuthProvider.OAUTH_PROVIDER_TWITCH => "Twitch"
case other => other.toString
}
private def resolvedAccessTokenPath(config: Config): Path =
config.bearerTokenFile.map(Paths.get(_)).getOrElse(defaultTokenDirectory.resolve("access-token"))
private def resolvedRefreshTokenPath(config: Config): Path =
config.refreshTokenFile.map(Paths.get(_)).getOrElse(defaultTokenDirectory.resolve("refresh-token"))
private def defaultTokenDirectory: Path =
Paths.get(System.getProperty("user.home"), ".eagle0", "text-client")
private def readToken(path: Path): Option[String] =
Option.when(Files.exists(path))(Files.readString(path).trim).filter(_.nonEmpty)
private def writeToken(path: Path, token: String): Unit = {
Option(path.getParent).foreach(Files.createDirectories(_))
Files.writeString(
path,
token.stripPrefix("Bearer ") + System.lineSeparator(),
StandardOpenOption.CREATE,
StandardOpenOption.TRUNCATE_EXISTING,
StandardOpenOption.WRITE
)
val ownerOnly = PosixFilePermissions.fromString("rw-------").asScala.toSet.asJava
val _ = Try(Files.setPosixFilePermissions(path, ownerOnly))
}
private def safeError(error: Throwable): String =
Option(error.getMessage).filter(_.nonEmpty).getOrElse(error.getClass.getSimpleName)
private class LoopbackCallback extends AutoCloseable {
private val server = new ServerSocket(0, 1, InetAddress.getByName("127.0.0.1"))
val returnUrl = s"http://127.0.0.1:${server.getLocalPort}/oauth-complete"
def start(): Unit = {
val callback = new Runnable {
override def run(): Unit =
try {
val socket = server.accept()
try respond(socket)
finally socket.close()
} catch {
case _: SocketException if server.isClosed => ()
case NonFatal(error) => println(s"OAuth callback error: ${safeError(error)}")
}
}
val thread = new Thread(callback, "eagle0-text-client-oauth-callback")
thread.setDaemon(true)
thread.start()
}
private def respond(socket: java.net.Socket): Unit = {
val reader = new BufferedReader(new InputStreamReader(socket.getInputStream, StandardCharsets.UTF_8))
var line = reader.readLine()
while line != null && line.nonEmpty do line = reader.readLine()
val body =
"<!doctype html><html><body><h1>Eagle0 authentication complete</h1>" +
"<p>You may close this tab and return to the text client.</p></body></html>"
val bytes = body.getBytes(StandardCharsets.UTF_8)
val out = socket.getOutputStream
out.write(
s"HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: ${bytes.length}\r\nConnection: close\r\n\r\n"
.getBytes(StandardCharsets.US_ASCII)
)
out.write(bytes)
out.flush()
}
override def close(): Unit = {
val _ = Try(server.close())
}
}
}
@@ -2,8 +2,12 @@ load("@rules_scala//scala:scala.bzl", "scala_test")
scala_test(
name = "client_state_test",
srcs = ["ClientStateTest.scala"],
srcs = [
"ClientStateTest.scala",
"TextClientAuthTest.scala",
],
deps = [
"//src/main/protobuf/net/eagle0/eagle/api:auth_scala_grpc",
"//src/main/protobuf/net/eagle0/eagle/api:eagle_scala_grpc",
"//src/main/protobuf/net/eagle0/eagle/views:action_result_view_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/views:game_state_view_scala_proto",
@@ -0,0 +1,46 @@
package net.eagle0.eagle.text_client
import java.nio.charset.StandardCharsets
import java.util.Base64
import net.eagle0.eagle.api.auth.auth.OAuthProvider
import org.scalatest.flatspec.AnyFlatSpec
import org.scalatest.matchers.should.Matchers
class TextClientAuthTest extends AnyFlatSpec with Matchers {
"Config" should "use GitHub for the default OAuth option" in {
Config.fromArgs(Vector("--oauth")).oauthProvider shouldBe Some(OAuthProvider.OAUTH_PROVIDER_GITHUB)
}
it should "accept an explicit OAuth provider" in {
Config.fromArgs(Vector("--oauth-provider", "google")).oauthProvider shouldBe Some(
OAuthProvider.OAUTH_PROVIDER_GOOGLE
)
}
it should "reject an unknown OAuth provider" in {
val error = intercept[IllegalArgumentException] {
Config.fromArgs(Vector("--oauth-provider", "unknown"))
}
error.getMessage should include("Unknown OAuth provider")
}
"TextClientAuth" should "accept an access token that is not near expiration" in {
TextClientAuth.accessTokenIsUsable(jwt(expiration = 2000L), nowEpochSeconds = 1000L) shouldBe true
}
it should "reject an expired access token" in {
TextClientAuth.accessTokenIsUsable(jwt(expiration = 999L), nowEpochSeconds = 1000L) shouldBe false
}
it should "reject an access token within the refresh leeway" in {
TextClientAuth.accessTokenIsUsable(jwt(expiration = 1060L), nowEpochSeconds = 1000L) shouldBe false
}
private def jwt(expiration: Long): String = {
val encoder = Base64.getUrlEncoder.withoutPadding()
val header = encoder.encodeToString("{\"alg\":\"none\"}".getBytes(StandardCharsets.UTF_8))
val payload = encoder.encodeToString(s"{\"exp\":$expiration}".getBytes(StandardCharsets.UTF_8))
s"$header.$payload.signature"
}
}