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 + } + } +}