From 2640bc6ab78e18fbd06948e67410cf052c205a58 Mon Sep 17 00:00:00 2001 From: Simon Meier Date: Tue, 18 Jan 2022 14:01:47 +0100 Subject: [PATCH] user management: require appropriate scope in user access tokens (#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 --- .../build-and-lint-test/src/__tests__/test.ts | 1 + .../api/auth/AuthServiceJWTPayload.scala | 123 +++++++++--------- .../api/auth/AuthServiceJWTCodecSpec.scala | 25 +++- .../create-daml-app/ui/src/config.ts.template | 1 + 4 files changed, 84 insertions(+), 66 deletions(-) diff --git a/language-support/ts/codegen/tests/ts/build-and-lint-test/src/__tests__/test.ts b/language-support/ts/codegen/tests/ts/build-and-lint-test/src/__tests__/test.ts index cd45ad8cd8e4..ca7a735688e0 100644 --- a/language-support/ts/codegen/tests/ts/build-and-lint-test/src/__tests__/test.ts +++ b/language-support/ts/codegen/tests/ts/build-and-lint-test/src/__tests__/test.ts @@ -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'; diff --git a/ledger/ledger-api-auth/src/main/scala/com/digitalasset/ledger/api/auth/AuthServiceJWTPayload.scala b/ledger/ledger-api-auth/src/main/scala/com/digitalasset/ledger/api/auth/AuthServiceJWTPayload.scala index 42ca444a7e4b..a90d9047f1de 100644 --- a/ledger/ledger-api-auth/src/main/scala/com/digitalasset/ledger/api/auth/AuthServiceJWTPayload.scala +++ b/ledger/ledger-api-auth/src/main/scala/com/digitalasset/ledger/api/auth/AuthServiceJWTPayload.scala @@ -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 { @@ -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" @@ -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 // ------------------------------------------------------------------------------------------------------------------ @@ -143,6 +126,7 @@ object AuthServiceJWTCodec { "aud" -> writeOptionalString(v.participantId), "sub" -> JsString(v.userId), "exp" -> writeOptionalInstant(v.exp), + "scope" -> JsString(scopeLedgerApiFull), ) } @@ -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" diff --git a/ledger/ledger-api-auth/src/test/suite/scala/com/digitalasset/ledger/api/auth/AuthServiceJWTCodecSpec.scala b/ledger/ledger-api-auth/src/test/suite/scala/com/digitalasset/ledger/api/auth/AuthServiceJWTCodecSpec.scala index 7268cd4ca606..362d7d4a8cd9 100644 --- a/ledger/ledger-api-auth/src/test/suite/scala/com/digitalasset/ledger/api/auth/AuthServiceJWTCodecSpec.scala +++ b/ledger/ledger-api-auth/src/test/suite/scala/com/digitalasset/ledger/api/auth/AuthServiceJWTCodecSpec.scala @@ -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( @@ -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( diff --git a/templates/create-daml-app/ui/src/config.ts.template b/templates/create-daml-app/ui/src/config.ts.template index c7062b028d27..b6025804835f 100644 --- a/templates/create-daml-app/ui/src/config.ts.template +++ b/templates/create-daml-app/ui/src/config.ts.template @@ -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();