Skip to content

Commit

Permalink
Merge pull request #15 from permutive-engineering/identity-token
Browse files Browse the repository at this point in the history
Add support for generating user-based identity tokens
  • Loading branch information
alejandrohdezma authored Nov 6, 2024
2 parents 971e6a4 + 28e96fb commit 6e7b6f8
Show file tree
Hide file tree
Showing 5 changed files with 111 additions and 8 deletions.
20 changes: 19 additions & 1 deletion .github/docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion build.sbt
Original file line number Diff line number Diff line change
@@ -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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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._
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) //
//////////////////////////////////////////
Expand Down Expand Up @@ -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"
} { _ =>
Expand Down Expand Up @@ -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]) {
Expand Down

0 comments on commit 6e7b6f8

Please sign in to comment.