From 6fb0af61c0efe712aaa2a09e10b9dd83d0a0e764 Mon Sep 17 00:00:00 2001 From: Nemo Godebski-Pedersen Date: Mon, 1 Sep 2025 17:53:32 +0530 Subject: [PATCH 1/3] merge and update --- obp-oidc-generated-config.txt | 10 +++++----- .../com/tesobe/oidc/auth/DatabaseAuthService.scala | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/obp-oidc-generated-config.txt b/obp-oidc-generated-config.txt index f0139fd..e097b34 100644 --- a/obp-oidc-generated-config.txt +++ b/obp-oidc-generated-config.txt @@ -1,5 +1,5 @@ # OBP-OIDC Generated Configuration -# Generated at: 2025-08-28T17:22:26.717936Z +# Generated at: 2025-08-29T13:47:28.569688Z # Copy the sections you need to your project configuration files # ============================================================================ @@ -11,7 +11,7 @@ openid_connect.scope=openid email profile # OBP-API OIDC Provider Settings openid_connect.endpoint=http://localhost:9000/obp-oidc/.well-known/openid_configuration oauth2.client_id=obp-api-client -oauth2.client_secret=8kcibFnrIzlSrm2Z-1beo3CdwNrFT9dISL_ANIYjkuw +oauth2.client_secret=iQZPiGjZ4ZgKP63sYJGl17lkIpWvihX4f5_iKrVXYfI oauth2.callback_url=http://localhost:8080/auth/openid-connect/callback # ============================================================================ @@ -19,7 +19,7 @@ oauth2.callback_url=http://localhost:8080/auth/openid-connect/callback # ============================================================================ # Add to your OBP-Portal .env file OBP_OAUTH_CLIENT_ID=obp-portal-client -OBP_OAUTH_CLIENT_SECRET=duUeGucp5u6J8W3vGQnfR_22hAYjyzHyS3w-EmPixXw +OBP_OAUTH_CLIENT_SECRET=2sev_vjY94fHeCstao2PcOh0K5tFFPs7kEOFQhmoME4 OBP_OAUTH_WELL_KNOWN_URL=http://localhost:9000/obp-oidc/.well-known/openid-configuration APP_CALLBACK_URL=http://localhost:5174/login/obp/callback VITE_API_URL=http://localhost:8080 @@ -31,7 +31,7 @@ VITE_CLIENT_ID=obp-portal-client # ============================================================================ # Add to your API-Explorer-II environment export REACT_APP_OAUTH_CLIENT_ID=obp-explorer-ii-client -export REACT_APP_OAUTH_CLIENT_SECRET=RH-45hufH5XK_lWQTxrzHXHUNvvb2wKX1WuIeWtlB0k +export REACT_APP_OAUTH_CLIENT_SECRET=EHtey9xcSBaU0SGUzkSS8orjXuM3a7FqD987FzTHxio export REACT_APP_OAUTH_AUTHORIZATION_URL=http://localhost:9000/obp-oidc/auth export REACT_APP_OAUTH_TOKEN_URL=http://localhost:9000/obp-oidc/token export REACT_APP_OAUTH_REDIRECT_URI=http://localhost:3001/callback @@ -41,7 +41,7 @@ export REACT_APP_OAUTH_REDIRECT_URI=http://localhost:3001/callback # ============================================================================ # Add to your Opey-II environment export VUE_APP_OAUTH_CLIENT_ID=obp-opey-ii-client -export VUE_APP_OAUTH_CLIENT_SECRET=WTl6ej-WF4xz8MmSv-7WNZNaV8Amks3Ie8gzu_OHP90 +export VUE_APP_OAUTH_CLIENT_SECRET=5rZntd0jUp_-3j--BHSGfj6HmzyILiYuZaaM7UGfUEU export VUE_APP_OAUTH_AUTHORIZATION_URL=http://localhost:9000/obp-oidc/auth export VUE_APP_OAUTH_TOKEN_URL=http://localhost:9000/obp-oidc/token export VUE_APP_OAUTH_REDIRECT_URI=http://localhost:3002/callback diff --git a/src/main/scala/com/tesobe/oidc/auth/DatabaseAuthService.scala b/src/main/scala/com/tesobe/oidc/auth/DatabaseAuthService.scala index d39b440..1f8fc0e 100644 --- a/src/main/scala/com/tesobe/oidc/auth/DatabaseAuthService.scala +++ b/src/main/scala/com/tesobe/oidc/auth/DatabaseAuthService.scala @@ -897,7 +897,7 @@ object AdminDatabaseClient { description = Some(s"OIDC client for ${client.client_name}"), developeremail = Some("admin@tesobe.com"), // Default email sub = Some(client.client_name), // Use client name as sub - consumerid = Some(client.client_id), + consumerid = Some(client.consumer_id), createdat = None, // Let database set this updatedat = None, // Let database set this secret = client.client_secret, From 9f638b219941969b88a44c8f168ba6df00a1db1c Mon Sep 17 00:00:00 2001 From: Nemo Godebski-Pedersen Date: Wed, 21 Jan 2026 11:35:06 +0000 Subject: [PATCH 2/3] merge branch 'main' of upstream into main --- .env.example | 136 ++++++ .../scala/com/tesobe/oidc/config/Config.scala | 7 +- .../tesobe/oidc/endpoints/AuthEndpoint.scala | 3 +- .../oidc/endpoints/DiscoveryEndpoint.scala | 28 ++ .../oidc/endpoints/RegistrationEndpoint.scala | 400 ++++++++++++++++++ .../com/tesobe/oidc/models/OidcModels.scala | 91 ++++ .../com/tesobe/oidc/server/OidcServer.scala | 47 ++ 7 files changed, 709 insertions(+), 3 deletions(-) create mode 100644 .env.example create mode 100644 src/main/scala/com/tesobe/oidc/endpoints/RegistrationEndpoint.scala diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..2585fb3 --- /dev/null +++ b/.env.example @@ -0,0 +1,136 @@ +# Copyright (c) 2025 TESOBE +# +# This file is part of OBP-OIDC. +# +# OBP-OIDC 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. +# +# OBP-OIDC 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 OBP-OIDC. If not, see . + +# ============================================================================ +# OBP-OIDC Environment Configuration Template +# ============================================================================ +# Copy this file to .env and edit with your actual values +# DO NOT commit .env to version control - it contains sensitive data! +# +# Usage: cp .env.example .env && edit .env +# ============================================================================ + +# ---------------------------------------------------------------------------- +# Server Configuration +# ---------------------------------------------------------------------------- +OIDC_HOST=localhost +OIDC_PORT=9000 +OIDC_KEY_ID=oidc-key-1 + +# External URL Configuration (for production with reverse proxy/load balancer) +# IMPORTANT: Set this when deploying behind a reverse proxy or using a custom domain +# This determines the issuer URL in the OIDC discovery document and JWT tokens +# +# Examples: +# OIDC_EXTERNAL_URL=https://test-oidc.openbankproject.com +# OIDC_EXTERNAL_URL=https://oidc.mycompany.com +# OIDC_EXTERNAL_URL=https://auth.example.com +# +# If not set, falls back to: http://OIDC_HOST:OIDC_PORT +# Note: Do NOT include trailing slash or /obp-oidc path (added automatically) +#OIDC_EXTERNAL_URL=https://test-oidc.openbankproject.com + +# Token expiration in seconds +# 3600 = 1 hour, 28800 = 8 hours, 300 = 5 minutes +OIDC_TOKEN_EXPIRATION=28800 + +# Authorization code expiration in seconds +OIDC_CODE_EXPIRATION=600 + +# ---------------------------------------------------------------------------- +# OBP-API Configuration +# ---------------------------------------------------------------------------- +OBP_API_HOST=localhost:8080 +OBP_API_URL=http://localhost:8080 + +# ---------------------------------------------------------------------------- +# Client Bootstrap Configuration +# ---------------------------------------------------------------------------- +# Set to 'true' to skip automatic client creation on startup +OIDC_SKIP_CLIENT_BOOTSTRAP=false + +# ---------------------------------------------------------------------------- +# Dynamic Client Registration +# ---------------------------------------------------------------------------- +# Set to 'true' to enable dynamic client registration endpoint +# WARNING: This can pose security risks if not properly managed! +# Needs to be enabled for OBP-OIDC to work with MCP servers. +ENABLE_DYNAMIC_CLIENT_REGISTRATION=false + +# ---------------------------------------------------------------------------- +# Database Configuration (Read-Only User) +# ---------------------------------------------------------------------------- +# IMPORTANT: Edit these values for your database setup +DB_HOST=localhost +DB_PORT=5432 +DB_NAME=sandbox +OIDC_USER_USERNAME=oidc_user +OIDC_USER_PASSWORD=CHANGE_THIS_PASSWORD +DB_MAX_CONNECTIONS=10 + +# ---------------------------------------------------------------------------- +# Admin Database Configuration (Write Access) +# ---------------------------------------------------------------------------- +# IMPORTANT: This user has write access to manage OIDC clients +OIDC_ADMIN_USERNAME=oidc_admin +OIDC_ADMIN_PASSWORD=CHANGE_THIS_ADMIN_PASSWORD +DB_ADMIN_MAX_CONNECTIONS=5 + +# ---------------------------------------------------------------------------- +# OBP Ecosystem Client Configuration +# ---------------------------------------------------------------------------- +# NOTE: OIDC clients are automatically created on first server startup! +# The server will generate secure client IDs and secrets automatically. +# +# Client configurations are hardcoded in the application and include: +# - obp-api-client (for OBP-API) +# - obp-portal-client (for Portal) +# - obp-explorer-ii-client (for Explorer II) +# - obp-opey-ii-client (for Opey II) +# - obp-api-manager-ii (for API Manager II) +# - obp-stripe (for Stripe integration) +# - other-app-1, other-app-2, other-app-3, other-app-4 (for custom apps) +# +# To customize redirect URLs, modify the ClientBootstrap.scala file. +# After first run, copy the generated client configurations from server output +# to your client applications (OBP-API Props file, Portal env, etc.) + +# ---------------------------------------------------------------------------- +# Development Mode +# ---------------------------------------------------------------------------- +LOCAL_DEVELOPMENT_MODE=true + +# ---------------------------------------------------------------------------- +# Logo Configuration (Optional) +# ---------------------------------------------------------------------------- +# The OBP logo displays by default. Uncomment and change to use a custom logo. +# To disable the logo entirely, set LOGO_URL to an empty string: LOGO_URL="" +#LOGO_URL=https://static.openbankproject.com/images/OBP/OBP_Horizontal_2025.png +#LOGO_ALT_TEXT=Open Bank Project + +# ---------------------------------------------------------------------------- +# Logging Configuration +# ---------------------------------------------------------------------------- +# Set to 'true' to enable TRACE level logging for detailed debugging +# Usage: OIDC_ENABLE_TRACE_LOGGING=true ./run-server.sh +OIDC_ENABLE_TRACE_LOGGING=false + +# ---------------------------------------------------------------------------- +# Cats Effect Configuration +# ---------------------------------------------------------------------------- +# Silence the threading warning +CATS_EFFECT_WARN_ON_NON_MAIN_THREAD_DETECTED=false diff --git a/src/main/scala/com/tesobe/oidc/config/Config.scala b/src/main/scala/com/tesobe/oidc/config/Config.scala index c6a5d87..9528e1b 100644 --- a/src/main/scala/com/tesobe/oidc/config/Config.scala +++ b/src/main/scala/com/tesobe/oidc/config/Config.scala @@ -49,7 +49,8 @@ case class OidcConfig( "https://static.openbankproject.com/images/OBP/OBP_Horizontal_2025.png" ), logoAltText: String = "Open Bank Project", - forgotPasswordUrl: Option[String] = None + forgotPasswordUrl: Option[String] = None, + enableDynamicClientRegistration: Boolean = false ) object Config { @@ -118,7 +119,9 @@ object Config { ) ), logoAltText = sys.env.getOrElse("LOGO_ALT_TEXT", "Open Bank Project"), - forgotPasswordUrl = sys.env.get("FORGOT_PASSWORD_URL") + forgotPasswordUrl = sys.env.get("FORGOT_PASSWORD_URL"), + enableDynamicClientRegistration = + sys.env.getOrElse("ENABLE_DYNAMIC_CLIENT_REGISTRATION", "false").toBoolean ) } } diff --git a/src/main/scala/com/tesobe/oidc/endpoints/AuthEndpoint.scala b/src/main/scala/com/tesobe/oidc/endpoints/AuthEndpoint.scala index 7fc6078..6d5d99c 100644 --- a/src/main/scala/com/tesobe/oidc/endpoints/AuthEndpoint.scala +++ b/src/main/scala/com/tesobe/oidc/endpoints/AuthEndpoint.scala @@ -622,8 +622,9 @@ class AuthEndpoint( code: String, state: Option[String] ): IO[Response[IO]] = { - val stateParam = state.map(s => s"&state=$s").getOrElse("") + val stateParam = state.map(s => s"&state=${java.net.URLEncoder.encode(s, "UTF-8")}").getOrElse("") // Code URL-encoding val location = s"$redirectUri?code=$code$stateParam" + IO(println(s"🔄 Redirecting with code to: $location")) *> SeeOther(Location(Uri.unsafeFromString(location))) } diff --git a/src/main/scala/com/tesobe/oidc/endpoints/DiscoveryEndpoint.scala b/src/main/scala/com/tesobe/oidc/endpoints/DiscoveryEndpoint.scala index fe8cf61..0fb8083 100644 --- a/src/main/scala/com/tesobe/oidc/endpoints/DiscoveryEndpoint.scala +++ b/src/main/scala/com/tesobe/oidc/endpoints/DiscoveryEndpoint.scala @@ -42,6 +42,8 @@ class DiscoveryEndpoint(config: OidcConfig) { token_endpoint = s"${config.issuer}/token", userinfo_endpoint = s"${config.issuer}/userinfo", jwks_uri = s"${config.issuer}/jwks", + revocation_endpoint = s"${config.issuer}/revoke", + registration_endpoint = if (config.enableDynamicClientRegistration) Some(s"${config.issuer}/connect/register") else None, response_types_supported = List("code"), subject_types_supported = List("public"), id_token_signing_alg_values_supported = List("RS256"), @@ -55,6 +57,32 @@ class DiscoveryEndpoint(config: OidcConfig) { Ok(configuration.asJson) } + + private def getConfigurationHead: IO[Response[IO]] = { + val configuration = OidcConfiguration( + issuer = config.issuer, + authorization_endpoint = s"${config.issuer}/auth", + token_endpoint = s"${config.issuer}/token", + userinfo_endpoint = s"${config.issuer}/userinfo", + jwks_uri = s"${config.issuer}/jwks", + revocation_endpoint = s"${config.issuer}/revoke", + registration_endpoint = if (config.enableDynamicClientRegistration) Some(s"${config.issuer}/connect/register") else None, + response_types_supported = List("code"), + subject_types_supported = List("public"), + id_token_signing_alg_values_supported = List("RS256"), + scopes_supported = List("openid", "profile", "email"), + token_endpoint_auth_methods_supported = + List("client_secret_post", "client_secret_basic", "none"), + claims_supported = List("sub", "name", "email", "email_verified"), + grant_types_supported = + List("authorization_code", "refresh_token", "client_credentials"), + revocation_endpoint_auth_methods_supported = + List("client_secret_post", "client_secret_basic") + ) + + // For HEAD requests, return OK with proper headers but no body + Ok(configuration.asJson).map(_.withBodyStream(fs2.Stream.empty)) + } } object DiscoveryEndpoint { diff --git a/src/main/scala/com/tesobe/oidc/endpoints/RegistrationEndpoint.scala b/src/main/scala/com/tesobe/oidc/endpoints/RegistrationEndpoint.scala new file mode 100644 index 0000000..aa9bf91 --- /dev/null +++ b/src/main/scala/com/tesobe/oidc/endpoints/RegistrationEndpoint.scala @@ -0,0 +1,400 @@ +/* + * Copyright (c) 2025 TESOBE + * + * This file is part of OBP-OIDC. + * + * OBP-OIDC 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. + * + * OBP-OIDC 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 OBP-OIDC. If not, see . + */ + +package com.tesobe.oidc.endpoints + +import cats.effect.IO +import cats.syntax.all._ +import com.tesobe.oidc.auth.DatabaseAuthService +import com.tesobe.oidc.config.OidcConfig +import com.tesobe.oidc.models._ +import com.tesobe.oidc.ratelimit.RateLimitService +import io.circe.syntax._ +import org.http4s._ +import org.http4s.circe._ +import org.http4s.circe.CirceEntityDecoder._ +import org.http4s.dsl.io._ +import org.http4s.headers.`Cache-Control` +import org.http4s.CacheDirective +import org.slf4j.LoggerFactory + +import java.security.SecureRandom +import java.util.{Base64, UUID} +import scala.util.Try + +/** Dynamic Client Registration Endpoint (RFC 7591) + * + * Allows OAuth 2.0 clients to register themselves programmatically. + * Endpoint: POST /obp-oidc/connect/register + */ +class RegistrationEndpoint( + authService: DatabaseAuthService, + rateLimitService: RateLimitService[IO], + config: OidcConfig +) { + + private val logger = LoggerFactory.getLogger(getClass) + private val secureRandom = new SecureRandom() + + val routes: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> Root / "obp-oidc" / "connect" / "register" => + logger.info("📝 Dynamic Client Registration request received") + handleRegistrationRequest(req) + } + + private def handleRegistrationRequest(req: Request[IO]): IO[Response[IO]] = { + // Extract IP for rate limiting + val clientIp = extractClientIp(req) + + // Check rate limit first + rateLimitService.isBlocked(clientIp, "registration").flatMap { blocked => + if (blocked) { + logger.warn(s"🚫 Rate limit exceeded for IP: $clientIp") + TooManyRequests( + ClientRegistrationError( + "invalid_request", + Some("Too many registration requests. Please try again later.") + ).asJson + ) + } else { + processRegistration(req, clientIp) + } + } + } + + private def processRegistration( + req: Request[IO], + clientIp: String + ): IO[Response[IO]] = { + req + .as[ClientRegistrationRequest] + .attempt + .flatMap { + case Right(registrationRequest) => + logger.info( + s"📋 Processing registration for client: ${registrationRequest.client_name}" + ) + validateAndRegister(registrationRequest) + + case Left(error) => + logger.error(s"💥 Failed to parse registration request: ${error.getMessage}") + BadRequest( + ClientRegistrationError( + ClientRegistrationError.INVALID_CLIENT_METADATA, + Some(s"Invalid JSON request body: ${error.getMessage}") + ).asJson + ).map(addNoCacheHeaders) + } + } + + private def validateAndRegister( + request: ClientRegistrationRequest + ): IO[Response[IO]] = { + // Validate the request + validateRequest(request) match { + case Left(error) => + logger.warn(s"❌ Validation failed: ${error.error_description.getOrElse(error.error)}") + BadRequest(error.asJson).map(addNoCacheHeaders) + + case Right(validatedRequest) => + // Generate credentials + val clientId = generateClientId() + val clientSecret = generateClientSecret() + val issuedAt = System.currentTimeMillis() / 1000 + + // Resolve defaults + val grantTypes = validatedRequest.grant_types.getOrElse( + ClientRegistrationRequest.DEFAULT_GRANT_TYPES + ) + val responseTypes = validatedRequest.response_types.getOrElse( + ClientRegistrationRequest.DEFAULT_RESPONSE_TYPES + ) + val authMethod = validatedRequest.token_endpoint_auth_method.getOrElse( + ClientRegistrationRequest.DEFAULT_AUTH_METHOD + ) + val scopes = validatedRequest.scope + .map(_.split("\\s+").toList) + .getOrElse(ClientRegistrationRequest.DEFAULT_SCOPES) + + // Create OidcClient for persistence + val oidcClient = OidcClient( + client_id = clientId, + client_secret = Some(clientSecret), + client_name = validatedRequest.client_name, + consumer_id = clientId, // Use client_id as consumer_id for DCR clients + redirect_uris = validatedRequest.redirect_uris, + grant_types = grantTypes, + response_types = responseTypes, + scopes = scopes, + token_endpoint_auth_method = authMethod, + created_at = Some(java.time.Instant.now().toString) + ) + + // Persist the client + authService.createClient(oidcClient).flatMap { + case Right(_) => + logger.info(s"✅ Successfully registered client: $clientId (${validatedRequest.client_name})") + + // Build response + val response = ClientRegistrationResponse( + client_id = clientId, + client_secret = Some(clientSecret), + client_id_issued_at = issuedAt, + client_secret_expires_at = 0, // Never expires + client_name = validatedRequest.client_name, + redirect_uris = validatedRequest.redirect_uris, + grant_types = grantTypes, + response_types = responseTypes, + scope = scopes.mkString(" "), + token_endpoint_auth_method = authMethod, + logo_uri = validatedRequest.logo_uri, + client_uri = validatedRequest.client_uri, + contacts = validatedRequest.contacts + ) + + Created(response.asJson).map(addNoCacheHeaders) + + case Left(error) => + logger.error(s"❌ Failed to persist client: ${error.error_description.getOrElse(error.error)}") + InternalServerError( + ClientRegistrationError( + "server_error", + Some(s"Failed to register client: ${error.error_description.getOrElse("Unknown error")}") + ).asJson + ).map(addNoCacheHeaders) + } + } + } + + /** Validate the registration request per RFC 7591 */ + private def validateRequest( + request: ClientRegistrationRequest + ): Either[ClientRegistrationError, ClientRegistrationRequest] = { + // Validate client_name is not empty + if (request.client_name.trim.isEmpty) { + return Left( + ClientRegistrationError( + ClientRegistrationError.INVALID_CLIENT_METADATA, + Some("client_name is required and cannot be empty") + ) + ) + } + + // Validate redirect_uris + if (request.redirect_uris.isEmpty) { + return Left( + ClientRegistrationError( + ClientRegistrationError.INVALID_REDIRECT_URI, + Some("At least one redirect_uri is required") + ) + ) + } + + // Validate each redirect_uri + for (uri <- request.redirect_uris) { + validateRedirectUri(uri) match { + case Left(error) => return Left(error) + case Right(_) => // Continue + } + } + + // Validate grant_types if provided + request.grant_types.foreach { grantTypes => + val unsupported = grantTypes.toSet -- ClientRegistrationRequest.SUPPORTED_GRANT_TYPES + if (unsupported.nonEmpty) { + return Left( + ClientRegistrationError( + ClientRegistrationError.INVALID_CLIENT_METADATA, + Some(s"Unsupported grant_types: ${unsupported.mkString(", ")}. Supported: ${ClientRegistrationRequest.SUPPORTED_GRANT_TYPES.mkString(", ")}") + ) + ) + } + } + + // Validate response_types if provided + request.response_types.foreach { responseTypes => + val unsupported = responseTypes.toSet -- ClientRegistrationRequest.SUPPORTED_RESPONSE_TYPES + if (unsupported.nonEmpty) { + return Left( + ClientRegistrationError( + ClientRegistrationError.INVALID_CLIENT_METADATA, + Some(s"Unsupported response_types: ${unsupported.mkString(", ")}. Supported: ${ClientRegistrationRequest.SUPPORTED_RESPONSE_TYPES.mkString(", ")}") + ) + ) + } + } + + // Validate token_endpoint_auth_method if provided + request.token_endpoint_auth_method.foreach { authMethod => + if (!ClientRegistrationRequest.SUPPORTED_AUTH_METHODS.contains(authMethod)) { + return Left( + ClientRegistrationError( + ClientRegistrationError.INVALID_CLIENT_METADATA, + Some(s"Unsupported token_endpoint_auth_method: $authMethod. Supported: ${ClientRegistrationRequest.SUPPORTED_AUTH_METHODS.mkString(", ")}") + ) + ) + } + } + + // Validate logo_uri if provided (must be valid URL) + request.logo_uri.foreach { logoUri => + if (!isValidHttpUrl(logoUri)) { + return Left( + ClientRegistrationError( + ClientRegistrationError.INVALID_CLIENT_METADATA, + Some(s"logo_uri must be a valid HTTP/HTTPS URL: $logoUri") + ) + ) + } + } + + // Validate client_uri if provided (must be valid URL) + request.client_uri.foreach { clientUri => + if (!isValidHttpUrl(clientUri)) { + return Left( + ClientRegistrationError( + ClientRegistrationError.INVALID_CLIENT_METADATA, + Some(s"client_uri must be a valid HTTP/HTTPS URL: $clientUri") + ) + ) + } + } + + Right(request) + } + + /** Validate a redirect URI per RFC 7591 / OAuth 2.1 security best practices */ + private def validateRedirectUri( + uri: String + ): Either[ClientRegistrationError, String] = { + Try(new java.net.URI(uri)).toEither match { + case Left(_) => + Left( + ClientRegistrationError( + ClientRegistrationError.INVALID_REDIRECT_URI, + Some(s"Invalid redirect_uri format: $uri") + ) + ) + + case Right(parsedUri) => + val scheme = Option(parsedUri.getScheme).map(_.toLowerCase) + + // Must have a scheme + if (scheme.isEmpty) { + return Left( + ClientRegistrationError( + ClientRegistrationError.INVALID_REDIRECT_URI, + Some(s"redirect_uri must have a scheme: $uri") + ) + ) + } + + // Allow http, https, and custom schemes (for native apps) + // But reject javascript: and data: schemes + val dangerousSchemes = Set("javascript", "data", "vbscript") + if (dangerousSchemes.contains(scheme.get)) { + return Left( + ClientRegistrationError( + ClientRegistrationError.INVALID_REDIRECT_URI, + Some(s"Dangerous redirect_uri scheme not allowed: ${scheme.get}") + ) + ) + } + + // For http(s) URIs, must be absolute (have a host) + if (scheme.contains("http") || scheme.contains("https")) { + if (Option(parsedUri.getHost).isEmpty) { + return Left( + ClientRegistrationError( + ClientRegistrationError.INVALID_REDIRECT_URI, + Some(s"HTTP(S) redirect_uri must have a host: $uri") + ) + ) + } + + // Warn about localhost in non-development mode (but still allow it) + val host = parsedUri.getHost.toLowerCase + if (!config.localDevelopmentMode && (host == "localhost" || host == "127.0.0.1")) { + logger.warn(s"⚠️ Localhost redirect_uri registered in non-development mode: $uri") + } + } + + // No fragment allowed in redirect_uri (OAuth 2.1 requirement) + if (Option(parsedUri.getFragment).isDefined) { + return Left( + ClientRegistrationError( + ClientRegistrationError.INVALID_REDIRECT_URI, + Some(s"redirect_uri must not contain a fragment: $uri") + ) + ) + } + + Right(uri) + } + } + + /** Check if a URL is a valid HTTP/HTTPS URL */ + private def isValidHttpUrl(url: String): Boolean = { + Try(new java.net.URI(url)).toOption.exists { uri => + val scheme = Option(uri.getScheme).map(_.toLowerCase) + (scheme.contains("http") || scheme.contains("https")) && Option(uri.getHost).isDefined + } + } + + /** Generate a secure client_id (UUID format) */ + private def generateClientId(): String = { + s"dcr-${UUID.randomUUID().toString}" + } + + /** Generate a secure client_secret (32 bytes, Base64 encoded) */ + private def generateClientSecret(): String = { + val bytes = new Array[Byte](32) + secureRandom.nextBytes(bytes) + Base64.getUrlEncoder.withoutPadding.encodeToString(bytes) + } + + /** Extract client IP from request headers */ + private def extractClientIp(req: Request[IO]): String = { + req.headers + .get(org.typelevel.ci.CIString("X-Forwarded-For")) + .map(_.head.value.split(",").head.trim) + .orElse( + req.headers + .get(org.typelevel.ci.CIString("X-Real-IP")) + .map(_.head.value) + ) + .getOrElse( + req.remoteAddr.map(_.toUriString).getOrElse("unknown") + ) + } + + /** Add Cache-Control: no-store header per RFC 7591 */ + private def addNoCacheHeaders(response: Response[IO]): Response[IO] = { + response.putHeaders(`Cache-Control`(CacheDirective.`no-store`)) + } +} + +object RegistrationEndpoint { + def apply( + authService: DatabaseAuthService, + rateLimitService: RateLimitService[IO], + config: OidcConfig + ): RegistrationEndpoint = + new RegistrationEndpoint(authService, rateLimitService, config) +} diff --git a/src/main/scala/com/tesobe/oidc/models/OidcModels.scala b/src/main/scala/com/tesobe/oidc/models/OidcModels.scala index ec10e36..6403b9d 100644 --- a/src/main/scala/com/tesobe/oidc/models/OidcModels.scala +++ b/src/main/scala/com/tesobe/oidc/models/OidcModels.scala @@ -29,6 +29,8 @@ case class OidcConfiguration( token_endpoint: String, userinfo_endpoint: String, jwks_uri: String, + revocation_endpoint: String, + registration_endpoint: Option[String] = None, // RFC 7591 Dynamic Client Registration response_types_supported: List[String], subject_types_supported: List[String], id_token_signing_alg_values_supported: List[String], @@ -260,3 +262,92 @@ object LoginForm { implicit val encoder: Encoder[LoginForm] = deriveEncoder implicit val decoder: Decoder[LoginForm] = deriveDecoder } + +// Token revocation request (RFC 7009) +case class RevocationRequest( + token: String, + token_type_hint: Option[String] = None // "access_token" or "refresh_token" +) + +object RevocationRequest { + implicit val encoder: Encoder[RevocationRequest] = deriveEncoder + implicit val decoder: Decoder[RevocationRequest] = deriveDecoder +} + +// Dynamic Client Registration (RFC 7591) +// Request for registering a new OAuth 2.0 client +case class ClientRegistrationRequest( + client_name: String, + redirect_uris: List[String], + grant_types: Option[List[String]] = None, + response_types: Option[List[String]] = None, + scope: Option[String] = None, + token_endpoint_auth_method: Option[String] = None, + logo_uri: Option[String] = None, + client_uri: Option[String] = None, + contacts: Option[List[String]] = None +) + +object ClientRegistrationRequest { + implicit val encoder: Encoder[ClientRegistrationRequest] = deriveEncoder + implicit val decoder: Decoder[ClientRegistrationRequest] = deriveDecoder + + // Supported values per RFC 7591 / OAuth 2.1 + val SUPPORTED_GRANT_TYPES: Set[String] = Set( + "authorization_code", + "refresh_token", + "client_credentials" + ) + + val SUPPORTED_RESPONSE_TYPES: Set[String] = Set("code") + + val SUPPORTED_AUTH_METHODS: Set[String] = Set( + "client_secret_post", + "client_secret_basic", + "none" + ) + + val DEFAULT_GRANT_TYPES: List[String] = List("authorization_code") + val DEFAULT_RESPONSE_TYPES: List[String] = List("code") + val DEFAULT_AUTH_METHOD: String = "client_secret_post" + val DEFAULT_SCOPES: List[String] = List("openid", "profile", "email") +} + +// Response after successfully registering a client (RFC 7591) +case class ClientRegistrationResponse( + client_id: String, + client_secret: Option[String], + client_id_issued_at: Long, + client_secret_expires_at: Long, // 0 means never expires + client_name: String, + redirect_uris: List[String], + grant_types: List[String], + response_types: List[String], + scope: String, + token_endpoint_auth_method: String, + logo_uri: Option[String] = None, + client_uri: Option[String] = None, + contacts: Option[List[String]] = None +) + +object ClientRegistrationResponse { + implicit val encoder: Encoder[ClientRegistrationResponse] = deriveEncoder + implicit val decoder: Decoder[ClientRegistrationResponse] = deriveDecoder +} + +// Error response for client registration (RFC 7591) +case class ClientRegistrationError( + error: String, + error_description: Option[String] = None +) + +object ClientRegistrationError { + implicit val encoder: Encoder[ClientRegistrationError] = deriveEncoder + implicit val decoder: Decoder[ClientRegistrationError] = deriveDecoder + + // Standard error codes per RFC 7591 + val INVALID_REDIRECT_URI = "invalid_redirect_uri" + val INVALID_CLIENT_METADATA = "invalid_client_metadata" + val INVALID_SOFTWARE_STATEMENT = "invalid_software_statement" + val UNAPPROVED_SOFTWARE_STATEMENT = "unapproved_software_statement" +} diff --git a/src/main/scala/com/tesobe/oidc/server/OidcServer.scala b/src/main/scala/com/tesobe/oidc/server/OidcServer.scala index 0a37063..643bc4e 100644 --- a/src/main/scala/com/tesobe/oidc/server/OidcServer.scala +++ b/src/main/scala/com/tesobe/oidc/server/OidcServer.scala @@ -174,6 +174,18 @@ object OidcServer extends IOApp { clientsEndpoint = ClientsEndpoint(authService) statsEndpoint = StatsEndpoint(statsService, config) staticFilesEndpoint = StaticFilesEndpoint() + registrationEndpoint = if (config.enableDynamicClientRegistration) { + Some(RegistrationEndpoint( + authService, + rateLimitService, + config + )) + } else None + + _ <- if (config.enableDynamicClientRegistration) + IO(println("Dynamic Client Registration endpoint initialized (enabled)")) + else + IO(println("Dynamic Client Registration endpoint disabled")) // Create all routes in a single HttpRoutes definition routes = { @@ -586,6 +598,41 @@ object OidcServer extends IOApp { case None => NotFound("JWKS endpoint not found") } + case HEAD -> Root / "obp-oidc" / "jwks" => + jwksEndpoint.routes + .run( + org.http4s.Request[IO]( + org.http4s.Method.HEAD, + org.http4s.Uri.unsafeFromString("/obp-oidc/jwks") + ) + ) + .value + .flatMap { + case Some(resp) => IO.pure(resp) + case None => NotFound("JWKS endpoint not found") + } + + // Revocation endpoint + case req @ POST -> Root / "obp-oidc" / "revoke" => + revocationEndpoint.routes.run(req).value.flatMap { + case Some(resp) => IO.pure(resp) + case None => NotFound("Revocation endpoint not found") + } + + // Dynamic Client Registration endpoint (RFC 7591) + case req @ POST -> Root / "obp-oidc" / "connect" / "register" => + registrationEndpoint match { + case Some(endpoint) => + IO(println("📝 Dynamic Client Registration request received")) *> + endpoint.routes.run(req).value.flatMap { + case Some(resp) => IO.pure(resp) + case None => NotFound("Registration endpoint not found") + } + case None => + IO(println("📝 Dynamic Client Registration request received but DCR is disabled")) *> + Forbidden("Dynamic Client Registration is disabled on this server") + } + // Delegate other requests to endpoints case req => statsService.incrementTotalRequests *> From 265d8e07f95c488df4a69dbe73dfe3d97d1f77ff Mon Sep 17 00:00:00 2001 From: Nemo Godebski-Pedersen Date: Mon, 1 Sep 2025 17:53:32 +0530 Subject: [PATCH 3/3] merge and update --- .../scala/com/tesobe/oidc/auth/DatabaseAuthService.scala | 8 -------- 1 file changed, 8 deletions(-) diff --git a/src/main/scala/com/tesobe/oidc/auth/DatabaseAuthService.scala b/src/main/scala/com/tesobe/oidc/auth/DatabaseAuthService.scala index e264f0b..b8892d8 100644 --- a/src/main/scala/com/tesobe/oidc/auth/DatabaseAuthService.scala +++ b/src/main/scala/com/tesobe/oidc/auth/DatabaseAuthService.scala @@ -1284,17 +1284,9 @@ object AdminDatabaseClient { description = Some(s"OIDC client for ${client.client_name}"), developeremail = Some("admin@tesobe.com"), // Default email sub = Some(client.client_name), // Use client name as sub -<<<<<<< HEAD consumerid = Some(client.consumer_id), createdat = None, // Let database set this updatedat = None, // Let database set this -======= - consumerid = Some( - client.consumer_id - ), // Use consumer_id for internal tracking (primary key) - createdat = Some(Instant.now()), // Set creation timestamp - updatedat = Some(Instant.now()), // Set update timestamp ->>>>>>> 1fbcbdd001dd3a27622cc4145c8fb2187c4743b5 secret = client.client_secret, azp = Some(client.client_id), aud = Some("obp-api"),