From 5a2f1dad63a804defd9f9ba63f85d15dc9195368 Mon Sep 17 00:00:00 2001 From: Florian M Date: Wed, 16 Aug 2023 11:56:07 +0200 Subject: [PATCH] Fetch server public key during OIDC auth (#7267) * Fetch server public key during OIDC auth * changelog + migration guide --- CHANGELOG.unreleased.md | 1 + MIGRATIONS.unreleased.md | 2 + .../AuthenticationController.scala | 6 +- app/oxalis/security/OpenIdConnectClient.scala | 77 ++++++++++++------- app/utils/WkConf.scala | 2 - conf/application.conf | 3 - conf/messages | 1 + 7 files changed, 57 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.unreleased.md b/CHANGELOG.unreleased.md index 4f296d3cb48..e3adfc4d751 100644 --- a/CHANGELOG.unreleased.md +++ b/CHANGELOG.unreleased.md @@ -21,6 +21,7 @@ For upgrade instructions, please check the [migration guide](MIGRATIONS.released ### Changed - Small messages during annotating (e.g. “finished undo”, “applying mapping…”) are now click-through so they do not block users from selecting tools. [7239](https://github.com/scalableminds/webknossos/pull/7239) +- OpenID Connect authorization now fetches the server’s public key automatically. The config keys `singleSignOn.openIdConnect.publicKey` and `singleSignOn.openIdConnect.publicKeyAlgorithm` are now unused. [7267](https://github.com/scalableminds/webknossos/pull/7267) ### Fixed - Fixed that is was possible to have larger active segment ids that supported by the data type of the segmentation layer which caused the segmentation ids to overflow. [#7240](https://github.com/scalableminds/webknossos/pull/7240) diff --git a/MIGRATIONS.unreleased.md b/MIGRATIONS.unreleased.md index 9ce5b3e2bbb..853d6848789 100644 --- a/MIGRATIONS.unreleased.md +++ b/MIGRATIONS.unreleased.md @@ -16,6 +16,8 @@ UPDATE webknossos.multiUsers SET isEmailVerified = false; - When interacting with webknossos via the python library, make sure you update to the latest version, as the task and project api have changed. Compare [webknossos-libs#930](https://github.com/scalableminds/webknossos-libs/pull/930). [#7220](https://github.com/scalableminds/webknossos/pull/7220) + - If you have OIDC authentication set up, you can now remove the config keys `singleSignOn.openIdConnect.publicKey` and `singleSignOn.openIdConnect.publicKeyAlgorithm`, as the server’s public key is now automatically fetched. [7267](https://github.com/scalableminds/webknossos/pull/7267) + ### Postgres Evolutions: - [105-verify-email.sql](conf/evolutions/105-verify-email.sql) - [106-folder-no-slashes.sql](conf/evolutions/106-folder-no-slashes.sql) diff --git a/app/controllers/AuthenticationController.scala b/app/controllers/AuthenticationController.scala index a3956d845ea..35567641a79 100755 --- a/app/controllers/AuthenticationController.scala +++ b/app/controllers/AuthenticationController.scala @@ -535,11 +535,11 @@ class AuthenticationController @Inject()( def openIdCallback(): Action[AnyContent] = Action.async { implicit request => for { - code <- openIdConnectClient.getToken( + code <- openIdConnectClient.getAndValidateToken( absoluteOpenIdConnectCallbackURL, request.queryString.get("code").flatMap(_.headOption).getOrElse("missing code"), - ) - oidc: OpenIdConnectClaimSet <- validateJsValue[OpenIdConnectClaimSet](code).toFox + ) ?~> "oidc.getToken.failed" ?~> "oidc.authentication.failed" + oidc: OpenIdConnectClaimSet <- validateJsValue[OpenIdConnectClaimSet](code) ?~> "oidc.parseClaimset.failed" ?~> "oidc.authentication.failed" user_result <- loginOrSignupViaOidc(oidc)(request) } yield user_result } diff --git a/app/oxalis/security/OpenIdConnectClient.scala b/app/oxalis/security/OpenIdConnectClient.scala index fd3c55648f6..f799e777e31 100644 --- a/app/oxalis/security/OpenIdConnectClient.scala +++ b/app/oxalis/security/OpenIdConnectClient.scala @@ -1,22 +1,24 @@ package oxalis.security -import com.scalableminds.util.tools.Fox -import com.scalableminds.util.tools.Fox.{bool2Fox, jsResult2Fox, try2Fox} +import com.scalableminds.util.tools.{Fox, FoxImplicits, JsonHelper} import com.scalableminds.webknossos.datastore.rpc.RPC import play.api.libs.json.{JsObject, Json, OFormat} -import pdi.jwt.{JwtJson, JwtOptions} +import pdi.jwt.JwtJson import play.api.libs.ws._ import utils.WkConf +import java.math.BigInteger import java.net.URLEncoder import java.nio.charset.StandardCharsets -import java.security.spec.X509EncodedKeySpec +import java.security.spec.RSAPublicKeySpec import java.security.{KeyFactory, PublicKey} import java.util.Base64 import javax.inject.Inject import scala.concurrent.ExecutionContext -class OpenIdConnectClient @Inject()(rpc: RPC, conf: WkConf)(implicit executionContext: ExecutionContext) { +class OpenIdConnectClient @Inject()(rpc: RPC, conf: WkConf)(implicit ec: ExecutionContext) extends FoxImplicits { + + private val keyTypeRsa = "RSA" private lazy val oidcConfig: OpenIdConnectConfig = OpenIdConnectConfig( @@ -49,7 +51,7 @@ class OpenIdConnectClient @Inject()(rpc: RPC, conf: WkConf)(implicit executionCo Fetches token from the oidc provider (https://openid.net/specs/openid-connect-core-1_0.html#TokenRequest), fields described by https://www.rfc-editor.org/rfc/rfc6749#section-4.4.2 */ - def getToken(redirectUrl: String, code: String): Fox[JsObject] = + def getAndValidateToken(redirectUrl: String, code: String): Fox[JsObject] = for { _ <- bool2Fox(conf.Features.openIdConnectEnabled) ?~> "oidc.disabled" _ <- bool2Fox(oidcConfig.isValid) ?~> "oidc.configuration.invalid" @@ -63,7 +65,7 @@ class OpenIdConnectClient @Inject()(rpc: RPC, conf: WkConf)(implicit executionCo "redirect_uri" -> redirectUrl, "code" -> code )) - newToken <- validateOpenIdConnectTokenResponse(tokenResponse) ?~> "failed to parse JWT" + newToken <- validateOpenIdConnectTokenResponse(tokenResponse, serverInfos) ?~> "failed to parse JWT" } yield newToken /* @@ -75,25 +77,26 @@ class OpenIdConnectClient @Inject()(rpc: RPC, conf: WkConf)(implicit executionCo serverInfo <- response.json.validate[OpenIdConnectProviderInfo](OpenIdConnectProviderInfo.format) } yield serverInfo - private def validateOpenIdConnectTokenResponse(tr: OpenIdConnectTokenResponse) = - publicKey match { - case Some(pk) => JwtJson.decodeJson(tr.access_token, pk).toFox - case None => - JwtJson.decodeJson(tr.access_token, JwtOptions.DEFAULT.copy(signature = false)).toFox - } - - private lazy val publicKey: Option[PublicKey] = { - if (conf.SingleSignOn.OpenIdConnect.publicKey.isEmpty || conf.SingleSignOn.OpenIdConnect.publicKeyAlgorithm.isEmpty) { - None - } else { - val kf = KeyFactory.getInstance("RSA") - val base64EncodedKey = conf.SingleSignOn.OpenIdConnect.publicKey - val key = Base64.getDecoder.decode(base64EncodedKey.getBytes) - val spec = new X509EncodedKeySpec(key) - Some(kf.generatePublic(spec)) - } - - } + private def validateOpenIdConnectTokenResponse(tokenResponse: OpenIdConnectTokenResponse, + serverInfos: OpenIdConnectProviderInfo): Fox[JsObject] = + for { + publicKey <- fetchServerPublicKey(serverInfos) + decodedResponse <- JwtJson.decodeJson(tokenResponse.access_token, publicKey).toFox + } yield decodedResponse + + private def fetchServerPublicKey(serverInfos: OpenIdConnectProviderInfo): Fox[PublicKey] = + for { + response: WSResponse <- rpc(serverInfos.jwks_uri).get + jsonWebKeySet: JsonWebKeySet <- JsonHelper.validateJsValue[JsonWebKeySet](response.json).toFox + firstRsaKey: JsonWebKey <- Fox.option2Fox(jsonWebKeySet.keys.find(key => + key.kty == keyTypeRsa && key.use == "sig")) ?~> "No server RSA Public Key found in server key set" + modulusString <- firstRsaKey.n + modulus = new BigInteger(1, Base64.getUrlDecoder.decode(modulusString.getBytes)) + exponentString <- firstRsaKey.e + exponent = new BigInteger(1, Base64.getUrlDecoder.decode(exponentString.getBytes)) + publicKeySpec = new RSAPublicKeySpec(modulus, exponent) + publicKey = KeyFactory.getInstance(keyTypeRsa).generatePublic(publicKeySpec) + } yield publicKey } @@ -101,6 +104,7 @@ class OpenIdConnectClient @Inject()(rpc: RPC, conf: WkConf)(implicit executionCo case class OpenIdConnectProviderInfo( authorization_endpoint: String, token_endpoint: String, + jwks_uri: String ) object OpenIdConnectProviderInfo { @@ -143,5 +147,24 @@ case class OpenIdConnectClaimSet(iss: String, } object OpenIdConnectClaimSet { - implicit val format: OFormat[OpenIdConnectClaimSet] = Json.format[OpenIdConnectClaimSet] + implicit val jsonFormat: OFormat[OpenIdConnectClaimSet] = Json.format[OpenIdConnectClaimSet] +} + +case class JsonWebKeySet(keys: Seq[JsonWebKey]) +object JsonWebKeySet { + implicit val jsonFormat: OFormat[JsonWebKeySet] = Json.format[JsonWebKeySet] +} + +// Specified by https://datatracker.ietf.org/doc/html/rfc7517#section-4 +// and RSA-specific by https://datatracker.ietf.org/doc/html/rfc7518#section-6.3.1 +case class JsonWebKey( + kty: String, // key type + alg: String, // algorithm + use: String, // usage (sig for signature or enc for encryption) + n: Option[String], // rsa modulus + e: Option[String] // rsa exponent +) + +object JsonWebKey { + implicit val jsonFormat: OFormat[JsonWebKey] = Json.format[JsonWebKey] } diff --git a/app/utils/WkConf.scala b/app/utils/WkConf.scala index 8d6d755070a..213379b1aed 100644 --- a/app/utils/WkConf.scala +++ b/app/utils/WkConf.scala @@ -104,8 +104,6 @@ class WkConf @Inject()(configuration: Configuration) extends ConfigReader with L val providerUrl: String = get[String]("singleSignOn.openIdConnect.providerUrl") val clientId: String = get[String]("singleSignOn.openIdConnect.clientId") val clientSecret: String = get[String]("singleSignOn.openIdConnect.clientSecret") - val publicKey: String = get[String]("singleSignOn.openIdConnect.publicKey") - val publicKeyAlgorithm: String = get[String]("singleSignOn.openIdConnect.publicKeyAlgorithm") } } diff --git a/conf/application.conf b/conf/application.conf index f6ccfee55a2..911439aeb8b 100644 --- a/conf/application.conf +++ b/conf/application.conf @@ -119,9 +119,6 @@ singleSignOn { providerUrl = "http://localhost:8080/auth/realms/master/" clientId = "myclient" clientSecret = "myClientSecret" - # Public Key to validate claim, for keycloak see Realm settings > keys - publicKey = "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAscUZB3Y5fiOfIdLC/31N1GufZ26bmB21V8D9Crg2bAHPD3g8qofRMg5Uo1+WuKuT5CJrCu+x0hIbA50GYb6E1V78MkYOaCbCT+xE+ec+Jv6zUJAaNJugx71oXI+X5e9kW/O8JSwIicSUYDz7LKvCklwn9/QmgetqGsBrAEOG+4WlwPnrZiKRaQl9V0vBOcwzD946Cbrgg3iLnryJ0pGVKHvWePsXR7Pt8hdA0FeA9V9hVd6gVHR2pHqg46kyPItNMwWTXENqJ4lbhgaoZ9sZpoMXIy1kjh3GXSXGOG+GeOOtOinr1K24I8HG9wsnEefjVSPDB6EvflPrhLKXMfI/JQIDAQAB" - publicKeyAlgorithm = "RSA" } } diff --git a/conf/messages b/conf/messages index 2e1acee0a7d..8baf21ada53 100644 --- a/conf/messages +++ b/conf/messages @@ -71,6 +71,7 @@ user.creation.failed=Failed to create user oidc.disabled=OIDC is disabled oidc.configuration.invalid=OIDC configuration is invalid +oidc.authentication.failed=Failed to register / log in via Single-Sign-On (SSO with OIDC) braintracing.new=An account on braintracing.org was created for you. You can use the same credentials as on WEBKNOSSOS to login. braintracing.error=We could not automatically create an account for you on braintracing.org. Please do it on your own.