Skip to content

Commit

Permalink
user management: require appropriate scope in user access tokens (dig…
Browse files Browse the repository at this point in the history
…ital-asset#12428)

* user management: require `daml_ledger_api` scope in user access tokens

The accesss token's scope must include `daml_ledger_api` to ensure
that access token was issued for accessing the Daml ledger api.

CHANGELOG_BEGIN
CHANGELOG_END
  • Loading branch information
meiersi-da authored Jan 18, 2022
1 parent 0c13a4f commit 2640bc6
Show file tree
Hide file tree
Showing 4 changed files with 84 additions and 66 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ const computeToken = (party: string) => encode({

const computeUserToken = (name: string) => encode({
sub: name,
scope: "daml_ledger_api"
}, SECRET_KEY, 'HS256');

const ALICE_PARTY = 'Alice';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,24 +75,11 @@ final case class StandardJWTPayload(
*
* In general:
* - All custom claims are placed in a namespace field according to the OpenID Connect standard.
* - All fields are optional in JSON for forward/backward compatibility reasons.
* - Access tokens use a Daml-specific scope to distinguish them from other access tokens
* issued by the same issuer for different systems or APIs.
* - All fields are optional in JSON for forward/backward compatibility reasons, where appropriate.
* - 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:
* ```
* {
* "https://daml.com/ledger-api": {
* "ledgerId": "aaaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
* "participantId": null,
* "applicationId": null,
* "admin": true,
* "actAs": ["Alice"],
* "readAs": ["Alice", "Bob"]
* },
* "exp": 1300819380
* }
* ```
*/
object AuthServiceJWTCodec {

Expand All @@ -103,6 +90,8 @@ object AuthServiceJWTCodec {
// ------------------------------------------------------------------------------------------------------------------
// OpenID Connect (OIDC) namespace for custom JWT claims
final val oidcNamespace: String = "https://daml.com/ledger-api"
// Unique scope for standard tokens, following the pattern of https://developers.google.com/identity/protocols/oauth2/scopes
final val scopeLedgerApiFull: String = "daml_ledger_api"

private[this] final val propLedgerId: String = "ledgerId"
private[this] final val propParticipantId: String = "participantId"
Expand All @@ -113,12 +102,6 @@ object AuthServiceJWTCodec {
private[this] final val propExp: String = "exp"
private[this] final val propParty: String = "party" // Legacy JSON API payload

// Properties whose presence signals a custom token. We do not include "applicationId", "admin", and "party" as we deem
// it too likely that they are present in a standard token as well due to vagaries of the identity provider. Identity
// providers can always be configured to set "actAs: []" to ensure proper parsing.
private[this] final val distinguishingCustomProps: Array[String] =
Array(oidcNamespace, propLedgerId, propParticipantId, propActAs, propReadAs)

// ------------------------------------------------------------------------------------------------------------------
// Encoding
// ------------------------------------------------------------------------------------------------------------------
Expand All @@ -143,6 +126,7 @@ object AuthServiceJWTCodec {
"aud" -> writeOptionalString(v.participantId),
"sub" -> JsString(v.userId),
"exp" -> writeOptionalInstant(v.exp),
"scope" -> JsString(scopeLedgerApiFull),
)
}

Expand All @@ -169,49 +153,62 @@ object AuthServiceJWTCodec {
}

def readPayload(value: JsValue): AuthServiceJWTPayload = value match {
case JsObject(fields)
if fields.contains("sub") && !distinguishingCustomProps.exists(fields.contains) =>
// Standard JWT claims
StandardJWTPayload(
participantId = readOptionalString("aud", fields),
userId = readOptionalString("sub", fields).get, // guarded by if-clause above
exp = readInstant("exp", fields),
)
case JsObject(fields) if !fields.contains(oidcNamespace) =>
// Legacy format
logger.warn(s"Token ${value.compactPrint} is using a deprecated JWT payload format")
CustomDamlJWTPayload(
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) ++ readOptionalString(propParty, fields).toList,
readAs = readOptionalStringList(propReadAs, fields),
)
case JsObject(fields) =>
// New format: OIDC compliant
val customClaims = fields
.getOrElse(
oidcNamespace,
deserializationError(
s"Can't read ${value.prettyPrint} as AuthServiceJWTPayload: namespace missing"
),
val scope = fields.get("scope")
val scopes = scope.toList.collect({ case JsString(scope) => scope.split(" ") }).flatten
// We're using this rather restrictive test to ensure we continue parsing all legacy sandbox tokens that
// are in use before the 2.0 release; and thereby maintain full backwards compatibility.
if (scopes.contains(scopeLedgerApiFull))
// Standard JWT payload
StandardJWTPayload(
participantId = readOptionalString("aud", fields),
userId = readOptionalString("sub", fields).get, // guarded by if-clause above
exp = readInstant("exp", fields),
)
.asJsObject(
s"Can't read ${value.prettyPrint} as AuthServiceJWTPayload: namespace is not an object"
)
.fields
CustomDamlJWTPayload(
ledgerId = readOptionalString(propLedgerId, customClaims),
participantId = readOptionalString(propParticipantId, customClaims),
applicationId = readOptionalString(propApplicationId, customClaims),
exp = readInstant(propExp, fields),
admin = readOptionalBoolean(propAdmin, customClaims).getOrElse(false),
actAs = readOptionalStringList(propActAs, customClaims),
readAs = readOptionalStringList(propReadAs, customClaims),
)
else {
if (scope.nonEmpty)
logger.warn(
s"Access token with unknown scope \"${scope.get}\" is being parsed as a custom claims token. Issue tokens with adjusted or no scope to get rid of this warning."
)
if (!fields.contains(oidcNamespace)) {
// Legacy format
logger.warn(s"Token ${value.compactPrint} is using a deprecated JWT payload format")
CustomDamlJWTPayload(
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) ++ readOptionalString(
propParty,
fields,
).toList,
readAs = readOptionalStringList(propReadAs, fields),
)
} else {
// New format: OIDC compliant
val customClaims = fields
.getOrElse(
oidcNamespace,
deserializationError(
s"Can't read ${value.prettyPrint} as AuthServiceJWTPayload: namespace missing"
),
)
.asJsObject(
s"Can't read ${value.prettyPrint} as AuthServiceJWTPayload: namespace is not an object"
)
.fields
CustomDamlJWTPayload(
ledgerId = readOptionalString(propLedgerId, customClaims),
participantId = readOptionalString(propParticipantId, customClaims),
applicationId = readOptionalString(propApplicationId, customClaims),
exp = readInstant(propExp, fields),
admin = readOptionalBoolean(propAdmin, customClaims).getOrElse(false),
actAs = readOptionalStringList(propActAs, customClaims),
readAs = readOptionalStringList(propReadAs, customClaims),
)
}
}
case _ =>
deserializationError(
s"Can't read ${value.prettyPrint} as AuthServiceJWTPayload: value is not an object"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,8 @@ class AuthServiceJWTCodecSpec
| "participantId": "someLegacyParticipantId",
| "aud": "someParticipantId",
| "sub": "someUserId",
| "exp": 100
| "exp": 100,
| "scope": "dummy-scope1 dummy-scope2"
|}
""".stripMargin
val expected = CustomDamlJWTPayload(
Expand All @@ -160,12 +161,30 @@ class AuthServiceJWTCodecSpec
parse(serialized) shouldBe Success(expected)
}

"support standard JWT claims" in {
"support standard JWT claims with just the one scope" in {
val serialized =
"""{
| "aud": "someParticipantId",
| "sub": "someUserId",
| "exp": 100
| "exp": 100,
| "scope": "daml_ledger_api"
|}
""".stripMargin
val expected = StandardJWTPayload(
participantId = Some("someParticipantId"),
userId = "someUserId",
exp = Some(Instant.ofEpochSecond(100)),
)
parse(serialized) shouldBe Success(expected)
}

"support standard JWT claims with extra scopes" in {
val serialized =
"""{
| "aud": "someParticipantId",
| "sub": "someUserId",
| "exp": 100,
| "scope": "dummy-scope1 daml_ledger_api dummy-scope2"
|}
""".stripMargin
val expected = StandardJWTPayload(
Expand Down
1 change: 1 addition & 0 deletions templates/create-daml-app/ui/src/config.ts.template
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ export const withUserManagement: UserManagement = {
tokenPayload: (loginName: string, ledgerId: string) =>
({
sub: loginName,
scope: "daml_ledger_api"
}),
primaryParty: async (loginName, ledger: Ledger) => {
const user = await ledger.getUser();
Expand Down

0 comments on commit 2640bc6

Please sign in to comment.