Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for generating user-based identity tokens #15

Merged
merged 2 commits into from
Nov 6, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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")
alejandrohdezma marked this conversation as resolved.
Show resolved Hide resolved
```

```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