diff --git a/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala b/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala index f8c8be873c..2496aad5a7 100644 --- a/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala +++ b/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala @@ -479,12 +479,12 @@ class Boot extends MdcLoggable { enableVersionIfAllowed(ApiVersion.`dynamic-endpoint`) enableVersionIfAllowed(ApiVersion.`dynamic-entity`) - // 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. `openid_connect.enabled`, `allow_direct_login`) live with those routes. + // 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 dispatches were retired in the + // http4s migration; any prop gates (e.g. `allow_direct_login`) live with those + // routes. The OBP-as-relying-party OpenID Connect callback was removed: OBP is a + // pure OAuth2 resource server (Bearer-JWT validation), login is done by the client/BFF. ////////////////////////////////////////////////////////////////////////////////////////////////// // Resource Docs are used in the process of surfacing endpoints so we enable them explicitly diff --git a/obp-api/src/main/scala/code/api/Http4sOpenIdConnect.scala b/obp-api/src/main/scala/code/api/Http4sOpenIdConnect.scala deleted file mode 100644 index 0d0c5747f5..0000000000 --- a/obp-api/src/main/scala/code/api/Http4sOpenIdConnect.scala +++ /dev/null @@ -1,408 +0,0 @@ -/** -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.db.DB -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.DefaultConnectionIdentifier -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 - * `notFoundCatchAll` (JSON 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") - } - } - } - - // CONFIG CAVEAT: portal-login was removed, so nothing stores the OIDC `state` server-side; - // `sessionState` is always "" (see processCallback). With the default - // openid_connect.check_session_state=true this fail-closed gate rejects every real (non-empty) - // state with 401 InvalidOpenIDConnectState — so OIDC deployments MUST set - // openid_connect.check_session_state=false (or reintroduce server-side state storage). - // Default kept true (fail-closed) on purpose. Long-term fix: stateless CSRF (PKCE / signed state). - 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(_) => - // Restore the single-connection-per-request semantics that Lift's removed - // S.addAround(DB.buildLoanWrapper) gave: all provisioning writes share one - // connection and commit together; a thrown DB error rolls the whole set back - // (same primitive as deletion.DeletionUtil.databaseAtomicTask). The network - // steps above (token exchange + JWKS validation) are kept OUTSIDE the tx so no - // DB connection is held during remote HTTP calls. - DB.use(DefaultConnectionIdentifier) { _ => - 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" - // Do NOT log `data` — it contains client_secret. Log the endpoint URL only. - logger.debug("Token exchange POST to: " + config.token_endpoint) - val response: Box[String] = fromUrl(String.format("%s", config.token_endpoint), data, "POST") - // Do NOT log the raw response — it contains id/access/refresh tokens. - logger.debug("Token endpoint response received (success=" + response.isDefined + ")") - response match { - case Full(value) => - val tokenResponse = json.parse(value) - 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 { - // Do NOT log token values (id/access/refresh). Non-sensitive metadata only. - logger.debug(s"OIDC token parsed (tokenType=$tokenType, expiresIn=${expiresIn.toLong}, 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 ca359ebfa4..23b345f13b 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(Http4sOpenIdConnect.openIdConnect)) + val consumer = getOrCreateConsumer(token, user.map(_.userId), Some("OpenID Connect")) 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 dc28977a05..01e9b66c4e 100644 --- a/obp-api/src/main/scala/code/api/directlogin.scala +++ b/obp-api/src/main/scala/code/api/directlogin.scala @@ -475,17 +475,6 @@ object DirectLogin extends 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 = "GET" val (httpCode, message, directLoginParameters) = validator("protectedResource") diff --git a/obp-api/src/main/scala/code/api/util/Glossary.scala b/obp-api/src/main/scala/code/api/util/Glossary.scala index 72285089f3..eb774a4696 100644 --- a/obp-api/src/main/scala/code/api/util/Glossary.scala +++ b/obp-api/src/main/scala/code/api/util/Glossary.scala @@ -2309,7 +2309,7 @@ object Glossary extends MdcLoggable { | | GET /obp/v3.0.0/users/current HTTP/1.1 | Host: $getServerUrl -| Authorization: Authorization: Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6IjA4ZDMyNDVjNjJmODZiNjM2MmFmY2JiZmZlMWQwNjk4MjZkZDFkYzEiLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJodHRwczovL2FjY291bnRzLmdvb2dsZS5jb20iLCJhenAiOiI0MDc0MDg3MTgxOTIuYXBwcy5nb29nbGV1c2VyY29udGVudC5jb20iLCJhdWQiOiI0MDc0MDg3MTgxOTIuYXBwcy5nb29nbGV1c2VyY29udGVudC5jb20iLCJzdWIiOiIxMTM5NjY4NTQyNDU3ODA4OTI5NTkiLCJlbWFpbCI6Im1hcmtvLm1pbGljLnNyYmlqYUBnbWFpbC5jb20iLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwiYXRfaGFzaCI6IkFvYVNGQTlVTTdCSGg3YWZYNGp2TmciLCJuYW1lIjoiTWFya28gTWlsacSHIiwicGljdHVyZSI6Imh0dHBzOi8vbGg1Lmdvb2dsZXVzZXJjb250ZW50LmNvbS8tWGQ0NGhuSjZURG8vQUFBQUFBQUFBQUkvQUFBQUFBQUFBQUEvQUt4cndjYWR3emhtNE40dFdrNUU4QXZ4aS1aSzZrczRxZy9zOTYtYy9waG90by5qcGciLCJnaXZlbl9uYW1lIjoiTWFya28iLCJmYW1pbHlfbmFtZSI6Ik1pbGnEhyIsImxvY2FsZSI6ImVuIiwiaWF0IjoxNTQ3NzExMTE1LCJleHAiOjE1NDc3MTQ3MTV9.MKsyecCSKS4Y0C8R4JP0J0d2Oa-xahvMAbtfFrGHncTm8xBgeaNb50XSJn20ak1YyA8hZiRP2M3el0f4eIVQZsMMa22MrwaiL8pLb1zGfawDLPb1RvOmoCWTDJGc_s1qQMlyc21Wenr9rjuu1bQCerGTYM6M0Aq-Uu_GT0lCEjz5WVDI5xDUf4Mhdi8HYq7UQ1kGz1gQFiBm5nI3_xtYm75EfXFeDg3TejaMmy36NpgtwN_vwpHByoHE5BoTl2J55rJ2creZZ7CmtZttm-9HsT6v1vxT8zi0RXObFrZSk-LgfF0tJQcGZ5LXQZL0yMKXPQVFIMCg8J0Gg7l_QACkCA +| Authorization: Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6IjA4ZDMyNDVjNjJmODZiNjM2MmFmY2JiZmZlMWQwNjk4MjZkZDFkYzEiLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJodHRwczovL2FjY291bnRzLmdvb2dsZS5jb20iLCJhenAiOiI0MDc0MDg3MTgxOTIuYXBwcy5nb29nbGV1c2VyY29udGVudC5jb20iLCJhdWQiOiI0MDc0MDg3MTgxOTIuYXBwcy5nb29nbGV1c2VyY29udGVudC5jb20iLCJzdWIiOiIxMTM5NjY4NTQyNDU3ODA4OTI5NTkiLCJlbWFpbCI6Im1hcmtvLm1pbGljLnNyYmlqYUBnbWFpbC5jb20iLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwiYXRfaGFzaCI6IkFvYVNGQTlVTTdCSGg3YWZYNGp2TmciLCJuYW1lIjoiTWFya28gTWlsacSHIiwicGljdHVyZSI6Imh0dHBzOi8vbGg1Lmdvb2dsZXVzZXJjb250ZW50LmNvbS8tWGQ0NGhuSjZURG8vQUFBQUFBQUFBQUkvQUFBQUFBQUFBQUEvQUt4cndjYWR3emhtNE40dFdrNUU4QXZ4aS1aSzZrczRxZy9zOTYtYy9waG90by5qcGciLCJnaXZlbl9uYW1lIjoiTWFya28iLCJmYW1pbHlfbmFtZSI6Ik1pbGnEhyIsImxvY2FsZSI6ImVuIiwiaWF0IjoxNTQ3NzExMTE1LCJleHAiOjE1NDc3MTQ3MTV9.MKsyecCSKS4Y0C8R4JP0J0d2Oa-xahvMAbtfFrGHncTm8xBgeaNb50XSJn20ak1YyA8hZiRP2M3el0f4eIVQZsMMa22MrwaiL8pLb1zGfawDLPb1RvOmoCWTDJGc_s1qQMlyc21Wenr9rjuu1bQCerGTYM6M0Aq-Uu_GT0lCEjz5WVDI5xDUf4Mhdi8HYq7UQ1kGz1gQFiBm5nI3_xtYm75EfXFeDg3TejaMmy36NpgtwN_vwpHByoHE5BoTl2J55rJ2creZZ7CmtZttm-9HsT6v1vxT8zi0RXObFrZSk-LgfF0tJQcGZ5LXQZL0yMKXPQVFIMCg8J0Gg7l_QACkCA | Cache-Control: no-cache | | @@ -2319,7 +2319,7 @@ object Glossary extends MdcLoggable { | | curl -X GET | $getServerUrl/obp/v3.0.0/users/current -| -H 'Authorization: Authorization: Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6IjA4ZDMyNDVjNjJmODZiNjM2MmFmY2JiZmZlMWQwNjk4MjZkZDFkYzEiLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJodHRwczovL2FjY291bnRzLmdvb2dsZS5jb20iLCJhenAiOiI0MDc0MDg3MTgxOTIuYXBwcy5nb29nbGV1c2VyY29udGVudC5jb20iLCJhdWQiOiI0MDc0MDg3MTgxOTIuYXBwcy5nb29nbGV1c2VyY29udGVudC5jb20iLCJzdWIiOiIxMTM5NjY4NTQyNDU3ODA4OTI5NTkiLCJlbWFpbCI6Im1hcmtvLm1pbGljLnNyYmlqYUBnbWFpbC5jb20iLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwiYXRfaGFzaCI6IkFvYVNGQTlVTTdCSGg3YWZYNGp2TmciLCJuYW1lIjoiTWFya28gTWlsacSHIiwicGljdHVyZSI6Imh0dHBzOi8vbGg1Lmdvb2dsZXVzZXJjb250ZW50LmNvbS8tWGQ0NGhuSjZURG8vQUFBQUFBQUFBQUkvQUFBQUFBQUFBQUEvQUt4cndjYWR3emhtNE40dFdrNUU4QXZ4aS1aSzZrczRxZy9zOTYtYy9waG90by5qcGciLCJnaXZlbl9uYW1lIjoiTWFya28iLCJmYW1pbHlfbmFtZSI6Ik1pbGnEhyIsImxvY2FsZSI6ImVuIiwiaWF0IjoxNTQ3NzExMTE1LCJleHAiOjE1NDc3MTQ3MTV9.MKsyecCSKS4Y0C8R4JP0J0d2Oa-xahvMAbtfFrGHncTm8xBgeaNb50XSJn20ak1YyA8hZiRP2M3el0f4eIVQZsMMa22MrwaiL8pLb1zGfawDLPb1RvOmoCWTDJGc_s1qQMlyc21Wenr9rjuu1bQCerGTYM6M0Aq-Uu_GT0lCEjz5WVDI5xDUf4Mhdi8HYq7UQ1kGz1gQFiBm5nI3_xtYm75EfXFeDg3TejaMmy36NpgtwN_vwpHByoHE5BoTl2J55rJ2creZZ7CmtZttm-9HsT6v1vxT8zi0RXObFrZSk-LgfF0tJQcGZ5LXQZL0yMKXPQVFIMCg8J0Gg7l_QACkCA' +| -H 'Authorization: Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6IjA4ZDMyNDVjNjJmODZiNjM2MmFmY2JiZmZlMWQwNjk4MjZkZDFkYzEiLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJodHRwczovL2FjY291bnRzLmdvb2dsZS5jb20iLCJhenAiOiI0MDc0MDg3MTgxOTIuYXBwcy5nb29nbGV1c2VyY29udGVudC5jb20iLCJhdWQiOiI0MDc0MDg3MTgxOTIuYXBwcy5nb29nbGV1c2VyY29udGVudC5jb20iLCJzdWIiOiIxMTM5NjY4NTQyNDU3ODA4OTI5NTkiLCJlbWFpbCI6Im1hcmtvLm1pbGljLnNyYmlqYUBnbWFpbC5jb20iLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwiYXRfaGFzaCI6IkFvYVNGQTlVTTdCSGg3YWZYNGp2TmciLCJuYW1lIjoiTWFya28gTWlsacSHIiwicGljdHVyZSI6Imh0dHBzOi8vbGg1Lmdvb2dsZXVzZXJjb250ZW50LmNvbS8tWGQ0NGhuSjZURG8vQUFBQUFBQUFBQUkvQUFBQUFBQUFBQUEvQUt4cndjYWR3emhtNE40dFdrNUU4QXZ4aS1aSzZrczRxZy9zOTYtYy9waG90by5qcGciLCJnaXZlbl9uYW1lIjoiTWFya28iLCJmYW1pbHlfbmFtZSI6Ik1pbGnEhyIsImxvY2FsZSI6ImVuIiwiaWF0IjoxNTQ3NzExMTE1LCJleHAiOjE1NDc3MTQ3MTV9.MKsyecCSKS4Y0C8R4JP0J0d2Oa-xahvMAbtfFrGHncTm8xBgeaNb50XSJn20ak1YyA8hZiRP2M3el0f4eIVQZsMMa22MrwaiL8pLb1zGfawDLPb1RvOmoCWTDJGc_s1qQMlyc21Wenr9rjuu1bQCerGTYM6M0Aq-Uu_GT0lCEjz5WVDI5xDUf4Mhdi8HYq7UQ1kGz1gQFiBm5nI3_xtYm75EfXFeDg3TejaMmy36NpgtwN_vwpHByoHE5BoTl2J55rJ2creZZ7CmtZttm-9HsT6v1vxT8zi0RXObFrZSk-LgfF0tJQcGZ5LXQZL0yMKXPQVFIMCg8J0Gg7l_QACkCA' | -H 'Cache-Control: no-cache' | -H 'Postman-Token: aa812d04-eddd-4752-adb7-4d56b3a98f36' | 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 8f9f568857..f678a66bf1 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 @@ -144,7 +144,6 @@ object Http4sApp extends MdcLoggable { .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(notFoundCatchAll.run(req)) } diff --git a/obp-api/src/test/scala/code/api/Http4sOpenIdConnectRoutesTest.scala b/obp-api/src/test/scala/code/api/Http4sOpenIdConnectRoutesTest.scala deleted file mode 100644 index e9ff0c58e5..0000000000 --- a/obp-api/src/test/scala/code/api/Http4sOpenIdConnectRoutesTest.scala +++ /dev/null @@ -1,105 +0,0 @@ -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 next route (ultimately notFoundCatchAll / JSON 404)") - 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 - } - } -} diff --git a/obp-api/src/test/scala/code/api/Http4sOpenIdConnectSuccessTest.scala b/obp-api/src/test/scala/code/api/Http4sOpenIdConnectSuccessTest.scala deleted file mode 100644 index 2df62e119a..0000000000 --- a/obp-api/src/test/scala/code/api/Http4sOpenIdConnectSuccessTest.scala +++ /dev/null @@ -1,133 +0,0 @@ -package code.api - -import cats.effect.IO -import cats.effect.unsafe.implicits.global -import code.setup.ServerSetup -import code.users.Users -import com.comcast.ip4s._ -import com.nimbusds.jose.crypto.RSASSASigner -import com.nimbusds.jose.jwk.JWKSet -import com.nimbusds.jose.jwk.gen.RSAKeyGenerator -import com.nimbusds.jose.{JWSAlgorithm, JWSHeader} -import com.nimbusds.jwt.{JWTClaimsSet, SignedJWT} -import org.http4s.dsl.io._ -import org.http4s.ember.server.EmberServerBuilder -import org.http4s.implicits._ -import org.http4s.{HttpRoutes, Method, Request, Uri} - -import java.util.Date - -/** - * Success-path integration test for [[Http4sOpenIdConnect]] — the `200 {"token": ...}` - * branch that the routing/failure suite ([[Http4sOpenIdConnectRoutesTest]]) cannot reach - * because it needs a provider to mint the OIDC tokens. - * - * Self-contained, no live provider: stands up a local stub OIDC provider (Ember) that - * serves - * - `POST /token` → a token response whose `id_token` is a locally-signed RS256 JWT - * - `GET /jwks` → the matching public JWK set - * points the `openid_connect_1.*` props at it, then drives the callback route in-process. - * It asserts the handler exchanges the code, validates the JWT against the JWKS, - * provisions the resource user, and returns `200 {"token": ...}`. - * - * Why a freshly-signed token is enough: `JwtUtil.validateIdToken` reads `iss`/`aud` from - * the token itself and only enforces the signature (against the served JWKS) and expiry, - * so no configured iss/aud matching is required. The JWS header `kid` matches the served - * JWK so the verification key selector picks the right key. - * - * This also exercises the M1 change — the provisioning block is now wrapped in - * `DB.use(DefaultConnectionIdentifier)` (one connection for all OIDC writes). - */ -class Http4sOpenIdConnectSuccessTest extends ServerSetup { - - private val providerClaim = "http://127.0.0.1/oidc-test-provider" - private val preferredUser = "oidctestuser" - private val clientId = "obp-oidc-test-client" - - // RSA keypair used to sign the id_token; its public half is served at /jwks. - private val rsaJwk = new RSAKeyGenerator(2048).keyID("oidc-test-kid").generate() - - private def signedIdToken(issuer: String): String = { - val claims = new JWTClaimsSet.Builder() - .issuer(issuer) - .subject("oidc-test-subject") - .audience(clientId) - .expirationTime(new Date(System.currentTimeMillis() + 3600L * 1000)) - .issueTime(new Date()) - .claim("preferred_username", preferredUser) - .claim("email", "oidctest@example.com") - .claim("provider", providerClaim) - .claim("azp", clientId) - .build() - val jwt = new SignedJWT( - new JWSHeader.Builder(JWSAlgorithm.RS256).keyID(rsaJwk.getKeyID).build(), - claims - ) - jwt.sign(new RSASSASigner(rsaJwk)) - jwt.serialize() - } - - /** Allocate an ephemeral free port for the stub provider. */ - private def freePort(): Int = { - val socket = new java.net.ServerSocket(0) - try socket.getLocalPort finally socket.close() - } - - private def stubProvider(issuer: String): HttpRoutes[IO] = HttpRoutes.of[IO] { - case POST -> Root / "token" => - Ok(s"""{"id_token":"${signedIdToken(issuer)}","access_token":"access-xyz",""" + - s""""token_type":"Bearer","expires_in":"3600","refresh_token":"refresh-xyz","scope":"openid"}""") - case GET -> Root / "jwks" => - Ok(new JWKSet(rsaJwk.toPublicJWK).toString) - } - - private def run(req: Request[IO]): (Int, String) = - Http4sOpenIdConnect.routes.run(req).value.unsafeRunSync().map { resp => - (resp.status.code, new String(resp.body.compile.to(Array).unsafeRunSync(), "UTF-8")) - }.getOrElse(fail(s"route did not match ${req.method} ${req.uri}")) - - feature("OpenID Connect callback — success path") { - - scenario("valid code + signed id_token → 200 {token} and the user is provisioned") { - val port = freePort() - val portObj = Port.fromInt(port).getOrElse(fail(s"invalid free port $port")) - val issuer = s"http://127.0.0.1:$port" - - val server = EmberServerBuilder - .default[IO] - .withHost(ipv4"127.0.0.1") - .withPort(portObj) - .withHttpApp(stubProvider(issuer).orNotFound) - .build - - server.use { _ => - IO { - Given("a local stub OIDC provider and openid_connect_1.* pointed at it") - setPropsValues( - "openid_connect.enabled" -> "true", - "openid_connect.check_session_state" -> "false", - "allow_openid_connect" -> "true", - "openid_connect_1.client_id" -> clientId, - "openid_connect_1.client_secret" -> "test-secret", - "openid_connect_1.callback_url" -> "http://localhost/auth/openid-connect/callback", - "openid_connect_1.endpoint.token" -> s"$issuer/token", - "openid_connect_1.endpoint.jwks_uri" -> s"$issuer/jwks" - ) - - When("the provider redirects back to the callback with an authorization code") - val (code, body) = run( - Request[IO](Method.GET, - Uri.unsafeFromString("/auth/openid-connect/callback?code=auth-code-123&state=ignored")) - ) - - Then("the handler returns 200 with a minted OBP DirectLogin token") - code shouldBe 200 - body should include("token") - - And("the resource user was provisioned from the validated claims") - Users.users.vend.getUserByProviderId(providerClaim, preferredUser).isDefined shouldBe true - } - }.unsafeRunSync() - } - } -}