diff --git a/.env.example b/.env.example index d371e65..2585fb3 100644 --- a/.env.example +++ b/.env.example @@ -63,6 +63,14 @@ OBP_API_URL=http://localhost:8080 # 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) # ---------------------------------------------------------------------------- diff --git a/obp-oidc-generated-config.txt b/obp-oidc-generated-config.txt new file mode 100644 index 0000000..e097b34 --- /dev/null +++ b/obp-oidc-generated-config.txt @@ -0,0 +1,53 @@ +# OBP-OIDC Generated Configuration +# Generated at: 2025-08-29T13:47:28.569688Z +# Copy the sections you need to your project configuration files + +# ============================================================================ +# 1. OBP-API Configuration (Props file) +# ============================================================================ +# Add to your OBP-API props file +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=iQZPiGjZ4ZgKP63sYJGl17lkIpWvihX4f5_iKrVXYfI +oauth2.callback_url=http://localhost:8080/auth/openid-connect/callback + +# ============================================================================ +# 2. OBP-Portal Configuration (.env file) +# ============================================================================ +# Add to your OBP-Portal .env file +OBP_OAUTH_CLIENT_ID=obp-portal-client +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 +VITE_OIDC_ISSUER=http://localhost:9000/obp-oidc +VITE_CLIENT_ID=obp-portal-client + +# ============================================================================ +# 3. API-Explorer-II Configuration (environment variables) +# ============================================================================ +# Add to your API-Explorer-II environment +export REACT_APP_OAUTH_CLIENT_ID=obp-explorer-ii-client +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 + +# ============================================================================ +# 4. Opey-II Configuration (environment variables) +# ============================================================================ +# Add to your Opey-II environment +export VUE_APP_OAUTH_CLIENT_ID=obp-opey-ii-client +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 + +# ============================================================================ +# Database Client Information +# ============================================================================ +# Client IDs and secrets are also stored in your v_oidc_admin_clients table +# Use these for reference or manual configuration diff --git a/src/main/scala/com/tesobe/oidc/auth/DatabaseAuthService.scala b/src/main/scala/com/tesobe/oidc/auth/DatabaseAuthService.scala index 7de78c8..b8892d8 100644 --- a/src/main/scala/com/tesobe/oidc/auth/DatabaseAuthService.scala +++ b/src/main/scala/com/tesobe/oidc/auth/DatabaseAuthService.scala @@ -1284,11 +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 - 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 + consumerid = Some(client.consumer_id), + createdat = None, // Let database set this + updatedat = None, // Let database set this secret = client.client_secret, azp = Some(client.client_id), aud = Some("obp-api"), 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 a1238f8..a277a5d 100644 --- a/src/main/scala/com/tesobe/oidc/endpoints/DiscoveryEndpoint.scala +++ b/src/main/scala/com/tesobe/oidc/endpoints/DiscoveryEndpoint.scala @@ -45,6 +45,7 @@ class DiscoveryEndpoint(config: OidcConfig) { 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"), @@ -69,6 +70,7 @@ class DiscoveryEndpoint(config: OidcConfig) { 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"), 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 3550d59..e84c07c 100644 --- a/src/main/scala/com/tesobe/oidc/models/OidcModels.scala +++ b/src/main/scala/com/tesobe/oidc/models/OidcModels.scala @@ -30,6 +30,7 @@ case class OidcConfiguration( 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], @@ -273,3 +274,81 @@ 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 679eedd..0eaea69 100644 --- a/src/main/scala/com/tesobe/oidc/server/OidcServer.scala +++ b/src/main/scala/com/tesobe/oidc/server/OidcServer.scala @@ -186,6 +186,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 = { @@ -640,6 +652,20 @@ object OidcServer extends IOApp { 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 *>