Skip to content

Commit 9894c7c

Browse files
committed
Status endpoint
1 parent 408275e commit 9894c7c

4 files changed

Lines changed: 545 additions & 3 deletions

File tree

src/main/scala/com/tesobe/oidc/auth/ObpApiCredentialsService.scala

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ class ObpApiCredentialsService(
103103

104104
/** Obtain a DirectLogin token using username/password
105105
*/
106-
private[auth] def obtainDirectLoginToken(): IO[Either[OidcError, String]] = {
106+
private[oidc] def obtainDirectLoginToken(): IO[Either[OidcError, String]] = {
107107
(config.obpApiUrl, config.obpApiUsername, config.obpApiPassword, config.obpApiConsumerKey) match {
108108
case (Some(baseUrl), Some(username), Some(password), Some(consumerKey)) =>
109109
val endpoint = s"${baseUrl.stripSuffix("/")}/obp/v6.0.0/my/logins/direct"
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
/*
2+
* Copyright (c) 2025 TESOBE
3+
*
4+
* This file is part of OBP-OIDC.
5+
*
6+
* OBP-OIDC is free software: you can redistribute it and/or modify
7+
* it under the terms of the GNU Affero General Public License as published by
8+
* the Free Software Foundation, either version 3 of the License, or
9+
* (at your option) any later version.
10+
*/
11+
12+
package com.tesobe.oidc.endpoints
13+
14+
import cats.effect.IO
15+
import com.tesobe.oidc.endpoints.HtmlUtils.htmlEncode
16+
import com.tesobe.oidc.status.{StatusCheck, StatusReport, StatusService}
17+
import org.http4s._
18+
import org.http4s.circe._
19+
import org.http4s.dsl.io._
20+
import org.http4s.headers.`Content-Type`
21+
22+
class StatusEndpoint(statusService: StatusService) {
23+
24+
val routes: HttpRoutes[IO] = HttpRoutes.of[IO] {
25+
case GET -> Root / "status" =>
26+
statusService.getReport.flatMap { report =>
27+
Ok(renderHtml(report))
28+
.map(_.withContentType(`Content-Type`(MediaType.text.html)))
29+
}
30+
31+
case GET -> Root / "status.json" =>
32+
statusService.getReport.flatMap { report =>
33+
Ok(StatusReport.toJson(report))
34+
}
35+
}
36+
37+
private def renderRow(c: StatusCheck): String = {
38+
val label = if (c.ok) "OK" else "FAIL"
39+
val cls = if (c.ok) "ok" else "fail"
40+
s"""<tr class="$cls">
41+
| <td class="name">${htmlEncode(c.name)}</td>
42+
| <td class="badge"><span class="pill pill-$cls">$label</span></td>
43+
|</tr>""".stripMargin
44+
}
45+
46+
private def renderHtml(report: StatusReport): String = {
47+
val overallLabel = if (report.overallOk) "All systems operational" else "Degraded service"
48+
val overallCls = if (report.overallOk) "ok" else "fail"
49+
val rows = report.checks.map(renderRow).mkString("\n")
50+
val generated = htmlEncode(report.generatedAt.toString)
51+
52+
s"""<!DOCTYPE html>
53+
|<html>
54+
|<head>
55+
| <title>Status - OBP OIDC Provider</title>
56+
| <meta name="viewport" content="width=device-width, initial-scale=1.0">
57+
| <link rel="stylesheet" href="/static/css/main.css">
58+
| <style>
59+
| .status-wrap { max-width: 720px; margin: 40px auto; padding: 30px; }
60+
| .overall {
61+
| display: inline-block;
62+
| padding: 12px 20px;
63+
| border-radius: 8px;
64+
| font-weight: 600;
65+
| margin: 20px 0 30px 0;
66+
| }
67+
| .overall.ok { background: #d1fae5; color: #065f46; border: 2px solid #10b981; }
68+
| .overall.fail { background: #fee2e2; color: #991b1b; border: 2px solid #ef4444; }
69+
| table.status {
70+
| width: 100%;
71+
| border-collapse: collapse;
72+
| margin-top: 10px;
73+
| }
74+
| table.status td {
75+
| padding: 12px 16px;
76+
| border-bottom: 1px solid #e9ecef;
77+
| }
78+
| table.status tr:last-child td { border-bottom: none; }
79+
| .name { font-weight: 500; color: #2c3e50; }
80+
| .badge { text-align: right; width: 80px; }
81+
| .pill {
82+
| display: inline-block;
83+
| padding: 4px 10px;
84+
| border-radius: 999px;
85+
| font-size: 0.85rem;
86+
| font-weight: 600;
87+
| }
88+
| .pill-ok { background: #d1fae5; color: #065f46; }
89+
| .pill-fail { background: #fee2e2; color: #991b1b; }
90+
| .meta { color: #6b7280; font-size: 0.9rem; margin-top: 20px; }
91+
| </style>
92+
|</head>
93+
|<body>
94+
| <div class="container status-wrap">
95+
| <h1>Service Status</h1>
96+
| <p class="subtitle">OBP OIDC Provider</p>
97+
| <div class="overall $overallCls" data-testid="status-overall">$overallLabel</div>
98+
| <table class="status" data-testid="status-table">
99+
| <tbody>
100+
|$rows
101+
| </tbody>
102+
| </table>
103+
| <p class="meta">Generated at $generated. Results cached briefly. See also <a href="/status.json">/status.json</a>.</p>
104+
| <p class="meta"><a href="/">Home</a></p>
105+
| </div>
106+
|</body>
107+
|</html>""".stripMargin
108+
}
109+
}
110+
111+
object StatusEndpoint {
112+
def apply(statusService: StatusService): StatusEndpoint =
113+
new StatusEndpoint(statusService)
114+
}

src/main/scala/com/tesobe/oidc/server/OidcServer.scala

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import com.tesobe.oidc.config.{Config, OidcConfig, VerifyCredentialsMethod, Veri
3232
import com.tesobe.oidc.endpoints._
3333
import com.tesobe.oidc.tokens.JwtService
3434
import com.tesobe.oidc.stats.StatsService
35+
import com.tesobe.oidc.status.StatusService
3536
import com.tesobe.oidc.ratelimit.{RateLimitConfig, InMemoryRateLimitService}
3637
import com.tesobe.oidc.revocation.InMemoryTokenRevocationService
3738
import org.http4s._
@@ -169,7 +170,13 @@ object OidcServer extends IOApp {
169170
case _ => List.empty
170171
}) ++
171172
(config.verifyClientMethod match {
172-
case VerifyClientMethod.ViaApiEndpoint => List("CanGetOidcClient", "CanGetConsumers")
173+
case VerifyClientMethod.ViaApiEndpoint =>
174+
val base = List("CanGetOidcClient", "CanVerifyOidcClient", "CanGetConsumers")
175+
val createConsumer =
176+
if (config.enableDynamicClientRegistration || !config.skipClientBootstrap)
177+
List("CanCreateConsumer")
178+
else Nil
179+
base ++ createConsumer
173180
case _ => List.empty
174181
})
175182

@@ -254,6 +261,10 @@ object OidcServer extends IOApp {
254261
codeService <- CodeService(config)
255262
jwtService <- JwtService(config)
256263
statsService <- StatsService()
264+
statusService <- StatusService
265+
.create(config, jwtService)
266+
.allocated
267+
.map(_._1)
257268
rateLimitConfig = RateLimitConfig.fromEnv
258269
rateLimitService <- InMemoryRateLimitService(rateLimitConfig)
259270
revocationService <- InMemoryTokenRevocationService(
@@ -297,6 +308,7 @@ object OidcServer extends IOApp {
297308
)
298309
clientsEndpoint = ClientsEndpoint(authService)
299310
statsEndpoint = StatsEndpoint(statsService, config)
311+
statusEndpoint = StatusEndpoint(statusService)
300312
staticFilesEndpoint = StaticFilesEndpoint()
301313
registrationEndpoint = if (config.enableDynamicClientRegistration) {
302314
Some(RegistrationEndpoint(
@@ -388,6 +400,19 @@ object OidcServer extends IOApp {
388400
)
389401
)
390402

403+
// Public status page - always available
404+
case req @ GET -> Root / "status" =>
405+
statusEndpoint.routes.run(req).value.flatMap {
406+
case Some(resp) => IO.pure(resp)
407+
case None => NotFound("Status endpoint not found")
408+
}
409+
410+
case req @ GET -> Root / "status.json" =>
411+
statusEndpoint.routes.run(req).value.flatMap {
412+
case Some(resp) => IO.pure(resp)
413+
case None => NotFound("Status endpoint not found")
414+
}
415+
391416
// Root page - simple landing with links - always available
392417
case GET -> Root =>
393418
val modeStatus =
@@ -458,6 +483,7 @@ object OidcServer extends IOApp {
458483
| <div class="links">
459484
| <a href="/info">Server Info</a>
460485
| <a href="/health">Health Check</a>
486+
| <a href="/status">Status</a>
461487
| </div>
462488
| <div class="version">
463489
| <strong>Version:</strong> v${readVersion()} (${readGitCommit()})
@@ -728,6 +754,7 @@ object OidcServer extends IOApp {
728754
|<li><a href="/clients">OIDC Clients</a> - View registered clients</li>
729755
|<li><a href="/stats">Statistics</a> - Real-time usage statistics</li>
730756
|<li><a href="/health">Health Check</a> - Service status</li>
757+
|<li><a href="/status">Status</a> - Dependency health checks (OBP API, endpoints, databases)</li>
731758
|</ul>
732759
|<h2>Supported Grant Types</h2>
733760
|<ul>
@@ -956,6 +983,8 @@ object OidcServer extends IOApp {
956983
IO(println(s" JWKS: $baseUriString/obp-oidc/jwks")) *>
957984
IO(println(s" Clients: $baseUriString/obp-oidc/clients")) *>
958985
IO(println(s" Health Check: $baseUriString/health")) *>
986+
IO(println(s" Status (HTML): $baseUriString/status")) *>
987+
IO(println(s" Status (JSON): $baseUriString/status.json")) *>
959988
config.obpApiUrl
960989
.fold(IO.unit)(url => IO(println(s" OBP-API: $url"))) *>
961990
printOBPConfiguration(baseUriString, authService, config) *>
@@ -969,12 +998,18 @@ object OidcServer extends IOApp {
969998
IO(println(s"USE_VERIFY_ENDPOINTS: ${config.useVerifyEndpoints}")) *>
970999
(if (config.useVerifyEndpoints) {
9711000
val username = config.obpApiUsername.getOrElse("unknown")
972-
val roleEndpoints = List(
1001+
val baseRoleEndpoints = List(
9731002
("CanVerifyUserCredentials", "POST /obp/v6.0.0/users/verify-credentials"),
9741003
("CanGetAnyUser", "GET /obp/v6.0.0/users/provider/PROVIDER/username/USERNAME"),
9751004
("CanGetOidcClient", "GET /obp/v6.0.0/oidc/clients/CLIENT_ID"),
1005+
("CanVerifyOidcClient", "POST /obp/v6.0.0/oidc/clients/verify"),
9761006
("CanGetConsumers", "GET /obp/v6.0.0/management/consumers")
9771007
)
1008+
val roleEndpoints =
1009+
if (config.enableDynamicClientRegistration || !config.skipClientBootstrap)
1010+
baseRoleEndpoints :+
1011+
("CanCreateConsumer", "POST /obp/v5.1.0/management/consumers")
1012+
else baseRoleEndpoints
9781013
IO(println(" All verification methods use OBP API endpoints")) *>
9791014
IO(println(s" OBP API Username: $username")) *>
9801015
IO(println(s" Required roles for OBP_API_USERNAME '$username':")) *>

0 commit comments

Comments
 (0)