From 8b9c2370319eb755dd8d5166c2796336241a2dcf Mon Sep 17 00:00:00 2001 From: Andreas Herrmann <42969706+aherrmann-da@users.noreply.github.com> Date: Fri, 9 Oct 2020 18:43:56 +0200 Subject: [PATCH] map ledger-api claims to scopes for auth0 (#7629) * Define mapping from claims to scope changelog_begin changelog_end * Update auth0 instructions changelog_begin changelog_end Co-authored-by: Andreas Herrmann --- triggers/service/auth/BUILD.bazel | 8 +++ .../scala/com/daml/oauth/middleware/README.md | 36 +++++-------- .../com/daml/oauth/middleware/Request.scala | 49 ++++++++++++++++- .../com/daml/oauth/middleware/Server.scala | 32 +++++++++--- .../scala/com/daml/oauth/server/Request.scala | 10 ++-- .../scala/com/daml/oauth/server/Server.scala | 9 +++- .../com/daml/oauth/middleware/Test.scala | 52 +++++++++++++------ .../scala/com/daml/oauth/server/Client.scala | 4 +- 8 files changed, 148 insertions(+), 52 deletions(-) diff --git a/triggers/service/auth/BUILD.bazel b/triggers/service/auth/BUILD.bazel index 349b627435..5830a02010 100644 --- a/triggers/service/auth/BUILD.bazel +++ b/triggers/service/auth/BUILD.bazel @@ -17,6 +17,9 @@ da_scala_library( visibility = ["//visibility:public"], deps = [ ":oauth-test-server", # TODO[AH] Extract OAuth2 request/response types + "//daml-lf/data", + "//ledger-service/jwt", + "//ledger/ledger-api-auth", "//libs-scala/ports", "@maven//:com_github_scopt_scopt_2_12", "@maven//:com_typesafe_akka_akka_actor_2_12", @@ -27,6 +30,7 @@ da_scala_library( "@maven//:com_typesafe_akka_akka_stream_2_12", "@maven//:com_typesafe_scala_logging_scala_logging_2_12", "@maven//:io_spray_spray_json_2_12", + "@maven//:org_scalaz_scalaz_core_2_12", "@maven//:org_slf4j_slf4j_api", ], ) @@ -107,8 +111,11 @@ da_scala_test( deps = [ ":oauth-middleware", ":oauth-test-server", + "//daml-lf/data", "//ledger-api/rs-grpc-bridge", "//ledger-api/testing-utils", + "//ledger-service/jwt", + "//ledger/ledger-api-auth", "//libs-scala/ports", "//libs-scala/resources", "@maven//:com_typesafe_akka_akka_actor_2_12", @@ -119,5 +126,6 @@ da_scala_test( "@maven//:com_typesafe_akka_akka_stream_2_12", "@maven//:com_typesafe_scala_logging_scala_logging_2_12", "@maven//:io_spray_spray_json_2_12", + "@maven//:org_scalaz_scalaz_core_2_12", ], ) diff --git a/triggers/service/auth/src/main/scala/com/daml/oauth/middleware/README.md b/triggers/service/auth/src/main/scala/com/daml/oauth/middleware/README.md index ad69d6187b..9aa54f2553 100644 --- a/triggers/service/auth/src/main/scala/com/daml/oauth/middleware/README.md +++ b/triggers/service/auth/src/main/scala/com/daml/oauth/middleware/README.md @@ -21,33 +21,21 @@ repository](https://github.com/digital-asset/ex-secure-daml-infra). * Create a new native application. - Provide a name (`ex-daml-auth-middleware`). - Select the authorized API (`ex-daml-api`). - - Configure the allowed callback URLs in the settings (`http://localhost:3000`). + - Configure the allowed callback URLs in the settings (`http://localhost:3000/cb`). - Note the "Client ID" and "Client Secret" displayed in the "Basic Information" pane of the application settings. - Note the "OAuth Authorization URL" and the "OAuth Token URL" in the "Endpoints" tab of the advanced settings. -* Create a "Hook" for "Client Credential Exchange". +* Create a new empty rule. - Provide a name (`ex-daml-claims`). - Provide a script ``` javascript - /** - @param {object} client - information about the client - @param {string} client.name - name of client - @param {string} client.id - client id - @param {string} client.tenant - Auth0 tenant name - @param {object} client.metadata - client metadata - @param {array|undefined} scope - array of strings representing the scope claim or undefined - @param {string} audience - token's audience claim - @param {object} context - additional authorization context - @param {object} context.webtask - webtask context - @param {function} cb - function (error, accessTokenClaims) - */ - module.exports = function(client, scope, audience, context, cb) { - var access_token = {}; + function (user, context, callback) { + // Grant all requested claims + const scope = (context.request.query.scope || "").split(" "); var actAs = []; var readAs = []; var admin = false; - // TODO[AH] specify mapping from scope to ledger claims. scope.forEach(s => { if (s.startsWith("actAs:")) { actAs.push(s.slice(6)); @@ -56,17 +44,21 @@ repository](https://github.com/digital-asset/ex-secure-daml-infra). } else if (s.startsWith("admin")) { admin = true; } - }) - access_token['https://daml.com/ledger-api'] = { + }); + + // Construct access token. + const namespace = 'https://daml.com/ledger-api'; + context.accessToken[namespace] = { // NOTE change the ledger ID to match your deployment. "ledgerId": "2D105384-CE61-4CCC-8E0E-37248BA935A3", - "applicationId": client.name, + "applicationId": context.clientName, "actAs": actAs, "readAs": readAs, "admin": admin }; - cb(null, access_token); - }; + + return callback(null, user, context); + } ``` * Create a new user. - Provide an email address (`alice@localhost`) diff --git a/triggers/service/auth/src/main/scala/com/daml/oauth/middleware/Request.scala b/triggers/service/auth/src/main/scala/com/daml/oauth/middleware/Request.scala index b3484c35ec..cfae792014 100644 --- a/triggers/service/auth/src/main/scala/com/daml/oauth/middleware/Request.scala +++ b/triggers/service/auth/src/main/scala/com/daml/oauth/middleware/Request.scala @@ -3,7 +3,10 @@ package com.daml.oauth.middleware +import akka.http.scaladsl.marshalling.Marshaller import akka.http.scaladsl.model.Uri +import akka.http.scaladsl.unmarshalling.Unmarshaller +import com.daml.lf.data.Ref.Party import spray.json.{ DefaultJsonProtocol, JsString, @@ -13,18 +16,60 @@ import spray.json.{ deserializationError } +import scala.collection.mutable.ArrayBuffer +import scala.concurrent._ +import scala.util.Try + object Request { + case class Claims(admin: Boolean, actAs: List[Party], readAs: List[Party]) { + def toQueryString() = { + val adminS = if (admin) Stream("admin") else Stream() + val actAsS = actAs.toStream.map(party => s"actAs:$party") + val readAsS = readAs.toStream.map(party => s"readAs:$party") + (adminS ++ actAsS ++ readAsS).mkString(" ") + } + } + object Claims { + def apply( + admin: Boolean = false, + actAs: List[Party] = List(), + readAs: List[Party] = List()): Claims = + new Claims(admin, actAs, readAs) + implicit val marshalRequestEntity: Marshaller[Claims, String] = + Marshaller.opaque(_.toQueryString) + implicit val unmarshalHttpEntity: Unmarshaller[String, Claims] = + Unmarshaller { _ => s => + var admin = false + val actAs = ArrayBuffer[Party]() + val readAs = ArrayBuffer[Party]() + Future.fromTry(Try { + s.split(' ').foreach { w => + if (w == "admin") { + admin = true + } else if (w.startsWith("actAs:")) { + actAs.append(Party.assertFromString(w.stripPrefix("actAs:"))) + } else if (w.startsWith("readAs:")) { + readAs.append(Party.assertFromString(w.stripPrefix("readAs:"))) + } else { + throw new IllegalArgumentException(s"Expected claim but got $w") + } + } + Claims(admin, actAs.toList, readAs.toList) + }) + } + } + /** Auth endpoint query parameters */ - case class Auth(claims: String) // TODO[AH] parse ledger claims + case class Auth(claims: Claims) /** Login endpoint query parameters * * @param redirectUri Redirect target after the login flow completed. I.e. the original request URI on the trigger service. * @param claims Required ledger claims. */ - case class Login(redirectUri: Uri, claims: String) // TODO[AH] parse ledger claims + case class Login(redirectUri: Uri, claims: Claims) } diff --git a/triggers/service/auth/src/main/scala/com/daml/oauth/middleware/Server.scala b/triggers/service/auth/src/main/scala/com/daml/oauth/middleware/Server.scala index 1a5388b131..8dc2f5aab0 100644 --- a/triggers/service/auth/src/main/scala/com/daml/oauth/middleware/Server.scala +++ b/triggers/service/auth/src/main/scala/com/daml/oauth/middleware/Server.scala @@ -17,6 +17,11 @@ import akka.http.scaladsl.unmarshalling.{Unmarshal, Unmarshaller} import com.daml.oauth.server.{Request => OAuthRequest, Response => OAuthResponse} import com.typesafe.scalalogging.StrictLogging import java.util.UUID + +import com.daml.jwt.JwtDecoder +import com.daml.jwt.domain.Jwt +import com.daml.ledger.api.auth.AuthServiceJWTCodec + import scala.collection.concurrent.TrieMap import scala.concurrent.{ExecutionContext, Future} import scala.language.postfixOps @@ -64,22 +69,33 @@ object Server extends StrictLogging { optionalCookie(cookieName).map(_.flatMap(f)) } + // Check whether the provided access token grants at least the requested claims. + private def tokenProvidesClaims(accessToken: String, claims: Request.Claims): Boolean = { + for { + decodedJwt <- JwtDecoder.decode(Jwt(accessToken)).toOption + tokenPayload <- AuthServiceJWTCodec.readFromString(decodedJwt.payload).toOption + } yield { + (tokenPayload.admin || !claims.admin) && + tokenPayload.actAs.toSet.subsetOf(claims.actAs.map(_.toString).toSet) && + tokenPayload.readAs.toSet.subsetOf(claims.readAs.map(_.toString).toSet) + } + }.getOrElse(false) + private def auth = - parameters(('claims)) + parameters(('claims.as[Request.Claims])) .as[Request.Auth](Request.Auth) { auth => optionalToken { - // TODO[AH] Implement mapping from scope to claims - // TODO[AH] Check whether granted scope subsumes requested claims - case Some(token) if token.scope == Some(auth.claims) => + case Some(token) if tokenProvidesClaims(token.accessToken, auth.claims) => complete( Response .Authorize(accessToken = token.accessToken, refreshToken = token.refreshToken)) + // TODO[AH] Include a `WWW-Authenticate` header. case _ => complete(StatusCodes.Unauthorized) } } private def login(config: Config, requests: TrieMap[UUID, Uri]) = - parameters(('redirect_uri.as[Uri], 'claims)) + parameters(('redirect_uri.as[Uri], 'claims.as[Request.Claims])) .as[Request.Login](Request.Login) { login => extractRequest { request => val requestId = UUID.randomUUID @@ -88,8 +104,10 @@ object Server extends StrictLogging { responseType = "code", clientId = config.clientId, redirectUri = toRedirectUri(request.uri), - scope = Some(login.claims), - state = Some(requestId.toString)) + scope = Some(login.claims.toQueryString), + state = Some(requestId.toString), + audience = Some("https://daml.com/ledger-api") + ) redirect(config.oauthAuth.withQuery(authorize.toQuery), StatusCodes.Found) } } diff --git a/triggers/service/auth/src/main/scala/com/daml/oauth/server/Request.scala b/triggers/service/auth/src/main/scala/com/daml/oauth/server/Request.scala index e0fedac3d4..d9a350cb22 100644 --- a/triggers/service/auth/src/main/scala/com/daml/oauth/server/Request.scala +++ b/triggers/service/auth/src/main/scala/com/daml/oauth/server/Request.scala @@ -21,7 +21,8 @@ object Request { clientId: String, redirectUri: Uri, // optional in oauth but we require it scope: Option[String], - state: Option[String]) { + state: Option[String], + audience: Option[Uri]) { // required by auth0 to obtain an access_token def toQuery: Query = { var params: Seq[(String, String)] = Seq( @@ -34,6 +35,9 @@ object Request { state.foreach { state => params ++= Seq(("state", state)) } + audience.foreach { audience => + params ++= Seq(("audience", audience.toString)) + } Query(params: _*) } } @@ -54,7 +58,7 @@ object Request { "code" -> token.code, "redirect_uri" -> token.redirectUri.toString, "client_id" -> token.clientId, - "client_secret" -> token.clientSecret + "client_secret" -> token.clientSecret, ) } implicit val unmarshalHttpEntity: Unmarshaller[HttpEntity, Token] = @@ -64,7 +68,7 @@ object Request { code = form.fields.get("code").get, redirectUri = form.fields.get("redirect_uri").get, clientId = form.fields.get("client_id").get, - clientSecret = form.fields.get("client_secret").get + clientSecret = form.fields.get("client_secret").get, ) } } diff --git a/triggers/service/auth/src/main/scala/com/daml/oauth/server/Server.scala b/triggers/service/auth/src/main/scala/com/daml/oauth/server/Server.scala index f158c73954..37a66173ec 100644 --- a/triggers/service/auth/src/main/scala/com/daml/oauth/server/Server.scala +++ b/triggers/service/auth/src/main/scala/com/daml/oauth/server/Server.scala @@ -60,7 +60,14 @@ object Server { val route = concat( path("authorize") { get { - parameters(('response_type, 'client_id, 'redirect_uri.as[Uri], 'scope ?, 'state ?)) + parameters( + ( + 'response_type, + 'client_id, + 'redirect_uri.as[Uri], + 'scope ?, + 'state ?, + 'audience.as[Uri] ?)) .as[Request.Authorize](Request.Authorize) { request => val authorizationCode = UUID.randomUUID() diff --git a/triggers/service/auth/src/test/scala/com/daml/oauth/middleware/Test.scala b/triggers/service/auth/src/test/scala/com/daml/oauth/middleware/Test.scala index 18468b3b9b..0ab3e6d489 100644 --- a/triggers/service/auth/src/test/scala/com/daml/oauth/middleware/Test.scala +++ b/triggers/service/auth/src/test/scala/com/daml/oauth/middleware/Test.scala @@ -9,7 +9,11 @@ import akka.http.scaladsl.model.Uri.{Path, Query} import akka.http.scaladsl.model._ import akka.http.scaladsl.model.headers.{Cookie, Location, `Set-Cookie`} import akka.http.scaladsl.unmarshalling.Unmarshal +import com.daml.jwt.JwtSigner +import com.daml.jwt.domain.DecodedJwt +import com.daml.ledger.api.auth.{AuthServiceJWTCodec, AuthServiceJWTPayload} import com.daml.ledger.api.testing.utils.SuiteResourceManagementAroundAll +import com.daml.lf.data.Ref.Party import com.daml.oauth.server.{Response => OAuthResponse} import org.scalatest.AsyncWordSpec @@ -21,6 +25,30 @@ class Test extends AsyncWordSpec with TestFixture with SuiteResourceManagementAr .withScheme("http") .withAuthority(middlewareBinding.getHostString, middlewareBinding.getPort) } + private def makeToken(claims: Request.Claims): OAuthResponse.Token = { + val jwtHeader = """{"alg": "HS256", "typ": "JWT"}""" + val jwtPayload = AuthServiceJWTPayload( + ledgerId = Some("test-ledger"), + applicationId = Some("test-application"), + participantId = None, + exp = None, + admin = claims.admin, + actAs = claims.actAs, + readAs = claims.readAs + ) + OAuthResponse.Token( + accessToken = JwtSigner.HMAC256 + .sign(DecodedJwt(jwtHeader, AuthServiceJWTCodec.compactPrint(jwtPayload)), "secret") + .getOrElse( + throw new IllegalArgumentException("Failed to sign a token") + ) + .value, + tokenType = "bearer", + expiresIn = None, + refreshToken = None, + scope = Some(claims.toQueryString()) + ) + } "the /auth endpoint" should { "return unauthorized without cookie" in { val claims = "actAs:Alice" @@ -35,18 +63,13 @@ class Test extends AsyncWordSpec with TestFixture with SuiteResourceManagementAr } } "return the token from a cookie" in { - val claims = "actAs:Alice" - val token = OAuthResponse.Token( - accessToken = "access", - tokenType = "bearer", - expiresIn = None, - refreshToken = Some("refresh"), - scope = Some(claims)) + val claims = Request.Claims(actAs = List(Party.assertFromString("Alice"))) + val token = makeToken(claims) val cookieHeader = Cookie("daml-ledger-token", token.toCookieValue) val req = HttpRequest( uri = middlewareUri .withPath(Path./("auth")) - .withQuery(Query(("claims", claims))), + .withQuery(Query(("claims", claims.toQueryString()))), headers = List(cookieHeader)) for { resp <- Http().singleRequest(req) @@ -60,18 +83,15 @@ class Test extends AsyncWordSpec with TestFixture with SuiteResourceManagementAr } } "return unauthorized on insufficient claims" in { - val token = OAuthResponse.Token( - accessToken = "access", - tokenType = "bearer", - expiresIn = None, - refreshToken = Some("refresh"), - scope = Some("actAs:Alice")) + val token = makeToken(Request.Claims(actAs = List(Party.assertFromString("Alice")))) val cookieHeader = Cookie("daml-ledger-token", token.toCookieValue) val req = HttpRequest( uri = middlewareUri .withPath(Path./("auth")) - .withQuery(Query(("claims", "actAs:Bob"))), - headers = List(cookieHeader)) + .withQuery(Query( + ("claims", Request.Claims(actAs = List(Party.assertFromString("Bob"))).toQueryString))), + headers = List(cookieHeader) + ) for { resp <- Http().singleRequest(req) } yield { diff --git a/triggers/service/auth/src/test/scala/com/daml/oauth/server/Client.scala b/triggers/service/auth/src/test/scala/com/daml/oauth/server/Client.scala index 18225194e7..9c8cb807ae 100644 --- a/triggers/service/auth/src/test/scala/com/daml/oauth/server/Client.scala +++ b/triggers/service/auth/src/test/scala/com/daml/oauth/server/Client.scala @@ -58,7 +58,9 @@ object Client { clientId = config.clientId, redirectUri = redirectUri, scope = Some(params.parties.map(p => "actAs:" + p).mkString(" ")), - state = None) + state = None, + audience = Some("https://daml.com/ledger-api") + ) redirect( config.authServerUrl .withQuery(authParams.toQuery)