diff --git a/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala b/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala
index 157a77a4ed..9c76228a1d 100644
--- a/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala
+++ b/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala
@@ -494,17 +494,12 @@ class Boot extends MdcLoggable {
enableVersionIfAllowed(ApiVersion.`dynamic-endpoint`)
enableVersionIfAllowed(ApiVersion.`dynamic-entity`)
- def enableOpenIdConnectApis = {
- // OpenIdConnect endpoint and validator
- if (code.api.Constant.openidConnectEnabled) {
- LiftRules.dispatch.append(OpenIdConnect)
- }
- }
- // DirectLogin (POST /my/logins/direct) and aliveCheck (GET /alive) are now
- // served by their native http4s counterparts wired into
- // Http4sApp.baseServices (DirectLoginRoutes / AliveCheckRoutes). The Lift
+ // OpenID Connect callbacks (/auth/openid-connect/callback{,-1,-2}), DirectLogin
+ // (POST /my/logins/direct) and aliveCheck (GET /alive) are now served by their
+ // native http4s counterparts wired into Http4sApp.baseServices
+ // (Http4sOpenIdConnect / DirectLoginRoutes / AliveCheckRoutes). The Lift
// dispatches were retired in the http4s migration; any prop gates
- // (e.g. `allow_direct_login`) live with those routes.
+ // (e.g. `openid_connect.enabled`, `allow_direct_login`) live with those routes.
diff --git a/obp-api/src/main/scala/code/api/Http4sOpenIdConnect.scala b/obp-api/src/main/scala/code/api/Http4sOpenIdConnect.scala
new file mode 100644
index 0000000000..19d14020f4
--- /dev/null
+++ b/obp-api/src/main/scala/code/api/Http4sOpenIdConnect.scala
@@ -0,0 +1,391 @@
+/**
+Open Bank Project - API
+Copyright (C) 2011-2019, TESOBE GmbH
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as published by
+the Free Software Foundation, either version 3 of the License, or
+(at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see .
+
+Email: contact@tesobe.com
+TESOBE GmbH.
+Osloer Strasse 16/17
+Berlin 13359, Germany
+
+This product includes software developed at
+TESOBE (http://www.tesobe.com/)
+
+ */
+package code.api
+
+import cats.effect.IO
+import code.api.OAuth2Login.Hydra
+import code.api.util.APIUtil._
+import code.api.util.http4s.{ErrorResponseConverter, Http4sCallContextBuilder}
+import code.api.util.{APIUtil, AfterApiAuth, CustomJsonFormats, ErrorMessages, JwtUtil}
+import code.api.v6_0_0.JSONFactory600
+import code.consumer.Consumers
+import code.loginattempts.LoginAttempt
+import code.model.dataAccess.AuthUser
+import code.model.{AppType, Consumer}
+import code.token.{OpenIDConnectToken, TokensOpenIDConnect}
+import code.users.Users
+import code.util.Helper.MdcLoggable
+import com.openbankproject.commons.model.User
+import net.liftweb.common._
+import net.liftweb.json
+import net.liftweb.json.JsonAST.prettyRender
+import net.liftweb.json.{Extraction, Formats}
+import net.liftweb.mapper.By
+import net.liftweb.util.Helpers
+import net.liftweb.util.Helpers._
+import org.http4s._
+import org.http4s.dsl.io._
+import org.http4s.headers.`Content-Type`
+
+import java.net.HttpURLConnection
+import javax.net.ssl.HttpsURLConnection
+
+/**
+ * Per-identity-provider OpenID Connect configuration, read from
+ * `openid_connect_$provider.*` props. Moved verbatim from the retired Lift
+ * `openidconnect.scala`; consumed by [[Http4sOpenIdConnect]].
+ */
+case class OpenIdConnectConfig(client_secret: String,
+ client_id: String,
+ callback_url: String,
+ userinfo_endpoint: String,
+ token_endpoint: String,
+ authorization_endpoint: String,
+ jwks_uri: String,
+ access_type_offline: Boolean
+ )
+
+object OpenIdConnectConfig {
+ lazy val openIDConnectEnabled = code.api.Constant.openidConnectEnabled
+ def getProps(props: String): String = {
+ APIUtil.getPropsValue(props).getOrElse("")
+ }
+ def get(provider: Int): OpenIdConnectConfig = {
+ OpenIdConnectConfig(
+ getProps(s"openid_connect_$provider.client_secret"),
+ getProps(s"openid_connect_$provider.client_id"),
+ getProps(s"openid_connect_$provider.callback_url"),
+ getProps(s"openid_connect_$provider.endpoint.userinfo"),
+ getProps(s"openid_connect_$provider.endpoint.token"),
+ getProps(s"openid_connect_$provider.endpoint.authorization"),
+ getProps(s"openid_connect_$provider.endpoint.jwks_uri"),
+ APIUtil.getPropsAsBoolValue(s"openid_connect_$provider.access_type_offline", false),
+ )
+ }
+}
+
+/**
+ * Native http4s OpenID Connect callback, replacing the Lift `OpenIdConnect`
+ * `serve {}` dispatch. OBP-API acts as the OIDC relying party: an external
+ * provider (OBP-OIDC, Keycloak, ...) authenticates the user, redirects the
+ * browser to one of these callbacks with `?code=...&state=...`, and the handler
+ * exchanges the code for tokens server-side.
+ *
+ * Provider contract preserved unchanged: the three callback paths, the
+ * form-encoded token exchange to `openid_connect_$provider.endpoint.token`
+ * (reading the same `openid_connect_$provider.*` props), and JWT validation
+ * against the provider's `jwks_uri`.
+ *
+ * Difference from the Lift version: instead of the (now-vestigial) Lift-session
+ * `logUserIn` + redirect, on success we mint a usable OBP DirectLogin token and
+ * return `200 {"token": "..."}`. The client then calls OBP APIs with
+ * `DirectLogin: token=...`.
+ *
+ * Gating: the route only fires when `openid_connect.enabled=true` (default
+ * false); otherwise the pattern guard fails and the request falls through to
+ * the Lift bridge (404), matching prior behaviour. A second runtime gate
+ * `allow_openid_connect` (default true) returns 401 when set false.
+ */
+object Http4sOpenIdConnect extends MdcLoggable {
+
+ private implicit val formats: Formats = CustomJsonFormats.formats
+
+ // Referenced by code.api.OAuth2 (getOrCreateConsumer description); kept here as
+ // the single home after the Lift OpenIdConnect object was retired.
+ val openIdConnect = "OpenID Connect"
+
+ // Registration gate, read per request so it stays togglable (default false).
+ private def enabled: Boolean = getPropsAsBoolValue("openid_connect.enabled", false)
+
+ val routes: HttpRoutes[IO] = HttpRoutes.of[IO] {
+ case req @ (GET | POST) -> Root / "auth" / "openid-connect" / "callback" if enabled => handle(req, 1)
+ case req @ (GET | POST) -> Root / "auth" / "openid-connect" / "callback-1" if enabled => handle(req, 1)
+ case req @ (GET | POST) -> Root / "auth" / "openid-connect" / "callback-2" if enabled => handle(req, 2)
+ }
+
+ private val jsonContentType = `Content-Type`(MediaType.application.json, Charset.`UTF-8`)
+
+ private def handle(req: Request[IO], identityProvider: Int): IO[Response[IO]] =
+ Http4sCallContextBuilder.fromRequest(req, apiVersion = "").flatMap { cc =>
+ if (!getPropsAsBoolValue("allow_openid_connect", true)) {
+ ErrorResponseConverter.createErrorResponse(401, ErrorMessages.OpenIDConnectIsDisabled, cc)
+ } else {
+ val code = param(req, cc, "code").getOrElse("")
+ val state = param(req, cc, "state").getOrElse("0")
+ // The whole flow is synchronous Lift-mapper / blocking HTTP work; run it off the compute pool.
+ IO.blocking(processCallback(identityProvider, code, state)).flatMap {
+ case Right(token) =>
+ Ok(prettyRender(Extraction.decompose(JSONFactory600.createTokenJSON(token))))
+ .map(_.withContentType(jsonContentType))
+ case Left((httpCode, message)) =>
+ ErrorResponseConverter.createErrorResponse(httpCode, message, cc)
+ }
+ }
+ }
+
+ /** Read a parameter from the query string, falling back to a form-urlencoded body (mirrors Lift `S.param`). */
+ private def param(req: Request[IO], cc: code.api.util.CallContext, name: String): Option[String] =
+ req.uri.query.params.get(name).orElse {
+ cc.httpBody.flatMap { body =>
+ body.split("&").iterator.map(_.split("=", 2)).collectFirst {
+ case Array(k, v) if java.net.URLDecoder.decode(k, "UTF-8") == name => java.net.URLDecoder.decode(v, "UTF-8")
+ }
+ }
+ }
+
+ private def checkSessionState(state: String, sessionState: String): Boolean =
+ if (getPropsAsBoolValue("openid_connect.check_session_state", true)) state == sessionState else true
+
+ /**
+ * Ports the Lift `callbackUrlCommonCode` business logic. Returns the minted OBP token on success,
+ * or `(httpCode, message)` on failure. All provider-facing steps (token exchange, JWT validation)
+ * and all provisioning side effects (resource user, auth user, entitlements, consumer, OIDC-token
+ * persistence) are preserved verbatim.
+ */
+ private def processCallback(identityProvider: Int, code: String, state: String): Either[(Int, String), String] = {
+ // Session state was always defaulted to "" once the portal pages were removed; preserved here.
+ val sessionState = ""
+ if (!checkSessionState(state, sessionState)) {
+ Left((401, ErrorMessages.InvalidOpenIDConnectState))
+ } else {
+ exchangeAuthorizationCodeForTokens(code, identityProvider) match {
+ case Full((idToken, accessToken, tokenType, expiresIn, refreshToken, scope)) =>
+ JwtUtil.validateIdToken(idToken, OpenIdConnectConfig.get(identityProvider).jwks_uri) match {
+ case Full(_) =>
+ getOrCreateResourceUser(idToken) match {
+ case Full(user) if LoginAttempt.userIsLocked(user.provider, user.name) =>
+ Left((401, ErrorMessages.UsernameHasBeenLocked))
+ case Full(user) =>
+ getOrCreateAuthUser(user) match {
+ case Full(authUser) =>
+ // Grant roles according to the props email_domain_to_space_mappings
+ AuthUser.grantEmailDomainEntitlementsToUser(authUser)
+ AuthUser.grantEntitlementsToUseDynamicEndpointsInSpaces(authUser)
+ // User init actions
+ AfterApiAuth.innerLoginUserInitAction(Full(authUser))
+ getOrCreateConsumer(idToken, user.userId) match {
+ case Full(consumer) =>
+ saveAuthorizationToken(tokenType, accessToken, idToken, refreshToken, scope, expiresIn, authUser.id.get) match {
+ case Full(_) =>
+ // Mint a usable OBP DirectLogin token bound to the provisioned user + consumer.
+ DirectLogin.issueTokenForUser(user.userPrimaryKey.value, consumer.key.get) match {
+ case Full(token) => Right(token)
+ case _ => Left((500, ErrorMessages.CouldNotHandleOpenIDConnectData + "issueToken"))
+ }
+ case _ => Left((401, ErrorMessages.CouldNotHandleOpenIDConnectData + "saveAuthorizationToken"))
+ }
+ case _ => Left((401, ErrorMessages.CouldNotHandleOpenIDConnectData + "getOrCreateConsumer"))
+ }
+ case _ => Left((401, ErrorMessages.CouldNotHandleOpenIDConnectData + "getOrCreateAuthUser"))
+ }
+ case _ => Left((401, ErrorMessages.CouldNotSaveOpenIDConnectUser))
+ }
+ case _ => Left((401, ErrorMessages.CouldNotValidateIDToken))
+ }
+ case _ => Left((401, ErrorMessages.CouldNotExchangeAuthorizationCodeForTokens))
+ }
+ }
+ }
+
+ // ── Business-logic helpers, ported verbatim from the Lift OpenIdConnect object ────────────────────
+
+ private def getOrCreateAuthUser(user: User): Box[AuthUser] = {
+ AuthUser.find(By(AuthUser.user, user.userPrimaryKey.value)) match {
+ case Full(user) => Full(user)
+ case _ => createAuthUser(user)
+ }
+ }
+
+ private def getOrCreateResourceUser(idToken: String): Box[User] = {
+ val uniqueIdGivenByProvider = JwtUtil.getSubject(idToken)
+ val preferredUsername = JwtUtil.getOptionalClaim("preferred_username", idToken)
+ // Try to get provider from token first, fallback to Hydra resolver
+ val provider = JwtUtil.getProvider(idToken).getOrElse(Hydra.resolveProvider(idToken))
+ val providerId = preferredUsername.orElse(uniqueIdGivenByProvider)
+ Users.users.vend.getUserByProviderId(provider = provider, idGivenByProvider = providerId.getOrElse("")).or { // Find a user
+ Users.users.vend.createResourceUser( // Otherwise create a new one
+ provider = provider,
+ providerId = providerId,
+ createdByConsentId = None,
+ name = providerId,
+ email = getClaim(name = "email", idToken = idToken),
+ userId = None,
+ createdByUserInvitationId = None,
+ company = None,
+ lastMarketingAgreementSignedDate = None
+ )
+ }
+ }
+
+ private def getClaim(name: String, idToken: String): Option[String] = {
+ val claim = JwtUtil.getClaim(name = name, jwtToken = idToken)
+ claim match {
+ case null => None
+ case string => Some(string)
+ }
+ }
+
+ private def createAuthUser(user: User): Box[AuthUser] = tryo {
+ val newUser = AuthUser.create
+ .firstName(user.name)
+ .email(user.emailAddress)
+ .user(user.userPrimaryKey.value)
+ .username(user.idGivenByProvider)
+ .provider(user.provider)
+ // No need to store password, so store dummy string instead
+ .password(Helpers.randomString(40))
+ .validated(true)
+ // Save the user in order to be able to log in
+ newUser.saveMe()
+ }
+
+ def exchangeAuthorizationCodeForTokens(authorizationCode: String, identityProvider: Int): Box[(String, String, String, Long, String, String)] = {
+ val config = OpenIdConnectConfig.get(identityProvider)
+ val data = "client_id=" + config.client_id + "&" +
+ "client_secret=" + config.client_secret + "&" +
+ "redirect_uri=" + config.callback_url + "&" +
+ "code=" + authorizationCode + "&" +
+ "grant_type=authorization_code"
+ logger.debug("Request parameters: " + data)
+ logger.debug("Token endpoint: " + config.token_endpoint)
+ val response: Box[String] = fromUrl(String.format("%s", config.token_endpoint), data, "POST")
+ logger.debug("Response: " + response)
+ response match {
+ case Full(value) =>
+ val tokenResponse = json.parse(value)
+ logger.debug("Token response: " + tokenResponse)
+ for {
+ idToken <- tryo{(tokenResponse \ "id_token").extractOrElse[String]("")}
+ accessToken <- tryo{(tokenResponse \ "access_token").extractOrElse[String]("")}
+ tokenType <- tryo{(tokenResponse \ "token_type").extractOrElse[String]("")}
+ expiresIn <- tryo{(tokenResponse \ "expires_in").extractOrElse[String]("")}
+ refreshToken <- tryo{(tokenResponse \ "refresh_token").extractOrElse[String]("")}
+ scope <- tryo{(tokenResponse \ "scope").extractOrElse[String]("")}
+ } yield {
+ logger.debug(s"(idToken: $idToken, accessToken: $accessToken, tokenType: $tokenType, expiresIn.toLong: ${expiresIn.toLong}, refreshToken: $refreshToken, scope: $scope)")
+ (idToken, accessToken, tokenType, expiresIn.toLong, refreshToken, scope)
+ }
+ case badObject@Failure(_, _, _) =>
+ logger.debug("Error at exchangeAuthorizationCodeForTokens: " + badObject)
+ badObject
+ case everythingElse =>
+ logger.debug("Error at exchangeAuthorizationCodeForTokens: " + everythingElse)
+ Failure(ErrorMessages.InternalServerError + " - exchangeAuthorizationCodeForTokens")
+ }
+ }
+
+ private def getOrCreateConsumer(idToken: String, userId: String): Box[Consumer] = {
+ Consumers.consumers.vend.getOrCreateConsumer(
+ consumerId=None,
+ None,
+ None,
+ Some(JwtUtil.getAudience(idToken).mkString(",")),
+ getClaim(name = "azp", idToken = idToken),
+ JwtUtil.getIssuer(idToken),
+ JwtUtil.getSubject(idToken),
+ Some(true),
+ name = Some(Helpers.randomString(10).toLowerCase),
+ appType = Some(AppType.Confidential),
+ description = Some(openIdConnect),
+ developerEmail = getClaim(name = "email", idToken = idToken),
+ redirectURL = None,
+ createdByUserId = Some(userId)
+ )
+ }
+
+ private def saveAuthorizationToken(tokenType: String,
+ accessToken: String,
+ idToken: String,
+ refreshToken: String,
+ scope: String,
+ expiresIn: Long,
+ authUserPrimaryKey: Long): Box[OpenIDConnectToken] = {
+ val token = TokensOpenIDConnect.tokens.vend.createToken(
+ tokenType = tokenType,
+ accessToken = accessToken,
+ idToken = idToken,
+ refreshToken = refreshToken,
+ scope = scope,
+ expiresIn = expiresIn,
+ authUserPrimaryKey = authUserPrimaryKey
+ )
+ token match {
+ case Full(_) => // All good
+ case error => logger.error(error)
+ }
+ token
+ }
+
+ def fromUrl( url: String,
+ data: String = "",
+ method: String,
+ connectTimeout: Int = 2000,
+ readTimeout: Int = 10000
+ ): Box[String] = {
+ var content:String = ""
+ import java.net.URL
+ try {
+ val connection = {
+ if (url.startsWith("https://")) {
+ val conn: HttpsURLConnection = new URL(url + {
+ if (method == "GET") data
+ else ""
+ }).openConnection.asInstanceOf[HttpsURLConnection]
+ conn
+ }
+ else {
+ val conn: HttpURLConnection = new URL(url + {
+ if (method == "GET") data
+ else ""
+ }).openConnection.asInstanceOf[HttpURLConnection]
+ conn
+ }
+ }
+ connection.setConnectTimeout(connectTimeout)
+ connection.setReadTimeout(readTimeout)
+ connection.setRequestMethod(method)
+ connection.setRequestProperty("Accept", "application/json")
+ if ( data != "" && method == "POST") {
+ connection.setRequestProperty("Content-type", "application/x-www-form-urlencoded")
+ connection.setRequestProperty("Charset", "utf-8")
+ val dataBytes = data.getBytes("UTF-8")
+ connection.setRequestProperty("Content-Length", dataBytes.length.toString)
+ connection.setDoOutput( true )
+ connection.getOutputStream.write(dataBytes)
+ }
+ val inputStream = connection.getInputStream
+ content = scala.io.Source.fromInputStream(inputStream).mkString
+ if (inputStream != null) inputStream.close()
+ Full(content)
+ } catch {
+ case e:Throwable =>
+ e.printStackTrace()
+ logger.error(e)
+ Failure(e.getMessage)
+ }
+ }
+}
diff --git a/obp-api/src/main/scala/code/api/OAuth2.scala b/obp-api/src/main/scala/code/api/OAuth2.scala
index 637e7e9f77..ca359ebfa4 100644
--- a/obp-api/src/main/scala/code/api/OAuth2.scala
+++ b/obp-api/src/main/scala/code/api/OAuth2.scala
@@ -533,7 +533,7 @@ object OAuth2Login extends MdcLoggable {
case Full(_) =>
logger.debug("applyIdTokenRules - ID token validation successful")
val user = getOrCreateResourceUser(token)
- val consumer = getOrCreateConsumer(token, user.map(_.userId), Some(OpenIdConnect.openIdConnect))
+ val consumer = getOrCreateConsumer(token, user.map(_.userId), Some(Http4sOpenIdConnect.openIdConnect))
LoginAttempt.userIsLocked(user.map(_.provider).getOrElse(""), user.map(_.name).getOrElse("")) match {
case true => ((Failure(UsernameHasBeenLocked), Some(cc.copy(consumer = consumer))))
case false => (user, Some(cc.copy(consumer = consumer)))
diff --git a/obp-api/src/main/scala/code/api/directlogin.scala b/obp-api/src/main/scala/code/api/directlogin.scala
index fa2f8ca31a..2b8ad2f665 100644
--- a/obp-api/src/main/scala/code/api/directlogin.scala
+++ b/obp-api/src/main/scala/code/api/directlogin.scala
@@ -529,6 +529,17 @@ object DirectLogin extends RestHelper with MdcLoggable {
}
}
+ /**
+ * Mint and persist a usable DirectLogin token for an already-authenticated user, bypassing the
+ * username/password validation in `createTokenCommonPart`. Used by the http4s OpenID Connect
+ * callback (`Http4sOpenIdConnect`) once the provider has verified the user's identity.
+ */
+ def issueTokenForUser(userPrimaryKey: Long, consumerKey: String): Box[String] = {
+ val (token, secret) = generateTokenAndSecret(JWTClaimsSet.parse("""{"":""}"""))
+ if (saveAuthorizationToken(Map("consumer_key" -> consumerKey), token, secret, userPrimaryKey)) Full(token)
+ else Failure("OpenIDConnect: could not persist DirectLogin token")
+ }
+
def getUser : Box[User] = {
val httpMethod = S.request match {
case Full(r) => r.request.method
diff --git a/obp-api/src/main/scala/code/api/openidconnect.scala b/obp-api/src/main/scala/code/api/openidconnect.scala
index df9e3bcfac..c74d853f9a 100644
--- a/obp-api/src/main/scala/code/api/openidconnect.scala
+++ b/obp-api/src/main/scala/code/api/openidconnect.scala
@@ -1,403 +1,403 @@
-/**
-Open Bank Project - API
-Copyright (C) 2011-2019, TESOBE GmbH
-
-This program is free software: you can redistribute it and/or modify
-it under the terms of the GNU Affero General Public License as published by
-the Free Software Foundation, either version 3 of the License, or
-(at your option) any later version.
-
-This program is distributed in the hope that it will be useful,
-but WITHOUT ANY WARRANTY; without even the implied warranty of
-MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-GNU Affero General Public License for more details.
-
-You should have received a copy of the GNU Affero General Public License
-along with this program. If not, see .
-
-Email: contact@tesobe.com
-TESOBE GmbH.
-Osloer Strasse 16/17
-Berlin 13359, Germany
-
-This product includes software developed at
-TESOBE (http://www.tesobe.com/)
-
- */
-package code.api
-
-import code.api.OAuth2Login.Hydra
-import code.api.util.APIUtil._
-import code.api.util.{APIUtil, AfterApiAuth, ErrorMessages, JwtUtil}
-import code.consumer.Consumers
-import code.loginattempts.LoginAttempt
-import code.model.dataAccess.AuthUser
-import code.model.{AppType, Consumer}
-import code.token.{OpenIDConnectToken, TokensOpenIDConnect}
-import code.users.Users
-import code.util.Helper.MdcLoggable
-import com.openbankproject.commons.model.User
-import com.openbankproject.commons.util.{ApiVersion, ApiVersionStatus}
-import net.liftweb.common._
-import net.liftweb.http._
-import net.liftweb.json
-import net.liftweb.json.JValue
-import net.liftweb.mapper.By
-import net.liftweb.util.Helpers
-import net.liftweb.util.Helpers._
-
-import java.net.HttpURLConnection
-import javax.net.ssl.HttpsURLConnection
-
-/**
- * This object provides the API calls necessary to authenticate
- * users using OpenIdConnect (http://openid.net).
- */
-
-case class OpenIdConnectConfig(client_secret: String,
- client_id: String,
- callback_url: String,
- userinfo_endpoint: String,
- token_endpoint: String,
- authorization_endpoint: String,
- jwks_uri: String,
- access_type_offline: Boolean
- )
-
-object OpenIdConnectConfig {
- lazy val openIDConnectEnabled = code.api.Constant.openidConnectEnabled
- def getProps(props: String): String = {
- APIUtil.getPropsValue(props).getOrElse("")
- }
- def get(provider: Int): OpenIdConnectConfig = {
- OpenIdConnectConfig(
- getProps(s"openid_connect_$provider.client_secret"),
- getProps(s"openid_connect_$provider.client_id"),
- getProps(s"openid_connect_$provider.callback_url"),
- getProps(s"openid_connect_$provider.endpoint.userinfo"),
- getProps(s"openid_connect_$provider.endpoint.token"),
- getProps(s"openid_connect_$provider.endpoint.authorization"),
- getProps(s"openid_connect_$provider.endpoint.jwks_uri"),
- APIUtil.getPropsAsBoolValue(s"openid_connect_$provider.access_type_offline", false),
- )
- }
-}
-
-object OpenIdConnect extends OBPRestHelper with MdcLoggable {
-
- val version = ApiVersion.openIdConnect1 // "1.0" // TODO: Should this be the lowest version supported or when introduced?
- val versionStatus = ApiVersionStatus.DRAFT.toString
-
- val openIdConnect = "OpenID Connect"
-
- serve {
- case Req("auth" :: "openid-connect" :: "callback" :: Nil, _, PostRequest | GetRequest) =>
- callbackUrlCommonCode(1)
- case Req("auth" :: "openid-connect" :: "callback-1" :: Nil, _, PostRequest | GetRequest) =>
- callbackUrlCommonCode(1)
- case Req("auth" :: "openid-connect" :: "callback-2" :: Nil, _, PostRequest | GetRequest) =>
- callbackUrlCommonCode(2)
- }
-
- private def callbackUrlCommonCode(identityProvider: Int): JsonResponse = {
- if (!APIUtil.getPropsAsBoolValue("allow_openid_connect", true)) {
- return errorJsonResponse(ErrorMessages.OpenIDConnectIsDisabled, 401)
- }
-
- val (code, state, sessionState) = extractParams(S)
- logger.debug("(code, state, sessionState) = " + (code, state, sessionState))
- logger.debug("S.receivedCookies = " + S.receivedCookies)
- logger.debug("S.responseCookies = " + S.responseCookies)
-
- def chainErrorMessage(badObj: Failure, errorMessage: String) = {
- val chainedFailure: Failure = badObj ?~! errorMessage
- (401, filterMessage(chainedFailure), None)
- }
-
- def checkSessionState: Boolean = {
- if (APIUtil.getPropsAsBoolValue("openid_connect.check_session_state", true))
- state == sessionState
- else true
- }
-
- val (httpCode, message, authorizationUser) = if (checkSessionState) {
- exchangeAuthorizationCodeForTokens(code, identityProvider) match {
- case Full((idToken, accessToken, tokenType, expiresIn, refreshToken, scope)) =>
- JwtUtil.validateIdToken(idToken, OpenIdConnectConfig.get(identityProvider).jwks_uri) match {
- case Full(_) =>
- getOrCreateResourceUser(idToken) match {
- case Full(user) if LoginAttempt.userIsLocked(user.provider, user.name) => // User is locked
- (401, ErrorMessages.UsernameHasBeenLocked, None)
- case Full(user) => // All good
- getOrCreateAuthUser(user) match {
- case Full(authUser) =>
- // Grant roles according to the props email_domain_to_space_mappings
- AuthUser.grantEmailDomainEntitlementsToUser(authUser)
- // Grant roles according to the props email_domain_to_space_mappings
- AuthUser.grantEntitlementsToUseDynamicEndpointsInSpaces(authUser)
- // User init actions
- AfterApiAuth.innerLoginUserInitAction(Full(authUser))
- // Consumer
- getOrCreateConsumer(idToken, user.userId) match {
- case Full(consumer) =>
- saveAuthorizationToken(tokenType, accessToken, idToken, refreshToken, scope, expiresIn, authUser.id.get) match {
- case Full(token) => (200, "OK", Some(authUser))
- case badObj@Failure(_, _, _) => chainErrorMessage(badObj, ErrorMessages.CouldNotHandleOpenIDConnectData+ "saveAuthorizationToken")
- case everythingElse =>
- logger.debug("Error at saveAuthorizationToken: " + everythingElse)
- (401, ErrorMessages.CouldNotHandleOpenIDConnectData + "saveAuthorizationToken", Some(authUser))
- }
- case badObj@Failure(_, _, _) => chainErrorMessage(badObj, ErrorMessages.CouldNotHandleOpenIDConnectData + "getOrCreateConsumer")
- case everythingElse =>
- logger.debug("Error at getOrCreateConsumer: " + everythingElse)
- (401, ErrorMessages.CouldNotHandleOpenIDConnectData + "getOrCreateConsumer", Some(authUser))
- }
- case badObj@Failure(_, _, _) => chainErrorMessage(badObj, ErrorMessages.CouldNotHandleOpenIDConnectData + "getOrCreateAuthUser")
- case everythingElse =>
- logger.debug("Error at getOrCreateAuthUser: " + everythingElse)
- (401, ErrorMessages.CouldNotHandleOpenIDConnectData + "getOrCreateAuthUser", None)
- }
- case badObj@Failure(_, _, _) => chainErrorMessage(badObj, ErrorMessages.CouldNotSaveOpenIDConnectUser)
- case everythingElse =>
- logger.debug("Error at getOrCreateResourceUser: " + everythingElse)
- (401, ErrorMessages.CouldNotSaveOpenIDConnectUser, None)
- }
- case badObj@Failure(_, _, _) =>
- logger.debug("Error at JwtUtil.validateIdToken: " + badObj)
- chainErrorMessage(badObj, ErrorMessages.CouldNotValidateIDToken)
- case everythingElse =>
- logger.debug("Error at JwtUtil.validateIdToken: " + everythingElse)
- (401, ErrorMessages.CouldNotValidateIDToken, None)
- }
- case badObj@Failure(_, _, _) =>
- logger.debug("Error at exchangeAuthorizationCodeForTokens: " + badObj)
- chainErrorMessage(badObj, ErrorMessages.CouldNotExchangeAuthorizationCodeForTokens)
- case everythingElse =>
- logger.debug("Error at exchangeAuthorizationCodeForTokens: " + everythingElse)
- (401, ErrorMessages.CouldNotExchangeAuthorizationCodeForTokens, None)
- }
- } else {
- (401, ErrorMessages.InvalidOpenIDConnectState, None)
- }
-
- (httpCode, authorizationUser) match {
- case (200, Some(user)) =>
- val loginRedirect = AuthUser.loginRedirect.get
- AuthUser.logUserIn(user, () => {
- S.notice(S.?("logged.in"))
- //This redirect to homePage, it is from scala code, no open redirect issue.
- val redirectUrl = loginRedirect match {
- case Full(url) =>
- AuthUser.loginRedirect(Empty)
- url
- case _ =>
- AuthUser.homePage
- }
- S.redirectTo(redirectUrl)
- })
- case _ =>
- errorJsonResponse(message, httpCode)
- }
- }
-
- private def extractParams(s: S): (String, String, String) = {
- // TODO Figure out why ObpS does not contain response parameter code
- val code = s.param("code")
- val state = s.param("state")
- // Session state removed with portal pages - using default value
- val sessionState = Box.legacyNullTest("")
- (code.getOrElse(""), state.getOrElse("0"), sessionState.map(_.toString).getOrElse("1"))
- }
-
- private def getOrCreateAuthUser(user: User): Box[AuthUser] = {
- AuthUser.find(By(AuthUser.user, user.userPrimaryKey.value)) match {
- case Full(user) => Full(user)
- case _ => createAuthUser(user)
- }
- }
-
- private def getOrCreateResourceUser(idToken: String): Box[User] = {
- val uniqueIdGivenByProvider = JwtUtil.getSubject(idToken)
- val preferredUsername = JwtUtil.getOptionalClaim("preferred_username", idToken)
- // Try to get provider from token first, fallback to Hydra resolver
- val provider = JwtUtil.getProvider(idToken).getOrElse(Hydra.resolveProvider(idToken))
- val providerId = preferredUsername.orElse(uniqueIdGivenByProvider)
- Users.users.vend.getUserByProviderId(provider = provider, idGivenByProvider = providerId.getOrElse("")).or { // Find a user
- Users.users.vend.createResourceUser( // Otherwise create a new one
- provider = provider,
- providerId = providerId,
- createdByConsentId = None,
- name = providerId,
- email = getClaim(name = "email", idToken = idToken),
- userId = None,
- createdByUserInvitationId = None,
- company = None,
- lastMarketingAgreementSignedDate = None
- )
- }
- }
-
- private def getClaim(name: String, idToken: String): Option[String] = {
- val claim = JwtUtil.getClaim(name = name, jwtToken = idToken)
- claim match {
- case null => None
- case string => Some(string)
- }
- }
- private def createAuthUser(user: User): Box[AuthUser] = tryo {
- val newUser = AuthUser.create
- .firstName(user.name)
- .email(user.emailAddress)
- .user(user.userPrimaryKey.value)
- .username(user.idGivenByProvider)
- .provider(user.provider)
- // No need to store password, so store dummy string instead
- .password(Helpers.randomString(40))
- .validated(true)
- // Save the user in order to be able to log in
- newUser.saveMe()
- }
-
- def exchangeAuthorizationCodeForTokens(authorizationCode: String, identityProvider: Int): Box[(String, String, String, Long, String, String)] = {
- val config = OpenIdConnectConfig.get(identityProvider)
- val data = "client_id=" + config.client_id + "&" +
- "client_secret=" + config.client_secret + "&" +
- "redirect_uri=" + config.callback_url + "&" +
- "code=" + authorizationCode + "&" +
- "grant_type=authorization_code"
- logger.debug("Request parameters: " + data)
- logger.debug("Token endpoint: " + config.token_endpoint)
- val response: Box[String] = fromUrl(String.format("%s", config.token_endpoint), data, "POST")
- logger.debug("Response: " + response)
- response match {
- case Full(value) =>
- val tokenResponse = json.parse(value)
- logger.debug("Token response: " + tokenResponse)
- for {
- idToken <- tryo{(tokenResponse \ "id_token").extractOrElse[String]("")}
- accessToken <- tryo{(tokenResponse \ "access_token").extractOrElse[String]("")}
- tokenType <- tryo{(tokenResponse \ "token_type").extractOrElse[String]("")}
- expiresIn <- tryo{(tokenResponse \ "expires_in").extractOrElse[String]("")}
- refreshToken <- tryo{(tokenResponse \ "refresh_token").extractOrElse[String]("")}
- scope <- tryo{(tokenResponse \ "scope").extractOrElse[String]("")}
- } yield {
- logger.debug(s"(idToken: $idToken, accessToken: $accessToken, tokenType: $tokenType, expiresIn.toLong: ${expiresIn.toLong}, refreshToken: $refreshToken, scope: $scope)")
- (idToken, accessToken, tokenType, expiresIn.toLong, refreshToken, scope)
- }
- case badObject@Failure(_, _, _) =>
- logger.debug("Error at exchangeAuthorizationCodeForTokens: " + badObject)
- badObject
- case everythingElse =>
- logger.debug("Error at exchangeAuthorizationCodeForTokens: " + everythingElse)
- Failure(ErrorMessages.InternalServerError + " - exchangeAuthorizationCodeForTokens")
- }
- }
-
- def getUserInfo(accessToken: String, identityProvider: Int): Box[JValue] = {
- val config = OpenIdConnectConfig.get(identityProvider)
- val userResponse = json.parse(
- fromUrl(
- String.format("%s", config.userinfo_endpoint),
- "?access_token="+accessToken,
- "GET"
- ).openOrThrowException(ErrorMessages.InternalServerError + " - getUserInfo")
- )
- userResponse match {
- case response: JValue => Full(response)
- case _ => Empty
- }
- }
-
- private def getOrCreateConsumer(idToken: String, userId: String): Box[Consumer] = {
- Consumers.consumers.vend.getOrCreateConsumer(
- consumerId=None,
- None,
- None,
- Some(JwtUtil.getAudience(idToken).mkString(",")),
- getClaim(name = "azp", idToken = idToken),
- JwtUtil.getIssuer(idToken),
- JwtUtil.getSubject(idToken),
- Some(true),
- name = Some(Helpers.randomString(10).toLowerCase),
- appType = Some(AppType.Confidential),
- description = Some(openIdConnect),
- developerEmail = getClaim(name = "email", idToken = idToken),
- redirectURL = None,
- createdByUserId = Some(userId)
- )
- }
-
- private def saveAuthorizationToken(tokenType: String,
- accessToken: String,
- idToken: String,
- refreshToken: String,
- scope: String,
- expiresIn: Long,
- authUserPrimaryKey: Long): Box[OpenIDConnectToken] = {
- val token = TokensOpenIDConnect.tokens.vend.createToken(
- tokenType = tokenType,
- accessToken = accessToken,
- idToken = idToken,
- refreshToken = refreshToken,
- scope = scope,
- expiresIn = expiresIn,
- authUserPrimaryKey = authUserPrimaryKey
- )
- token match {
- case Full(_) => // All good
- case error => logger.error(error)
- }
- token
- }
-
- def fromUrl( url: String,
- data: String = "",
- method: String,
- connectTimeout: Int = 2000,
- readTimeout: Int = 10000
- ): Box[String] = {
- var content:String = ""
- import java.net.URL
- try {
- val connection = {
- if (url.startsWith("https://")) {
- val conn: HttpsURLConnection = new URL(url + {
- if (method == "GET") data
- else ""
- }).openConnection.asInstanceOf[HttpsURLConnection]
- conn
- }
- else {
- val conn: HttpURLConnection = new URL(url + {
- if (method == "GET") data
- else ""
- }).openConnection.asInstanceOf[HttpURLConnection]
- conn
- }
- }
- connection.setConnectTimeout(connectTimeout)
- connection.setReadTimeout(readTimeout)
- connection.setRequestMethod(method)
- connection.setRequestProperty("Accept", "application/json")
- if ( data != "" && method == "POST") {
- connection.setRequestProperty("Content-type", "application/x-www-form-urlencoded")
- connection.setRequestProperty("Charset", "utf-8")
- val dataBytes = data.getBytes("UTF-8")
- connection.setRequestProperty("Content-Length", dataBytes.length.toString)
- connection.setDoOutput( true )
- connection.getOutputStream.write(dataBytes)
- }
- val inputStream = connection.getInputStream
- content = scala.io.Source.fromInputStream(inputStream).mkString
- if (inputStream != null) inputStream.close()
- Full(content)
- } catch {
- case e:Throwable =>
- e.printStackTrace()
- logger.error(e)
- Failure(e.getMessage)
- }
- }
-
-
-}
+///**
+//Open Bank Project - API
+//Copyright (C) 2011-2019, TESOBE GmbH
+//
+//This program is free software: you can redistribute it and/or modify
+//it under the terms of the GNU Affero General Public License as published by
+//the Free Software Foundation, either version 3 of the License, or
+//(at your option) any later version.
+//
+//This program is distributed in the hope that it will be useful,
+//but WITHOUT ANY WARRANTY; without even the implied warranty of
+//MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+//GNU Affero General Public License for more details.
+//
+//You should have received a copy of the GNU Affero General Public License
+//along with this program. If not, see .
+//
+//Email: contact@tesobe.com
+//TESOBE GmbH.
+//Osloer Strasse 16/17
+//Berlin 13359, Germany
+//
+//This product includes software developed at
+//TESOBE (http://www.tesobe.com/)
+//
+// */
+//package code.api
+//
+//import code.api.OAuth2Login.Hydra
+//import code.api.util.APIUtil._
+//import code.api.util.{APIUtil, AfterApiAuth, ErrorMessages, JwtUtil}
+//import code.consumer.Consumers
+//import code.loginattempts.LoginAttempt
+//import code.model.dataAccess.AuthUser
+//import code.model.{AppType, Consumer}
+//import code.token.{OpenIDConnectToken, TokensOpenIDConnect}
+//import code.users.Users
+//import code.util.Helper.MdcLoggable
+//import com.openbankproject.commons.model.User
+//import com.openbankproject.commons.util.{ApiVersion, ApiVersionStatus}
+//import net.liftweb.common._
+//import net.liftweb.http._
+//import net.liftweb.json
+//import net.liftweb.json.JValue
+//import net.liftweb.mapper.By
+//import net.liftweb.util.Helpers
+//import net.liftweb.util.Helpers._
+//
+//import java.net.HttpURLConnection
+//import javax.net.ssl.HttpsURLConnection
+//
+///**
+// * This object provides the API calls necessary to authenticate
+// * users using OpenIdConnect (http://openid.net).
+// */
+//
+//case class OpenIdConnectConfig(client_secret: String,
+// client_id: String,
+// callback_url: String,
+// userinfo_endpoint: String,
+// token_endpoint: String,
+// authorization_endpoint: String,
+// jwks_uri: String,
+// access_type_offline: Boolean
+// )
+//
+//object OpenIdConnectConfig {
+// lazy val openIDConnectEnabled = code.api.Constant.openidConnectEnabled
+// def getProps(props: String): String = {
+// APIUtil.getPropsValue(props).getOrElse("")
+// }
+// def get(provider: Int): OpenIdConnectConfig = {
+// OpenIdConnectConfig(
+// getProps(s"openid_connect_$provider.client_secret"),
+// getProps(s"openid_connect_$provider.client_id"),
+// getProps(s"openid_connect_$provider.callback_url"),
+// getProps(s"openid_connect_$provider.endpoint.userinfo"),
+// getProps(s"openid_connect_$provider.endpoint.token"),
+// getProps(s"openid_connect_$provider.endpoint.authorization"),
+// getProps(s"openid_connect_$provider.endpoint.jwks_uri"),
+// APIUtil.getPropsAsBoolValue(s"openid_connect_$provider.access_type_offline", false),
+// )
+// }
+//}
+//
+//object OpenIdConnect extends OBPRestHelper with MdcLoggable {
+//
+// val version = ApiVersion.openIdConnect1 // "1.0" // TODO: Should this be the lowest version supported or when introduced?
+// val versionStatus = ApiVersionStatus.DRAFT.toString
+//
+// val openIdConnect = "OpenID Connect"
+//
+// serve {
+// case Req("auth" :: "openid-connect" :: "callback" :: Nil, _, PostRequest | GetRequest) =>
+// callbackUrlCommonCode(1)
+// case Req("auth" :: "openid-connect" :: "callback-1" :: Nil, _, PostRequest | GetRequest) =>
+// callbackUrlCommonCode(1)
+// case Req("auth" :: "openid-connect" :: "callback-2" :: Nil, _, PostRequest | GetRequest) =>
+// callbackUrlCommonCode(2)
+// }
+//
+// private def callbackUrlCommonCode(identityProvider: Int): JsonResponse = {
+// if (!APIUtil.getPropsAsBoolValue("allow_openid_connect", true)) {
+// return errorJsonResponse(ErrorMessages.OpenIDConnectIsDisabled, 401)
+// }
+//
+// val (code, state, sessionState) = extractParams(S)
+// logger.debug("(code, state, sessionState) = " + (code, state, sessionState))
+// logger.debug("S.receivedCookies = " + S.receivedCookies)
+// logger.debug("S.responseCookies = " + S.responseCookies)
+//
+// def chainErrorMessage(badObj: Failure, errorMessage: String) = {
+// val chainedFailure: Failure = badObj ?~! errorMessage
+// (401, filterMessage(chainedFailure), None)
+// }
+//
+// def checkSessionState: Boolean = {
+// if (APIUtil.getPropsAsBoolValue("openid_connect.check_session_state", true))
+// state == sessionState
+// else true
+// }
+//
+// val (httpCode, message, authorizationUser) = if (checkSessionState) {
+// exchangeAuthorizationCodeForTokens(code, identityProvider) match {
+// case Full((idToken, accessToken, tokenType, expiresIn, refreshToken, scope)) =>
+// JwtUtil.validateIdToken(idToken, OpenIdConnectConfig.get(identityProvider).jwks_uri) match {
+// case Full(_) =>
+// getOrCreateResourceUser(idToken) match {
+// case Full(user) if LoginAttempt.userIsLocked(user.provider, user.name) => // User is locked
+// (401, ErrorMessages.UsernameHasBeenLocked, None)
+// case Full(user) => // All good
+// getOrCreateAuthUser(user) match {
+// case Full(authUser) =>
+// // Grant roles according to the props email_domain_to_space_mappings
+// AuthUser.grantEmailDomainEntitlementsToUser(authUser)
+// // Grant roles according to the props email_domain_to_space_mappings
+// AuthUser.grantEntitlementsToUseDynamicEndpointsInSpaces(authUser)
+// // User init actions
+// AfterApiAuth.innerLoginUserInitAction(Full(authUser))
+// // Consumer
+// getOrCreateConsumer(idToken, user.userId) match {
+// case Full(consumer) =>
+// saveAuthorizationToken(tokenType, accessToken, idToken, refreshToken, scope, expiresIn, authUser.id.get) match {
+// case Full(token) => (200, "OK", Some(authUser))
+// case badObj@Failure(_, _, _) => chainErrorMessage(badObj, ErrorMessages.CouldNotHandleOpenIDConnectData+ "saveAuthorizationToken")
+// case everythingElse =>
+// logger.debug("Error at saveAuthorizationToken: " + everythingElse)
+// (401, ErrorMessages.CouldNotHandleOpenIDConnectData + "saveAuthorizationToken", Some(authUser))
+// }
+// case badObj@Failure(_, _, _) => chainErrorMessage(badObj, ErrorMessages.CouldNotHandleOpenIDConnectData + "getOrCreateConsumer")
+// case everythingElse =>
+// logger.debug("Error at getOrCreateConsumer: " + everythingElse)
+// (401, ErrorMessages.CouldNotHandleOpenIDConnectData + "getOrCreateConsumer", Some(authUser))
+// }
+// case badObj@Failure(_, _, _) => chainErrorMessage(badObj, ErrorMessages.CouldNotHandleOpenIDConnectData + "getOrCreateAuthUser")
+// case everythingElse =>
+// logger.debug("Error at getOrCreateAuthUser: " + everythingElse)
+// (401, ErrorMessages.CouldNotHandleOpenIDConnectData + "getOrCreateAuthUser", None)
+// }
+// case badObj@Failure(_, _, _) => chainErrorMessage(badObj, ErrorMessages.CouldNotSaveOpenIDConnectUser)
+// case everythingElse =>
+// logger.debug("Error at getOrCreateResourceUser: " + everythingElse)
+// (401, ErrorMessages.CouldNotSaveOpenIDConnectUser, None)
+// }
+// case badObj@Failure(_, _, _) =>
+// logger.debug("Error at JwtUtil.validateIdToken: " + badObj)
+// chainErrorMessage(badObj, ErrorMessages.CouldNotValidateIDToken)
+// case everythingElse =>
+// logger.debug("Error at JwtUtil.validateIdToken: " + everythingElse)
+// (401, ErrorMessages.CouldNotValidateIDToken, None)
+// }
+// case badObj@Failure(_, _, _) =>
+// logger.debug("Error at exchangeAuthorizationCodeForTokens: " + badObj)
+// chainErrorMessage(badObj, ErrorMessages.CouldNotExchangeAuthorizationCodeForTokens)
+// case everythingElse =>
+// logger.debug("Error at exchangeAuthorizationCodeForTokens: " + everythingElse)
+// (401, ErrorMessages.CouldNotExchangeAuthorizationCodeForTokens, None)
+// }
+// } else {
+// (401, ErrorMessages.InvalidOpenIDConnectState, None)
+// }
+//
+// (httpCode, authorizationUser) match {
+// case (200, Some(user)) =>
+// val loginRedirect = AuthUser.loginRedirect.get
+// AuthUser.logUserIn(user, () => {
+// S.notice(S.?("logged.in"))
+// //This redirect to homePage, it is from scala code, no open redirect issue.
+// val redirectUrl = loginRedirect match {
+// case Full(url) =>
+// AuthUser.loginRedirect(Empty)
+// url
+// case _ =>
+// AuthUser.homePage
+// }
+// S.redirectTo(redirectUrl)
+// })
+// case _ =>
+// errorJsonResponse(message, httpCode)
+// }
+// }
+//
+// private def extractParams(s: S): (String, String, String) = {
+// // TODO Figure out why ObpS does not contain response parameter code
+// val code = s.param("code")
+// val state = s.param("state")
+// // Session state removed with portal pages - using default value
+// val sessionState = Box.legacyNullTest("")
+// (code.getOrElse(""), state.getOrElse("0"), sessionState.map(_.toString).getOrElse("1"))
+// }
+//
+// private def getOrCreateAuthUser(user: User): Box[AuthUser] = {
+// AuthUser.find(By(AuthUser.user, user.userPrimaryKey.value)) match {
+// case Full(user) => Full(user)
+// case _ => createAuthUser(user)
+// }
+// }
+//
+// private def getOrCreateResourceUser(idToken: String): Box[User] = {
+// val uniqueIdGivenByProvider = JwtUtil.getSubject(idToken)
+// val preferredUsername = JwtUtil.getOptionalClaim("preferred_username", idToken)
+// // Try to get provider from token first, fallback to Hydra resolver
+// val provider = JwtUtil.getProvider(idToken).getOrElse(Hydra.resolveProvider(idToken))
+// val providerId = preferredUsername.orElse(uniqueIdGivenByProvider)
+// Users.users.vend.getUserByProviderId(provider = provider, idGivenByProvider = providerId.getOrElse("")).or { // Find a user
+// Users.users.vend.createResourceUser( // Otherwise create a new one
+// provider = provider,
+// providerId = providerId,
+// createdByConsentId = None,
+// name = providerId,
+// email = getClaim(name = "email", idToken = idToken),
+// userId = None,
+// createdByUserInvitationId = None,
+// company = None,
+// lastMarketingAgreementSignedDate = None
+// )
+// }
+// }
+//
+// private def getClaim(name: String, idToken: String): Option[String] = {
+// val claim = JwtUtil.getClaim(name = name, jwtToken = idToken)
+// claim match {
+// case null => None
+// case string => Some(string)
+// }
+// }
+// private def createAuthUser(user: User): Box[AuthUser] = tryo {
+// val newUser = AuthUser.create
+// .firstName(user.name)
+// .email(user.emailAddress)
+// .user(user.userPrimaryKey.value)
+// .username(user.idGivenByProvider)
+// .provider(user.provider)
+// // No need to store password, so store dummy string instead
+// .password(Helpers.randomString(40))
+// .validated(true)
+// // Save the user in order to be able to log in
+// newUser.saveMe()
+// }
+//
+// def exchangeAuthorizationCodeForTokens(authorizationCode: String, identityProvider: Int): Box[(String, String, String, Long, String, String)] = {
+// val config = OpenIdConnectConfig.get(identityProvider)
+// val data = "client_id=" + config.client_id + "&" +
+// "client_secret=" + config.client_secret + "&" +
+// "redirect_uri=" + config.callback_url + "&" +
+// "code=" + authorizationCode + "&" +
+// "grant_type=authorization_code"
+// logger.debug("Request parameters: " + data)
+// logger.debug("Token endpoint: " + config.token_endpoint)
+// val response: Box[String] = fromUrl(String.format("%s", config.token_endpoint), data, "POST")
+// logger.debug("Response: " + response)
+// response match {
+// case Full(value) =>
+// val tokenResponse = json.parse(value)
+// logger.debug("Token response: " + tokenResponse)
+// for {
+// idToken <- tryo{(tokenResponse \ "id_token").extractOrElse[String]("")}
+// accessToken <- tryo{(tokenResponse \ "access_token").extractOrElse[String]("")}
+// tokenType <- tryo{(tokenResponse \ "token_type").extractOrElse[String]("")}
+// expiresIn <- tryo{(tokenResponse \ "expires_in").extractOrElse[String]("")}
+// refreshToken <- tryo{(tokenResponse \ "refresh_token").extractOrElse[String]("")}
+// scope <- tryo{(tokenResponse \ "scope").extractOrElse[String]("")}
+// } yield {
+// logger.debug(s"(idToken: $idToken, accessToken: $accessToken, tokenType: $tokenType, expiresIn.toLong: ${expiresIn.toLong}, refreshToken: $refreshToken, scope: $scope)")
+// (idToken, accessToken, tokenType, expiresIn.toLong, refreshToken, scope)
+// }
+// case badObject@Failure(_, _, _) =>
+// logger.debug("Error at exchangeAuthorizationCodeForTokens: " + badObject)
+// badObject
+// case everythingElse =>
+// logger.debug("Error at exchangeAuthorizationCodeForTokens: " + everythingElse)
+// Failure(ErrorMessages.InternalServerError + " - exchangeAuthorizationCodeForTokens")
+// }
+// }
+//
+// def getUserInfo(accessToken: String, identityProvider: Int): Box[JValue] = {
+// val config = OpenIdConnectConfig.get(identityProvider)
+// val userResponse = json.parse(
+// fromUrl(
+// String.format("%s", config.userinfo_endpoint),
+// "?access_token="+accessToken,
+// "GET"
+// ).openOrThrowException(ErrorMessages.InternalServerError + " - getUserInfo")
+// )
+// userResponse match {
+// case response: JValue => Full(response)
+// case _ => Empty
+// }
+// }
+//
+// private def getOrCreateConsumer(idToken: String, userId: String): Box[Consumer] = {
+// Consumers.consumers.vend.getOrCreateConsumer(
+// consumerId=None,
+// None,
+// None,
+// Some(JwtUtil.getAudience(idToken).mkString(",")),
+// getClaim(name = "azp", idToken = idToken),
+// JwtUtil.getIssuer(idToken),
+// JwtUtil.getSubject(idToken),
+// Some(true),
+// name = Some(Helpers.randomString(10).toLowerCase),
+// appType = Some(AppType.Confidential),
+// description = Some(openIdConnect),
+// developerEmail = getClaim(name = "email", idToken = idToken),
+// redirectURL = None,
+// createdByUserId = Some(userId)
+// )
+// }
+//
+// private def saveAuthorizationToken(tokenType: String,
+// accessToken: String,
+// idToken: String,
+// refreshToken: String,
+// scope: String,
+// expiresIn: Long,
+// authUserPrimaryKey: Long): Box[OpenIDConnectToken] = {
+// val token = TokensOpenIDConnect.tokens.vend.createToken(
+// tokenType = tokenType,
+// accessToken = accessToken,
+// idToken = idToken,
+// refreshToken = refreshToken,
+// scope = scope,
+// expiresIn = expiresIn,
+// authUserPrimaryKey = authUserPrimaryKey
+// )
+// token match {
+// case Full(_) => // All good
+// case error => logger.error(error)
+// }
+// token
+// }
+//
+// def fromUrl( url: String,
+// data: String = "",
+// method: String,
+// connectTimeout: Int = 2000,
+// readTimeout: Int = 10000
+// ): Box[String] = {
+// var content:String = ""
+// import java.net.URL
+// try {
+// val connection = {
+// if (url.startsWith("https://")) {
+// val conn: HttpsURLConnection = new URL(url + {
+// if (method == "GET") data
+// else ""
+// }).openConnection.asInstanceOf[HttpsURLConnection]
+// conn
+// }
+// else {
+// val conn: HttpURLConnection = new URL(url + {
+// if (method == "GET") data
+// else ""
+// }).openConnection.asInstanceOf[HttpURLConnection]
+// conn
+// }
+// }
+// connection.setConnectTimeout(connectTimeout)
+// connection.setReadTimeout(readTimeout)
+// connection.setRequestMethod(method)
+// connection.setRequestProperty("Accept", "application/json")
+// if ( data != "" && method == "POST") {
+// connection.setRequestProperty("Content-type", "application/x-www-form-urlencoded")
+// connection.setRequestProperty("Charset", "utf-8")
+// val dataBytes = data.getBytes("UTF-8")
+// connection.setRequestProperty("Content-Length", dataBytes.length.toString)
+// connection.setDoOutput( true )
+// connection.getOutputStream.write(dataBytes)
+// }
+// val inputStream = connection.getInputStream
+// content = scala.io.Source.fromInputStream(inputStream).mkString
+// if (inputStream != null) inputStream.close()
+// Full(content)
+// } catch {
+// case e:Throwable =>
+// e.printStackTrace()
+// logger.error(e)
+// Failure(e.getMessage)
+// }
+// }
+//
+//
+//}
diff --git a/obp-api/src/main/scala/code/api/util/http4s/Http4sApp.scala b/obp-api/src/main/scala/code/api/util/http4s/Http4sApp.scala
index bbfc3642c7..a781dcb2ba 100644
--- a/obp-api/src/main/scala/code/api/util/http4s/Http4sApp.scala
+++ b/obp-api/src/main/scala/code/api/util/http4s/Http4sApp.scala
@@ -148,6 +148,7 @@ object Http4sApp {
.orElse(dynamicEntityRoutes.run(req))
.orElse(dynamicEndpointRoutes.run(req))
.orElse(code.api.DirectLoginRoutes.routes.run(req))
+ .orElse(code.api.Http4sOpenIdConnect.routes.run(req))
.orElse(code.api.AliveCheckRoutes.routes.run(req))
.orElse(Http4sLiftWebBridge.routes.run(req))
}
diff --git a/obp-api/src/test/scala/code/api/Http4sOpenIdConnectRoutesTest.scala b/obp-api/src/test/scala/code/api/Http4sOpenIdConnectRoutesTest.scala
new file mode 100644
index 0000000000..8657567ba6
--- /dev/null
+++ b/obp-api/src/test/scala/code/api/Http4sOpenIdConnectRoutesTest.scala
@@ -0,0 +1,105 @@
+package code.api
+
+import cats.effect.IO
+import cats.effect.unsafe.implicits.global
+import code.api.util.ErrorMessages
+import code.setup.ServerSetup
+import org.http4s.{Method, Request, Uri}
+
+/**
+ * Pure route test for the native http4s OpenID Connect callback
+ * (`Http4sOpenIdConnect`). No live provider, no TCP, no DB — drives the routes
+ * in-process and flips the gating Props via [[PropsReset]].
+ *
+ * Pins the gates and path matching that the OBP-OIDC / Keycloak integration
+ * depends on:
+ * - `openid_connect.enabled=false` (default) → the callback paths do not match
+ * and fall through (None), exactly as before the migration.
+ * - `openid_connect.enabled=true` → the three callback paths match GET and POST.
+ * - `allow_openid_connect=false` → 401 OpenIDConnectIsDisabled.
+ * - the session-state gate and the token-exchange failure both surface as 401.
+ *
+ * The success path (200 {token}) needs a real provider to mint the OIDC tokens,
+ * so it is covered by the manual end-to-end verification, not here.
+ */
+class Http4sOpenIdConnectRoutesTest extends ServerSetup {
+
+ private def run(req: Request[IO]): Option[(Int, String)] =
+ Http4sOpenIdConnect.routes.run(req).value.unsafeRunSync().map { resp =>
+ val body = new String(resp.body.compile.to(Array).unsafeRunSync(), "UTF-8")
+ (resp.status.code, body)
+ }
+
+ private def get(path: String): Request[IO] = Request[IO](Method.GET, Uri.unsafeFromString(path))
+ private def post(path: String): Request[IO] = Request[IO](Method.POST, Uri.unsafeFromString(path))
+
+ feature("OpenID Connect callback gating (openid_connect.enabled)") {
+
+ scenario("Disabled by default — callback paths fall through (None)") {
+ Given("openid_connect.enabled is not set (default false)")
+ When("the three callback paths are invoked with GET and POST")
+ Then("none match — request falls through to the Lift bridge, as before the migration")
+ run(get("/auth/openid-connect/callback")) shouldBe None
+ run(post("/auth/openid-connect/callback")) shouldBe None
+ run(get("/auth/openid-connect/callback-1")) shouldBe None
+ run(post("/auth/openid-connect/callback-2")) shouldBe None
+ }
+
+ scenario("Enabled — the three callback paths match GET and POST") {
+ Given("openid_connect.enabled=true and the session-state check disabled (no portal)")
+ setPropsValues(
+ "openid_connect.enabled" -> "true",
+ "openid_connect.check_session_state" -> "false"
+ )
+ When("the three callback paths are invoked with GET and POST (no provider configured)")
+ Then("each matches and yields a 401 token-exchange failure (not None)")
+ // No provider props → exchangeAuthorizationCodeForTokens fails → 401 CouldNotExchange...
+ List(
+ get("/auth/openid-connect/callback"),
+ post("/auth/openid-connect/callback"),
+ get("/auth/openid-connect/callback-1"),
+ post("/auth/openid-connect/callback-1"),
+ get("/auth/openid-connect/callback-2"),
+ post("/auth/openid-connect/callback-2")
+ ).foreach { req =>
+ val (code, body) = run(req).getOrElse(fail(s"route did not match ${req.method} ${req.uri}"))
+ code shouldBe 401
+ body should include(ErrorMessages.CouldNotExchangeAuthorizationCodeForTokens)
+ }
+ }
+
+ scenario("Enabled but allow_openid_connect=false → 401 OpenIDConnectIsDisabled") {
+ Given("openid_connect.enabled=true but allow_openid_connect=false")
+ setPropsValues(
+ "openid_connect.enabled" -> "true",
+ "allow_openid_connect" -> "false"
+ )
+ When("the callback is invoked")
+ val (code, body) = run(post("/auth/openid-connect/callback"))
+ .getOrElse(fail("route did not match"))
+ Then("it returns 401 OpenIDConnectIsDisabled before any token exchange")
+ code shouldBe 401
+ body should include(ErrorMessages.OpenIDConnectIsDisabled)
+ }
+
+ scenario("Enabled, default session-state check, non-matching state → 401 InvalidOpenIDConnectState") {
+ Given("openid_connect.enabled=true with the session-state check left at its default (true)")
+ setPropsValues("openid_connect.enabled" -> "true")
+ When("a callback arrives whose state does not equal the (empty) session state")
+ val (code, body) = run(get("/auth/openid-connect/callback?code=abc&state=non-empty"))
+ .getOrElse(fail("route did not match"))
+ Then("it returns 401 InvalidOpenIDConnectState before any token exchange")
+ code shouldBe 401
+ body should include(ErrorMessages.InvalidOpenIDConnectState)
+ }
+
+ scenario("Enabled — an unrelated /auth/openid-connect path does not match") {
+ Given("openid_connect.enabled=true")
+ setPropsValues("openid_connect.enabled" -> "true")
+ When("a path that is not one of the three callbacks is invoked")
+ Then("the route does not match")
+ run(get("/auth/openid-connect/callback-3")) shouldBe None
+ run(get("/auth/openid-connect/other")) shouldBe None
+ }
+ }
+}