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 <andreas.herrmann@tweag.io>
This commit is contained in:
Andreas Herrmann 2020-10-09 18:43:56 +02:00 committed by GitHub
parent a69d10766d
commit 8b9c237031
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 148 additions and 52 deletions

View File

@ -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",
],
)

View File

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

View File

@ -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)
}

View File

@ -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)
}
}

View File

@ -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,
)
}
}

View File

@ -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()

View File

@ -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 {

View File

@ -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)