Skip to content

Commit

Permalink
Fetch server public key during OIDC auth (#7267)
Browse files Browse the repository at this point in the history
* Fetch server public key during OIDC auth

* changelog + migration guide
  • Loading branch information
fm3 authored Aug 16, 2023
1 parent 84887cd commit 5a2f1da
Show file tree
Hide file tree
Showing 7 changed files with 57 additions and 35 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.unreleased.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions MIGRATIONS.unreleased.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
6 changes: 3 additions & 3 deletions app/controllers/AuthenticationController.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
77 changes: 50 additions & 27 deletions app/oxalis/security/OpenIdConnectClient.scala
Original file line number Diff line number Diff line change
@@ -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(
Expand Down Expand Up @@ -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"
Expand All @@ -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

/*
Expand All @@ -75,32 +77,34 @@ 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

}

// Fields as specified by https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata
case class OpenIdConnectProviderInfo(
authorization_endpoint: String,
token_endpoint: String,
jwks_uri: String
)

object OpenIdConnectProviderInfo {
Expand Down Expand Up @@ -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]
}
2 changes: 0 additions & 2 deletions app/utils/WkConf.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}

Expand Down
3 changes: 0 additions & 3 deletions conf/application.conf
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}

Expand Down
1 change: 1 addition & 0 deletions conf/messages
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down

0 comments on commit 5a2f1da

Please sign in to comment.