forked from digital-asset/daml
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add a JWT authentication to sandbox (digital-asset#3283)
Fixes digital-asset#3363
- Loading branch information
1 parent
df11293
commit 89d6c73
Showing
14 changed files
with
532 additions
and
141 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
88 changes: 88 additions & 0 deletions
88
ledger/ledger-api-auth/src/main/scala/com/digitalasset/ledger/api/auth/AuthServiceJWT.scala
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,88 @@ | ||
// Copyright (c) 2019 The DAML Authors. All rights reserved. | ||
// SPDX-License-Identifier: Apache-2.0 | ||
|
||
package com.digitalasset.ledger.api.auth | ||
|
||
import java.util.concurrent.{CompletableFuture, CompletionStage} | ||
|
||
import com.digitalasset.daml.lf.data.Ref | ||
import com.digitalasset.jwt.JwtVerifier | ||
import com.digitalasset.ledger.api.auth.AuthServiceJWT.Error | ||
import io.grpc.Metadata | ||
import org.slf4j.{Logger, LoggerFactory} | ||
import spray.json._ | ||
|
||
import scala.collection.mutable.ListBuffer | ||
import scala.util.Try | ||
|
||
/** An AuthService that reads a JWT token from a `Authorization: Bearer` HTTP header. | ||
* The token is expected to use the format as defined in [[AuthServiceJWTPayload]]: | ||
*/ | ||
class AuthServiceJWT(verifier: JwtVerifier) extends AuthService { | ||
|
||
protected val logger: Logger = LoggerFactory.getLogger(AuthServiceJWT.getClass) | ||
|
||
override def decodeMetadata(headers: Metadata): CompletionStage[Claims] = { | ||
decodeAndParse(headers).fold( | ||
error => { | ||
logger.warn("Authorization error: " + error.message) | ||
CompletableFuture.completedFuture(Claims.empty) | ||
}, | ||
token => CompletableFuture.completedFuture(payloadToClaims(token)) | ||
) | ||
} | ||
|
||
private[this] def parsePayload(jwtPayload: String): Either[Error, AuthServiceJWTPayload] = { | ||
import AuthServiceJWTCodec.JsonImplicits._ | ||
Try(JsonParser(jwtPayload).convertTo[AuthServiceJWTPayload]).toEither.left.map(t => | ||
Error("Could not parse JWT token: " + t.getMessage)) | ||
} | ||
|
||
private[this] def decodeAndParse(headers: Metadata): Either[Error, AuthServiceJWTPayload] = { | ||
val bearerTokenRegex = "Bearer (.*)".r | ||
|
||
for { | ||
headerValue <- Option | ||
.apply(headers.get(AuthServiceJWT.AUTHORIZATION_KEY)) | ||
.toRight(Error("Authorization header not found")) | ||
token <- bearerTokenRegex | ||
.findFirstMatchIn(headerValue) | ||
.map(_.group(1)) | ||
.toRight(Error("Authorization header does not use Bearer format")) | ||
decoded <- verifier | ||
.verify(com.digitalasset.jwt.domain.Jwt(token)) | ||
.toEither | ||
.left | ||
.map(e => Error("Could not verify JWT token: " + e.message)) | ||
parsed <- parsePayload(decoded.payload) | ||
} yield parsed | ||
} | ||
|
||
private[this] def payloadToClaims(payload: AuthServiceJWTPayload): Claims = { | ||
val claims = ListBuffer[Claim]() | ||
|
||
// Any valid token authorizes the user to use public services | ||
claims.append(ClaimPublic) | ||
|
||
if (payload.admin) | ||
claims.append(ClaimAdmin) | ||
|
||
payload.actAs | ||
.foreach(party => claims.append(ClaimActAsParty(Ref.Party.assertFromString(party)))) | ||
|
||
Claims(claims.toList, payload.exp) | ||
} | ||
} | ||
|
||
object AuthServiceJWT { | ||
final case class Error(message: String) | ||
|
||
val AUTHORIZATION_KEY: Metadata.Key[String] = | ||
Metadata.Key.of("Authorization", Metadata.ASCII_STRING_MARSHALLER) | ||
|
||
def apply(verifier: com.auth0.jwt.interfaces.JWTVerifier) = | ||
new AuthServiceJWT(new JwtVerifier(verifier)) | ||
|
||
def apply(verifier: JwtVerifier) = | ||
new AuthServiceJWT(verifier) | ||
} |
172 changes: 172 additions & 0 deletions
172
...dger-api-auth/src/main/scala/com/digitalasset/ledger/api/auth/AuthServiceJWTPayload.scala
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,172 @@ | ||
// Copyright (c) 2019 The DAML Authors. All rights reserved. | ||
// SPDX-License-Identifier: Apache-2.0 | ||
|
||
package com.digitalasset.ledger.api.auth | ||
|
||
import java.time.Instant | ||
|
||
import spray.json._ | ||
|
||
/** The JWT token payload used in [[AuthServiceJWT]] | ||
* | ||
* | ||
* @param ledgerId If set, the token is only valid for the given ledger ID. | ||
* May also be used to fill in missing ledger ID fields in ledger API requests. | ||
* | ||
* @param participantId If set, the token is only valid for the given participant ID. | ||
* May also be used to fill in missing participant ID fields in ledger API requests. | ||
* | ||
* @param applicationId If set, the token is only valid for the given application ID. | ||
* May also be used to fill in missing application ID fields in ledger API requests. | ||
* | ||
* @param exp If set, the token is only valid before the given instant. | ||
* Note: This is a registered claim in JWT | ||
* | ||
* @param admin Whether the token bearer is authorized to use admin endpoints of the ledger API. | ||
* | ||
* @param actAs List of parties the token bearer can act as. | ||
* May also be used to fill in missing party fields in ledger API requests (e.g., submitter). | ||
* | ||
* @param readAs List of parties the token bearer can read data for. | ||
* May also be used to fill in missing party fields in ledger API requests (e.g., transaction filter). | ||
*/ | ||
case class AuthServiceJWTPayload( | ||
ledgerId: Option[String], | ||
participantId: Option[String], | ||
applicationId: Option[String], | ||
exp: Option[Instant], | ||
admin: Boolean, | ||
actAs: List[String], | ||
readAs: List[String] | ||
) | ||
|
||
/** | ||
* Codec for writing and reading [[AuthServiceJWTPayload]] to and from JSON. | ||
* | ||
* In general: | ||
* - All fields are optional in JSON for forward/backward compatibility reasons. | ||
* - Extra JSON fields are ignored when reading. | ||
* - Null values and missing JSON fields map to None or a safe default value (if there is one). | ||
* | ||
* Example: | ||
* ``` | ||
* { | ||
* "ledgerId": "aaaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", | ||
* "participantId": null, | ||
* "applicationId": null, | ||
* "exp": 1300819380, | ||
* "admin": true, | ||
* "actAs": ["Alice"], | ||
* "readAs": ["Alice", "Bob"], | ||
* } | ||
* ``` | ||
*/ | ||
object AuthServiceJWTCodec { | ||
|
||
// ------------------------------------------------------------------------------------------------------------------ | ||
// Constants used in the encoding | ||
// ------------------------------------------------------------------------------------------------------------------ | ||
private[this] final val propLedgerId: String = "ledgerId" | ||
private[this] final val propParticipantId: String = "participantId" | ||
private[this] final val propApplicationId: String = "applicationId" | ||
private[this] final val propAdmin: String = "admin" | ||
private[this] final val propActAs: String = "actAs" | ||
private[this] final val propReadAs: String = "readAs" | ||
private[this] final val propExp: String = "exp" | ||
|
||
// ------------------------------------------------------------------------------------------------------------------ | ||
// Encoding | ||
// ------------------------------------------------------------------------------------------------------------------ | ||
def writePayload(v: AuthServiceJWTPayload): JsValue = JsObject( | ||
propLedgerId -> writeOptionalString(v.ledgerId), | ||
propParticipantId -> writeOptionalString(v.participantId), | ||
propApplicationId -> writeOptionalString(v.applicationId), | ||
propAdmin -> JsBoolean(v.admin), | ||
propExp -> writeOptionalInstant(v.exp), | ||
propActAs -> writeStringList(v.actAs), | ||
propReadAs -> writeStringList(v.readAs) | ||
) | ||
|
||
/** Writes the given payload to a compact JSON string */ | ||
def compactPrint(v: AuthServiceJWTPayload): String = writePayload(v).compactPrint | ||
|
||
private[this] def writeOptionalString(value: Option[String]): JsValue = | ||
value.fold[JsValue](JsNull)(JsString(_)) | ||
|
||
private[this] def writeStringList(value: List[String]): JsValue = | ||
JsArray(value.map(JsString(_)): _*) | ||
|
||
private[this] def writeOptionalInstant(value: Option[Instant]): JsValue = | ||
value.fold[JsValue](JsNull)(i => JsNumber(i.getEpochSecond)) | ||
|
||
// ------------------------------------------------------------------------------------------------------------------ | ||
// Decoding | ||
// ------------------------------------------------------------------------------------------------------------------ | ||
def readPayload(value: JsValue): AuthServiceJWTPayload = value match { | ||
case JsObject(fields) => | ||
AuthServiceJWTPayload( | ||
ledgerId = readOptionalString(propLedgerId, fields), | ||
participantId = readOptionalString(propParticipantId, fields), | ||
applicationId = readOptionalString(propApplicationId, fields), | ||
exp = readInstant(propExp, fields), | ||
admin = readOptionalBoolean(propAdmin, fields).getOrElse(false), | ||
actAs = readOptionalStringList(propActAs, fields), | ||
readAs = readOptionalStringList(propReadAs, fields) | ||
) | ||
case _ => | ||
deserializationError(s"Can't read ${value.prettyPrint} as AuthServiceJWTPayload") | ||
} | ||
|
||
private[this] def readOptionalString(name: String, fields: Map[String, JsValue]): Option[String] = | ||
fields.get(name) match { | ||
case None => None | ||
case Some(JsNull) => None | ||
case Some(JsString(value)) => Some(value) | ||
case Some(value) => | ||
deserializationError(s"Can't read ${value.prettyPrint} as string for $name") | ||
} | ||
|
||
private[this] def readOptionalStringList( | ||
name: String, | ||
fields: Map[String, JsValue]): List[String] = fields.get(name) match { | ||
case None => List.empty | ||
case Some(JsNull) => List.empty | ||
case Some(JsArray(values)) => | ||
values.toList.map { | ||
case JsString(value) => value | ||
case value => | ||
deserializationError(s"Can't read ${value.prettyPrint} as string element for $name") | ||
} | ||
case Some(value) => | ||
deserializationError(s"Can't read ${value.prettyPrint} as string list for $name") | ||
} | ||
|
||
private[this] def readOptionalBoolean( | ||
name: String, | ||
fields: Map[String, JsValue]): Option[Boolean] = fields.get(name) match { | ||
case None => None | ||
case Some(JsNull) => None | ||
case Some(JsBoolean(value)) => Some(value) | ||
case Some(value) => | ||
deserializationError(s"Can't read ${value.prettyPrint} as boolean for $name") | ||
} | ||
|
||
private[this] def readInstant(name: String, fields: Map[String, JsValue]): Option[Instant] = | ||
fields.get(name) match { | ||
case None => None | ||
case Some(JsNull) => None | ||
case Some(JsNumber(epochSeconds)) => Some(Instant.ofEpochSecond(epochSeconds.longValue())) | ||
case Some(value) => | ||
deserializationError(s"Can't read ${value.prettyPrint} as epoch seconds for $name") | ||
} | ||
|
||
// ------------------------------------------------------------------------------------------------------------------ | ||
// Implicits that can be imported to write JSON | ||
// ------------------------------------------------------------------------------------------------------------------ | ||
object JsonImplicits extends DefaultJsonProtocol { | ||
implicit object AuthServiceJWTPayloadFormat extends RootJsonFormat[AuthServiceJWTPayload] { | ||
override def write(v: AuthServiceJWTPayload): JsValue = writePayload(v) | ||
override def read(json: JsValue): AuthServiceJWTPayload = readPayload(json) | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.