mirror of
https://github.com/digital-asset/daml.git
synced 2024-09-19 08:48:21 +03:00
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:
parent
a69d10766d
commit
8b9c237031
@ -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",
|
||||
],
|
||||
)
|
||||
|
@ -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`)
|
||||
|
@ -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)
|
||||
|
||||
}
|
||||
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -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()
|
||||
|
@ -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 {
|
||||
|
@ -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)
|
||||
|
Loading…
Reference in New Issue
Block a user