From 06953d86509d8f7ca9b27cd45f062a659eada6f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Herna=CC=81ndez?= Date: Wed, 6 Nov 2024 17:43:20 +0100 Subject: [PATCH 1/2] Add support for generating user-based identity tokens --- .github/docs/README.md | 20 +++++++- .../gcp/auth/pureconfig/TokenType.scala | 17 +++++++ .../permutive/gcp/auth/TokenProvider.scala | 34 ++++++++++++++ .../gcp/auth/TokenProviderSuite.scala | 46 ++++++++++++++++--- 4 files changed, 110 insertions(+), 7 deletions(-) diff --git a/.github/docs/README.md b/.github/docs/README.md index dddd9af..f2a5c23 100644 --- a/.github/docs/README.md +++ b/.github/docs/README.md @@ -42,7 +42,7 @@ val pathToRefreshTokenPath = Path("") val privateKey = KeyPairGenerator.getInstance("RSA").generateKeyPair().getPrivate().asInstanceOf[RSAPrivateKey] ``` -#### Identity +#### Identity (via service-account) Retrieves an [Identity Token] using Google's metadata server for a specific audience. @@ -59,6 +59,21 @@ val audience = uri"https://my-run-app.a.run.app" TokenProvider.identity[IO](httpClient, audience) ``` +#### Identity (via user-account) + +Retrieves an [Identity Token] using your user account credentials. + +Identity tokens can be used for calling Cloud Run services. + +**Warning!** Be sure to keep these tokens secure, and never use them in a +production environment. They are meant to be used during development only. + +```scala mdoc:silent +import com.permutive.gcp.auth.TokenProvider + +TokenProvider.userIdentity[IO](httpClient) +``` + #### Service-Account Retrieves a [Google Service Account Token] either via the @@ -206,13 +221,16 @@ import cats.effect.Resource import org.http4s.client.Client import org.http4s.Response +import org.http4s.Uri val httpClient: Client[IO] = Client[IO] { _ => Resource.pure(Response[IO]())} val config = Config(TokenType.UserAccount) +val myAudience = Uri.unsafeFromString("https://my-run-app.a.run.app") ``` ```scala mdoc:silent val tokenProvider = config.tokenType.tokenProvider(httpClient) +val identityTokenProvider = config.tokenType.identityTokenProvider(httpClient, myAudience) ``` ## Contributors to this project diff --git a/modules/gcp-auth-pureconfig/src/main/scala/com/permutive/gcp/auth/pureconfig/TokenType.scala b/modules/gcp-auth-pureconfig/src/main/scala/com/permutive/gcp/auth/pureconfig/TokenType.scala index 5be0b21..96f2daa 100644 --- a/modules/gcp-auth-pureconfig/src/main/scala/com/permutive/gcp/auth/pureconfig/TokenType.scala +++ b/modules/gcp-auth-pureconfig/src/main/scala/com/permutive/gcp/auth/pureconfig/TokenType.scala @@ -17,12 +17,14 @@ package com.permutive.gcp.auth.pureconfig import cats.effect.Concurrent +import cats.effect.kernel.Clock import cats.syntax.all._ import _root_.pureconfig.ConfigReader import com.permutive.gcp.auth.TokenProvider import com.permutive.gcp.auth.models.AccessToken import fs2.io.file.Files +import org.http4s.Uri import org.http4s.client.Client /** Provides a convenient way to initialise a [[com.permutive.gcp.auth.TokenProvider TokenProvider]] using pureconfig. @@ -47,6 +49,21 @@ sealed trait TokenType { case TokenType.NoOp => TokenProvider.const(AccessToken.noop).pure[F] } + /** Creates a [[com.permutive.gcp.auth.TokenProvider TokenProvider]] that provides identity tokens using a different + * method depending on the instance: + * + * - [[TokenType.UserAccount]]: the provider will be created using `TokenProvider.userIdentity`. + * - [[TokenType.ServiceAccount]]: the provider will be created using `TokenProvider.identity`. + * - [[TokenType.NoOp]]: will return a provider that always returns + * [[com.permutive.gcp.auth.models.AccessToken.noop AccessToken.noop]]. + */ + def identityTokenProvider[F[_]: Files: Concurrent: Clock](httpClient: Client[F], audience: Uri): F[TokenProvider[F]] = + this match { + case TokenType.UserAccount => TokenProvider.userIdentity[F](httpClient) + case TokenType.ServiceAccount => TokenProvider.identity[F](httpClient, audience).pure[F] + case TokenType.NoOp => TokenProvider.const(AccessToken.noop).pure[F] + } + } object TokenType { diff --git a/modules/gcp-auth/src/main/scala/com/permutive/gcp/auth/TokenProvider.scala b/modules/gcp-auth/src/main/scala/com/permutive/gcp/auth/TokenProvider.scala index 85208b0..1efac09 100644 --- a/modules/gcp-auth/src/main/scala/com/permutive/gcp/auth/TokenProvider.scala +++ b/modules/gcp-auth/src/main/scala/com/permutive/gcp/auth/TokenProvider.scala @@ -47,12 +47,15 @@ import com.permutive.gcp.auth.models.Token import com.permutive.refreshable.Refreshable import fs2.io.file.Files import fs2.io.file.Path +import io.circe.Decoder +import io.circe.Json import org.http4s.Header import org.http4s.Method.GET import org.http4s.Method.POST import org.http4s.Request import org.http4s.Uri import org.http4s.UrlForm +import org.http4s.circe._ import org.http4s.client.Client import org.http4s.syntax.all._ import org.typelevel.ci._ @@ -182,6 +185,37 @@ object TokenProvider { .adaptError { case t => new UnableToGetToken(t) } } + /** Retrieves an identity token using your user account credentials. + * + * Identity tokens can be used for calling Cloud Run services. + * + * '''Warning!''' Be sure to keep these tokens secure, and never use them in a production environment. They are meant + * to be used during development only. + * + * @see + * https://cloud.google.com/run/docs/securing/service-identity#fetching_identity_and_access_tokens_using_the_metadata_server + */ + def userIdentity[F[_]: Concurrent: Files](httpClient: Client[F]): F[TokenProvider[F]] = + Parser.applicationDefaultCredentials.map { case (clientId, clientSecret, refreshToken) => + TokenProvider.create { + val form = UrlForm( + "refresh_token" -> refreshToken.value, + "client_id" -> clientId.value, + "client_secret" -> clientSecret.value, + "grant_type" -> "refresh_token" + ) + + val request = Request[F](POST, uri"https://oauth2.googleapis.com/token").withEntity(form) + + val decoder = Decoder.forProduct2("id_token", "expires_in")(AccessToken.apply) + + httpClient + .expect[Json](request) + .flatMap(_.as[AccessToken](decoder).liftTo[F]) + .adaptError { case t => new UnableToGetToken(t) } + } + } + /** Retrieves a workload service account token using Google's metadata server. * * You can then user the service account token to send authenticated requests to GCP services, such as Vertex-AI, diff --git a/modules/gcp-auth/src/test/scala/com/permutive/gcp/auth/TokenProviderSuite.scala b/modules/gcp-auth/src/test/scala/com/permutive/gcp/auth/TokenProviderSuite.scala index cf4da68..37674e3 100644 --- a/modules/gcp-auth/src/test/scala/com/permutive/gcp/auth/TokenProviderSuite.scala +++ b/modules/gcp-auth/src/test/scala/com/permutive/gcp/auth/TokenProviderSuite.scala @@ -114,6 +114,36 @@ class TokenProviderSuite extends CatsEffectSuite with Http4sMUnitSyntax { interceptIO[UnableToGetToken](tokenProvider.accessToken) } + //////////////////////////// + // TokenProvider.userIdentity // + //////////////////////////// + + fixture("/default/valid").test { + "TokenProvider.userIdentity retrieves and calculates expiration" + } { _ => + val client = Client.from { case POST -> Root / "token" => + Ok(Json.obj("id_token" := "token", "expires_in" := 3600)) + } + + for { + tokenProvider <- TokenProvider.userIdentity[IO](client) + token <- tokenProvider.accessToken + } yield { + assert(token.token.value.nonEmpty) + assertEquals(token.expiresIn.value, 3600L) + } + } + + fixture("/").test { + "TokenProvider.userIdentity returns an error when default credentials cannot be found" + } { _ => + val client = Client.fromHttpApp(HttpApp.notFound[IO]) + + interceptIO[UnableToGetDefaultCredentials] { + TokenProvider.userIdentity[IO](client).flatMap(_.accessToken) + } + } + ////////////////////////////////////////// // TokenProvider.serviceAccount(Client) // ////////////////////////////////////////// @@ -230,12 +260,6 @@ class TokenProviderSuite extends CatsEffectSuite with Http4sMUnitSyntax { // TokenProvider.userAccount(Client) // /////////////////////////////////////// - def fixture(resource: String) = ResourceFunFixture { - Resource.make { - IO(sys.props("user.home")).flatTap(_ => IO(sys.props.put("user.home", getClass.getResource(resource).getPath()))) - }(userHome => IO(sys.props.put("user.home", userHome)).void) - } - fixture("/default/valid").test { "TokenProvider.userAccount(Client) retrieves token successfully" } { _ => @@ -341,6 +365,16 @@ class TokenProviderSuite extends CatsEffectSuite with Http4sMUnitSyntax { assertIO(result, "Success!") } + ////////////// + // Fixtures // + ////////////// + + def fixture(resource: String) = ResourceFunFixture { + Resource.make { + IO(sys.props("user.home")).flatTap(_ => IO(sys.props.put("user.home", getClass.getResource(resource).getPath()))) + }(userHome => IO(sys.props.put("user.home", userHome)).void) + } + private def resourcePath(file: String) = fs2.io.file.Path(getClass.getResource("/").getPath()) / file implicit private class RequestTestOps(request: Request[IO]) { From 28e96fb922394ae5654f2eb6ba1f97ae56962980 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Herna=CC=81ndez?= Date: Wed, 6 Nov 2024 17:50:51 +0100 Subject: [PATCH 2/2] Relax `versionPolicyIntention` --- build.sbt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.sbt b/build.sbt index 0478f3b..0c4309a 100644 --- a/build.sbt +++ b/build.sbt @@ -1,7 +1,7 @@ ThisBuild / scalaVersion := "2.13.14" ThisBuild / crossScalaVersions := Seq("2.12.19", "2.13.14", "3.3.3") ThisBuild / organization := "com.permutive" -ThisBuild / versionPolicyIntention := Compatibility.BinaryAndSourceCompatible +ThisBuild / versionPolicyIntention := Compatibility.BinaryCompatible addCommandAlias("ci-test", "fix --check; versionPolicyCheck; mdoc; publishLocal; +test") addCommandAlias("ci-docs", "github; mdoc; headerCreateAll")