mirror of
https://github.com/nolen777/eagle0.git
synced 2026-07-28 21:35:42 +00:00
Add OAuth login to text client (#8610)
This commit is contained in:
@@ -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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user