Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add OIDC authentication #6534

Merged
merged 51 commits into from
Dec 5, 2022
Merged
Show file tree
Hide file tree
Changes from 21 commits
Commits
Show all changes
51 commits
Select commit Hold shift + click to select a range
94bf748
Update silhouette dependency
frcroth Sep 28, 2022
48a9daa
fix legacy route compilation
fm3 Sep 29, 2022
5c8df51
Merge branch 'master' into sso-auth
fm3 Sep 29, 2022
93c15a5
upgrade to play 2.8.16
fm3 Sep 29, 2022
1357814
undo comment out in routes file
fm3 Sep 29, 2022
2701c8e
Add oidc routes
frcroth Oct 5, 2022
d987909
Fix typo
frcroth Oct 5, 2022
6401562
Update schema for oidc
frcroth Oct 7, 2022
efca4e3
Create or sign up user after OIDC authentication
frcroth Oct 7, 2022
d6326af
Sign in when registering via OIDC
frcroth Oct 9, 2022
676a0df
Improve usage of fox
frcroth Oct 19, 2022
b0f0f8d
Refactoring
frcroth Oct 20, 2022
504a9c4
Add OIDC client implementation
frcroth Oct 20, 2022
c625086
Merge branch 'master' into oidc
frcroth Oct 20, 2022
f56cd5b
Fix cannot alter enum in transaction block
frcroth Oct 20, 2022
49d3a8f
Fix warnings and complie errors
frcroth Oct 20, 2022
4873fba
Drop view to change column type
frcroth Oct 20, 2022
31b6998
Fix evolution
frcroth Oct 20, 2022
4676667
Remove complicated reversion
frcroth Oct 20, 2022
0025553
Update migrations and changelog
frcroth Oct 20, 2022
0edb066
Merge branch 'master' into oidc
frcroth Oct 20, 2022
f67770c
Renaming
frcroth Oct 25, 2022
5109ea7
Check for validity of OIDC config
frcroth Oct 26, 2022
edef763
Rename and move
frcroth Oct 26, 2022
07277c1
WIP: Extract user creation
frcroth Oct 26, 2022
47361cc
Move oidc config to OpenIdConnectClient.scala
frcroth Oct 26, 2022
1e447cf
Merge branch 'master' into oidc
frcroth Oct 26, 2022
b767c8d
Fix application.conf
frcroth Oct 26, 2022
e7ac9af
Add notice on route usage
frcroth Oct 26, 2022
fd8cbea
Fix lint error
frcroth Oct 27, 2022
a889f7a
Refactoring
frcroth Oct 30, 2022
395b133
Merge branch 'master' into oidc
frcroth Oct 30, 2022
633fab1
Remove redirect for frontend integration
frcroth Nov 2, 2022
308ff63
provide login-via-sso button in log in form
philippotto Nov 18, 2022
f7ad5e6
Merge branch 'master' into oidc
frcroth Nov 20, 2022
f6e7c5b
Fix config values
frcroth Nov 20, 2022
221fd64
Add feature flag for oidc
frcroth Nov 20, 2022
4ace8f1
Check if oidc is enabled in OIDC client
frcroth Nov 22, 2022
5e46a3d
Update end to end tests
frcroth Nov 22, 2022
13dc1ff
hide sso-button if oidc is disabled
philippotto Nov 23, 2022
390647c
Merge branch 'master' into oidc
frcroth Nov 24, 2022
90de845
Make methods private
frcroth Nov 24, 2022
d94f43f
Rename feature flag
frcroth Nov 24, 2022
caae3ce
Update e2e tests
frcroth Nov 24, 2022
d200266
Validate token with public key if available
frcroth Nov 25, 2022
0694935
Fix warning
frcroth Nov 30, 2022
d851569
Merge branch 'master' into oidc, Update Evolution number
frcroth Nov 30, 2022
c2d6f37
Merge branch 'master' into oidc
philippotto Dec 1, 2022
f119cf0
rename oidcEnabled to openIdConnectEnabled
philippotto Dec 1, 2022
0bbfd99
Fix signup redirect failing
frcroth Dec 2, 2022
3a5f1d5
Merge branch 'master' into oidc
frcroth Dec 5, 2022
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CHANGELOG.unreleased.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ For upgrade instructions, please check the [migration guide](MIGRATIONS.released
- Add context-menu option to delete skeleton root group. [#6553](https://github.com/scalableminds/webknossos/pull/6553)
- Added remaining task time estimation (ETA) for Voxelytics tasks in workflow reporting. [#6564](https://github.com/scalableminds/webknossos/pull/6564)
- Added a help button to the UI to send questions and feedbacks to the dev team. [#6560](https://github.com/scalableminds/webknossos/pull/6560)

- Added sign in via OIDC. [#6534](https://github.com/scalableminds/webknossos/pull/6534)

### Changed
- Creating tasks in bulk now also supports referencing task types by their summary instead of id. [#6486](https://github.com/scalableminds/webknossos/pull/6486)
Expand Down
1 change: 1 addition & 0 deletions MIGRATIONS.unreleased.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,4 @@ User-facing changes are documented in the [changelog](CHANGELOG.released.md).
[Commits](https://github.com/scalableminds/webknossos/compare/22.10.0...HEAD)

### Postgres Evolutions:
- [091-oidc.sql](conf/evolutions/091-oidc.sql)
97 changes: 92 additions & 5 deletions app/controllers/AuthenticationController.scala
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package controllers

import java.net.URLEncoder

import akka.actor.ActorSystem
import com.mohiva.play.silhouette.api.actions.SecuredRequest
import com.mohiva.play.silhouette.api.exceptions.ProviderException
Expand All @@ -10,7 +9,9 @@ import com.mohiva.play.silhouette.api.util.Credentials
import com.mohiva.play.silhouette.api.{LoginInfo, Silhouette}
import com.mohiva.play.silhouette.impl.providers.CredentialsProvider
import com.scalableminds.util.accesscontext.{AuthorizedAccessContext, DBAccessContext, GlobalAccessContext}
import com.scalableminds.util.tools.JsonHelper.{validateJsValue}
import com.scalableminds.util.tools.{Fox, FoxImplicits, TextUtils}

import javax.inject.Inject
import models.analytics.{AnalyticsService, InviteEvent, JoinOrganizationEvent, SignupEvent}
import models.annotation.AnnotationState.Cancelled
Expand All @@ -29,7 +30,7 @@ import play.api.data.Forms.{email, _}
import play.api.data.validation.Constraints._
import play.api.i18n.Messages
import play.api.libs.json._
import play.api.mvc.{Action, AnyContent, PlayBodyParsers}
import play.api.mvc.{Action, AnyContent, Cookie, PlayBodyParsers, Request, Result}
import utils.{ObjectId, WkConf}

import scala.concurrent.{ExecutionContext, Future}
Expand All @@ -54,6 +55,7 @@ class AuthenticationController @Inject()(
conf: WkConf,
annotationDAO: AnnotationDAO,
wkSilhouetteEnvironment: WkSilhouetteEnvironment,
openIdClient: OpenIDConnectClient,
sil: Silhouette[WkEnv])(implicit ec: ExecutionContext, bodyParsers: PlayBodyParsers)
extends Controller
with AuthForms
Expand Down Expand Up @@ -82,7 +84,7 @@ class AuthenticationController @Inject()(
errors ::= Messages("user.lastName.invalid")
""
}
multiUserDAO.findOneByEmail(email)(GlobalAccessContext).toFox.futureBox.flatMap {
multiUserDAO.findOneByEmail(email)(GlobalAccessContext).futureBox.flatMap {
case Full(_) =>
errors ::= Messages("user.email.alreadyInUse")
Fox.successful(BadRequest(Json.obj("messages" -> Json.toJson(errors.map(t => Json.obj("error" -> t))))))
Expand Down Expand Up @@ -402,7 +404,7 @@ class AuthenticationController @Inject()(
request.identity match {
case Some(user) =>
// logged in
// Check if the request we recieved was signed using our private sso-key
// Check if the request we received was signed using our private sso-key
if (shaHex(ssoKey, sso) == sig) {
val payload = new String(Base64.decodeBase64(sso))
val values = play.core.parsers.FormUrlEncodedParser.parse(payload)
Expand All @@ -429,6 +431,91 @@ class AuthenticationController @Inject()(
}
}

private lazy val oidcClientId = conf.WebKnossos.OIDC.clientId
private lazy val oidcProviderUrl = conf.WebKnossos.OIDC.providerURL

lazy val oidcConfig: OpenIdConnectConfig = OpenIdConnectConfig(oidcProviderUrl, oidcClientId)
lazy val absoluteOidcCallbackURL = "http://localhost:9000/api/auth/oidc/callback"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should be WkConf.Http.uri instead of hard-coded localhost:9000

Suggested change
lazy val absoluteOidcCallbackURL = "http://localhost:9000/api/auth/oidc/callback"
lazy val absoluteCallbackUrl = s"${conf.Http.uri}/api/auth/oidc/callback"

And I think it would be fair to move this to the OpenIdConnectClient class, as it is only used there

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure about moving this to the oidcc class, because the client should exist independently of the controller and the callback url is very specific controller knowledge


// Claims from https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims
case class OpenConnectId(iss: String,
sub: String,
preferred_username: String,
given_name: String,
family_name: String,
email: String) {
def username: String = preferred_username
}

object OpenConnectId {
implicit val format = Json.format[OpenConnectId]
}

def loginViaOpenIdConnect(): Action[AnyContent] = sil.UserAwareAction.async { implicit request =>
openIdClient.redirectUrl(oidcConfig, absoluteOidcCallbackURL).map(url => Redirect(url))
}

def loginUser(user: User, loginInfo: LoginInfo)(implicit request: Request[AnyContent]): Future[Result] =
userService.retrieve(loginInfo).flatMap {
case Some(user) if !user.isDeactivated =>
for {
authenticator: CombinedAuthenticator <- combinedAuthenticatorService.create(loginInfo)
value: Cookie <- combinedAuthenticatorService.init(authenticator)
result: AuthenticatorResult <- combinedAuthenticatorService.embed(value, Redirect("/dashboard"))
_ <- multiUserDAO.updateLastLoggedInIdentity(user._multiUser, user._id)(GlobalAccessContext)
_ = userDAO.updateLastActivity(user._id)(GlobalAccessContext)
} yield result
case None =>
Future.successful(BadRequest(Messages("error.noUser")))
case Some(_) => Future.successful(BadRequest(Messages("user.deactivated")))
}

// Is called after user was successfully authenticated
def loginOrSignupViaOidc(oidc: OpenConnectId): Request[AnyContent] => Future[Result] = {
implicit request: Request[AnyContent] =>
userService.userFromMultiUserEmail(oidc.email)(GlobalAccessContext).futureBox.flatMap {
case Full(user) =>
val loginInfo = LoginInfo("credentials", user._id.toString)
loginUser(user, loginInfo)
case Empty =>
for {
organization: Organization <- organizationService.findOneByInviteByNameOrDefault(None, None)(
GlobalAccessContext)
user: User <- userService.insert(organization._id,
oidc.email,
oidc.given_name,
oidc.family_name,
isActive = true,
userService.getOIDCPasswordInfo)
multiUser: MultiUser <- multiUserDAO.findOne(user._multiUser)(GlobalAccessContext)
_ = analyticsService.track(SignupEvent(user, hadInvite = false))
// brainDBResult <- brainTracing.registerIfNeeded(user, signUpData.password).toFox // Is this necessary?
_ = if (conf.Features.isDemoInstance) {
mailchimpClient.registerUser(user, multiUser, tag = MailchimpTag.RegisteredAsUser)
} else {
Mailer ! Send(defaultMails.newUserMail(user.name, oidc.email, None, enableAutoVerify = true))
}
_ = Mailer ! Send(
defaultMails.registerAdminNotifyerMail(user.name, oidc.email, None, organization, autoActivate = true))
// After registering, also login
loginInfo = LoginInfo("credentials", user._id.toString)
loginResult <- loginUser(user, loginInfo)
} yield loginResult
case _ => Future.successful(InternalServerError)
}
}

def openIdCallback(): Action[AnyContent] = Action.async { implicit request =>
for {
code <- openIdClient.getToken(oidcConfig,
absoluteOidcCallbackURL,
request.queryString.get("code").flatMap(_.headOption).getOrElse("missing code"),
)
oidc: OpenConnectId <- validateJsValue[OpenConnectId](code).toFox
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
oidc: OpenConnectId <- validateJsValue[OpenConnectId](code).toFox
openConnectId <- validateJsValue[OpenConnectId](code).toFox

(if I understand correctly that OpenIdConnect uses OpenConnectIds? Seems confusing to me, but may be correct if that’s what the protocol states)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is not something that is directly part of the protocol. I renamed it to OpenIdConnectClaimSet since it describes it more accurately

user_result <- loginOrSignupViaOidc(oidc)(request)
} yield user_result
}

private def shaHex(key: String, valueToDigest: String): String =
new HmacUtils(HmacAlgorithms.HMAC_SHA_256, key).hmacHex(valueToDigest)

Expand All @@ -448,7 +535,7 @@ class AuthenticationController @Inject()(
errors ::= Messages("user.lastName.invalid")
""
}
multiUserDAO.findOneByEmail(email)(GlobalAccessContext).toFox.futureBox.flatMap {
multiUserDAO.findOneByEmail(email)(GlobalAccessContext).futureBox.flatMap {
case Full(_) =>
errors ::= Messages("user.email.alreadyInUse")
Fox.successful(BadRequest(Json.obj("messages" -> Json.toJson(errors.map(t => Json.obj("error" -> t))))))
Expand Down
5 changes: 2 additions & 3 deletions app/controllers/LegacyApiController.scala
Original file line number Diff line number Diff line change
Expand Up @@ -407,11 +407,10 @@ class LegacyApiController @Inject()(annotationController: AnnotationController,
def annotationEditV1(typ: String, id: String): Action[JsValue] = sil.SecuredAction.async(parse.json) {
implicit request =>
logVersioned(request)
val oldRequest = request.request
val oldRequest = request
val newRequest =
if (request.body.as[JsObject].keys.contains("isPublic"))
request.copy(
request = oldRequest.withBody(Json.toJson(insertVisibilityInJsObject(oldRequest.body.as[JsObject]))))
request.withBody(Json.toJson(insertVisibilityInJsObject(oldRequest.body.as[JsObject])))
else request

for {
Expand Down
2 changes: 1 addition & 1 deletion app/controllers/TaskController.scala
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ Expects:
file.filename.toLowerCase.endsWith(".nml") || file.filename.toLowerCase.endsWith(".zip"))
_ <- bool2Fox(inputFiles.nonEmpty) ?~> "nml.file.notFound"
jsonString <- body.dataParts.get("formJSON").flatMap(_.headOption) ?~> "format.json.missing"
params <- JsonHelper.parseJsonToFox[NmlTaskParameters](jsonString) ?~> "task.create.failed"
params <- JsonHelper.parseAndValidateJson[NmlTaskParameters](jsonString) ?~> "task.create.failed"
_ <- taskCreationService.assertBatchLimit(inputFiles.length, List(params.taskTypeId))
taskTypeIdValidated <- ObjectId.fromString(params.taskTypeId) ?~> "taskType.id.invalid"
taskType <- taskTypeDAO.findOne(taskTypeIdValidated) ?~> "taskType.notFound" ~> NOT_FOUND
Expand Down
2 changes: 1 addition & 1 deletion app/models/annotation/Annotation.scala
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ class AnnotationDAO @Inject()(sqlClient: SQLClient, annotationLayerDAO: Annotati
for {
state <- AnnotationState.fromString(r.state).toFox
typ <- AnnotationType.fromString(r.typ).toFox
viewconfigurationOpt <- Fox.runOptional(r.viewconfiguration)(JsonHelper.parseJsonToFox[JsObject](_))
viewconfigurationOpt <- Fox.runOptional(r.viewconfiguration)(JsonHelper.parseAndValidateJson[JsObject](_))
visibility <- AnnotationVisibility.fromString(r.visibility).toFox
annotationLayers <- annotationLayerDAO.findAnnotationLayersFor(ObjectId(r._Id))
} yield {
Expand Down
10 changes: 5 additions & 5 deletions app/models/binary/DataSet.scala
Original file line number Diff line number Diff line change
Expand Up @@ -80,10 +80,10 @@ class DataSetDAO @Inject()(sqlClient: SQLClient,
for {
scale <- parseScaleOpt(r.scale)
defaultViewConfigurationOpt <- Fox.runOptional(r.defaultviewconfiguration)(
JsonHelper.parseJsonToFox[DataSetViewConfiguration](_))
JsonHelper.parseAndValidateJson[DataSetViewConfiguration](_))
adminViewConfigurationOpt <- Fox.runOptional(r.adminviewconfiguration)(
JsonHelper.parseJsonToFox[DataSetViewConfiguration](_))
details <- Fox.runOptional(r.details)(JsonHelper.parseJsonToFox[JsObject](_))
JsonHelper.parseAndValidateJson[DataSetViewConfiguration](_))
details <- Fox.runOptional(r.details)(JsonHelper.parseAndValidateJson[JsObject](_))
} yield {
DataSet(
ObjectId(r._Id),
Expand Down Expand Up @@ -399,9 +399,9 @@ class DataSetDataLayerDAO @Inject()(sqlClient: SQLClient, dataSetResolutionsDAO:
resolutions <- Fox.fillOption(standinResolutions)(
dataSetResolutionsDAO.findDataResolutionForLayer(dataSetId, row.name) ?~> "Could not find resolution for layer")
defaultViewConfigurationOpt <- Fox.runOptional(row.defaultviewconfiguration)(
JsonHelper.parseJsonToFox[LayerViewConfiguration](_))
JsonHelper.parseAndValidateJson[LayerViewConfiguration](_))
adminViewConfigurationOpt <- Fox.runOptional(row.adminviewconfiguration)(
JsonHelper.parseJsonToFox[LayerViewConfiguration](_))
JsonHelper.parseAndValidateJson[LayerViewConfiguration](_))
} yield {
category match {
case Category.segmentation =>
Expand Down
2 changes: 1 addition & 1 deletion app/models/binary/explore/RemoteLayerExplorer.scala
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ trait RemoteLayerExplorer extends FoxImplicits {
protected def parseJsonFromPath[T: Reads](path: Path): Fox[T] =
for {
fileAsString <- tryo(new String(Files.readAllBytes(path), StandardCharsets.UTF_8)).toFox ?~> "Failed to read remote file"
parsed <- JsonHelper.parseJsonToFox[T](fileAsString) ?~> "Failed to validate json against data schema"
parsed <- JsonHelper.parseAndValidateJson[T](fileAsString) ?~> "Failed to validate json against data schema"
} yield parsed

protected def looksLikeSegmentationLayer(layerName: String, elementClass: ElementClass.Value): Boolean =
Expand Down
4 changes: 2 additions & 2 deletions app/models/organization/OrganizationService.scala
Original file line number Diff line number Diff line change
Expand Up @@ -41,12 +41,12 @@ class OrganizationService @Inject()(organizationDAO: OrganizationDAO,
)
}

def findOneByInviteByNameOrDefault(inviteOpt: Option[Invite], organizatioNameOpt: Option[String])(
def findOneByInviteByNameOrDefault(inviteOpt: Option[Invite], organizationNameOpt: Option[String])(
implicit ctx: DBAccessContext): Fox[Organization] =
inviteOpt match {
case Some(invite) => organizationDAO.findOne(invite._organization)
case None =>
organizatioNameOpt match {
organizationNameOpt match {
case Some(organizationName) => organizationDAO.findOneByName(organizationName)
case None =>
for {
Expand Down
2 changes: 1 addition & 1 deletion app/models/user/MultiUser.scala
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ class MultiUserDAO @Inject()(sqlClient: SQLClient)(implicit ec: ExecutionContext

def parse(r: MultiusersRow): Fox[MultiUser] =
for {
novelUserExperienceInfos <- JsonHelper.parseJsonToFox[JsObject](r.noveluserexperienceinfos).toFox
novelUserExperienceInfos <- JsonHelper.parseAndValidateJson[JsObject](r.noveluserexperienceinfos).toFox
theme <- Theme.fromString(r.selectedtheme).toFox
} yield {
MultiUser(
Expand Down
4 changes: 2 additions & 2 deletions app/models/user/User.scala
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ package models.user

import com.mohiva.play.silhouette.api.{Identity, LoginInfo}
import com.scalableminds.util.accesscontext._
import com.scalableminds.util.tools.JsonHelper.parseJsonToFox
import com.scalableminds.util.tools.JsonHelper.parseAndValidateJson
import com.scalableminds.util.tools.{Fox, FoxImplicits}
import com.scalableminds.webknossos.datastore.models.datasource.DataSetViewConfiguration.DataSetViewConfiguration
import com.scalableminds.webknossos.datastore.models.datasource.LayerViewConfiguration.LayerViewConfiguration
Expand Down Expand Up @@ -65,7 +65,7 @@ class UserDAO @Inject()(sqlClient: SQLClient)(implicit ec: ExecutionContext)

def parse(r: UsersRow): Fox[User] =
for {
userConfiguration <- parseJsonToFox[JsObject](r.userconfiguration)
userConfiguration <- parseAndValidateJson[JsObject](r.userconfiguration)
} yield {
User(
ObjectId(r._Id),
Expand Down
3 changes: 3 additions & 0 deletions app/models/user/UserService.scala
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,9 @@ class UserService @Inject()(conf: WkConf,
_ <- multiUserDAO.updatePasswordInfo(user._multiUser, passwordInfo)(GlobalAccessContext)
} yield passwordInfo

def getOIDCPasswordInfo: PasswordInfo =
PasswordInfo("Empty", "")

def updateUserConfiguration(user: User, configuration: JsObject)(implicit ctx: DBAccessContext): Fox[Unit] =
userDAO.updateUserConfiguration(user._id, configuration).map { result =>
userCache.invalidateUser(user._id)
Expand Down
93 changes: 93 additions & 0 deletions app/oxalis/security/OpenIdConnectClient.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
package oxalis.security

import com.scalableminds.util.tools.Fox
import com.scalableminds.util.tools.Fox.{jsResult2Fox, try2Fox}
import com.scalableminds.webknossos.datastore.rpc.RPC
import play.api.libs.json.{JsObject, Json, OFormat}
import pdi.jwt.{JwtJson, JwtOptions}
import play.api.libs.ws._
import utils.WkConf

import java.net.URLEncoder
import java.nio.charset.StandardCharsets
import javax.inject.Inject
import scala.concurrent.ExecutionContext

class OpenIDConnectClient @Inject()(rpc: RPC, conf: WkConf)(implicit executionContext: ExecutionContext) {

/*
Build redirect URL to redirect to OIDC provider for auth request (https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest)
*/
def redirectUrl(openIdClient: OpenIdConnectConfig, redirectUrl: String): Fox[String] =
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
def redirectUrl(openIdClient: OpenIdConnectConfig, redirectUrl: String): Fox[String] =
def getRedirectUrl: Fox[String] =

I’d add the get because redirect also reads a bit like a verb.

I’d say it is not necessary to pass the parameters in from the caller, but rather have lazy vals for the client itself, I thinkt it already has everything it needs to construct them (mostly the injected WkConf).
Maybe the OpenIdConnectConfig case class is not needed at all, but the fields can be read from the passed WkConf where needed?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redirect URL as stated above should not be known to the oidcclient IMO. The oidcconfig info can also be gathered at the client, true. However, the case class can be useful IMO because it allows for checking if the configuration is valid (and thus determine if the routes are activated)

discover(openIdClient).map { serverInfos =>
def queryParams: Map[String, String] = Map(
"client_id" -> openIdClient.clientId,
"redirect_uri" -> redirectUrl,
"scope" -> openIdClient.scope,
"response_type" -> "code",
)
serverInfos.authorization_endpoint + "?" +
queryParams.map(v => v._1 + "=" + URLEncoder.encode(v._2, StandardCharsets.UTF_8.toString)).mkString("&")
}

/*
Create token request (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(openIdClient: OpenIdConnectConfig, redirectUrl: String, code: String): Fox[JsObject] =
for {
serverInfos <- discover(openIdClient)
tokenResponse <- rpc(serverInfos.token_endpoint).postFormParseJson[OpenIdConnectTokenResponse](
Map(
"grant_type" -> "authorization_code",
"client_id" -> openIdClient.clientId,
"redirect_uri" -> redirectUrl,
"code" -> code
))
new_code <- JwtJson
.decodeJson(tokenResponse.access_token, JwtOptions.DEFAULT.copy(signature = false))
.toFox ?~> "failed to parse JWT"
} yield new_code

/*
Discover endpoints of the provider (https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfig)
*/
def discover(openIdClient: OpenIdConnectConfig): Fox[OpenIdConnectProviderInfo] =
for {
response: WSResponse <- rpc(openIdClient.discoveryUrl).get
serverInfo <- response.json.validate[OpenIdConnectProviderInfo](OpenIdConnectProviderInfo.format)
} yield serverInfo

}

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

object OpenIdConnectProviderInfo {
implicit val format: OFormat[OpenIdConnectProviderInfo] = Json.format[OpenIdConnectProviderInfo]
}

case class OpenIdConnectConfig(
baseUrl: String,
clientId: String,
scope: String = "openid profile"
) {

lazy val discoveryUrl: String = baseUrl + ".well-known/openid-configuration"
}

// Fields as specified by https://www.rfc-editor.org/rfc/rfc6749#section-5.1
case class OpenIdConnectTokenResponse(
access_token: String,
token_type: String,
expires_in: Option[String],
refresh_token: Option[String],
scope: Option[String]
)

object OpenIdConnectTokenResponse {
implicit val format: OFormat[OpenIdConnectTokenResponse] = Json.format[OpenIdConnectTokenResponse]
}
7 changes: 7 additions & 0 deletions app/utils/WkConf.scala
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,13 @@ class WkConf @Inject()(configuration: Configuration) extends ConfigReader with L
val children = List(User)
}

object OIDC {
val providerURL: String = get[String]("webKnossos.OIDC.providerURL")
val callbackURL: String = get[String]("webKnossos.OIDC.callbackURL")
val clientId: String = get[String]("webKnossos.OIDC.clientID")
val clientSecret: String = get[String]("webKnossos.OIDC.clientSecret")
}

val operatorData: String = get[String]("webKnossos.operatorData")
val children = List(User, Tasks, Cache, SampleOrganization)
}
Expand Down
Loading