Skip to content

Commit 220ce7d

Browse files
committed
Added Token Revokation endpoint 2 included files
1 parent 2a4c048 commit 220ce7d

2 files changed

Lines changed: 477 additions & 0 deletions

File tree

Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
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+
* OBP-OIDC is distributed in the hope that it will be useful,
12+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
13+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14+
* GNU Affero General Public License for more details.
15+
*
16+
* You should have received a copy of the GNU Affero General Public License
17+
* along with OBP-OIDC. If not, see <http://www.gnu.org/licenses/>.
18+
*/
19+
20+
package com.tesobe.oidc.endpoints
21+
22+
import cats.effect.IO
23+
24+
import com.tesobe.oidc.auth.DatabaseAuthService
25+
import com.tesobe.oidc.config.OidcConfig
26+
import com.tesobe.oidc.revocation.TokenRevocationService
27+
import org.http4s._
28+
import org.http4s.dsl.io._
29+
import org.http4s.headers.Authorization
30+
import org.slf4j.LoggerFactory
31+
32+
import java.util.Base64
33+
34+
/** Token Revocation Endpoint (RFC 7009)
35+
*
36+
* This endpoint allows clients to notify the authorization server that a
37+
* previously obtained refresh or access token is no longer needed. This allows
38+
* the authorization server to clean up security credentials.
39+
*
40+
* Key features:
41+
* - Accepts both access tokens and refresh tokens
42+
* - Supports client authentication via Basic Auth or POST body
43+
* - Always returns 200 OK (even for invalid tokens, per RFC 7009)
44+
* - Optional token_type_hint parameter to optimize lookup
45+
*/
46+
class RevocationEndpoint(
47+
authService: DatabaseAuthService,
48+
revocationService: TokenRevocationService[IO],
49+
config: OidcConfig
50+
) {
51+
52+
private val logger = LoggerFactory.getLogger(getClass)
53+
54+
val routes: HttpRoutes[IO] = HttpRoutes.of[IO] {
55+
case req @ POST -> Root / "obp-oidc" / "revoke" =>
56+
handleRevocationRequest(req)
57+
}
58+
59+
/** Extract Basic Auth credentials from Authorization header
60+
*
61+
* @param req
62+
* HTTP request
63+
* @return
64+
* Option of (clientId, clientSecret)
65+
*/
66+
private def extractBasicAuthCredentials(
67+
req: Request[IO]
68+
): Option[(String, String)] = {
69+
req.headers.get[Authorization].flatMap { auth =>
70+
auth.credentials match {
71+
case org.http4s.Credentials.Token(scheme, token)
72+
if scheme == org.http4s.AuthScheme.Basic =>
73+
val encoded = token
74+
try {
75+
val decoded = new String(
76+
Base64.getDecoder.decode(encoded),
77+
"UTF-8"
78+
)
79+
decoded.split(":", 2) match {
80+
case Array(clientId, clientSecret) =>
81+
Some((clientId, clientSecret))
82+
case _ => None
83+
}
84+
} catch {
85+
case _: Exception => None
86+
}
87+
case _ => None
88+
}
89+
}
90+
}
91+
92+
/** Handle token revocation request according to RFC 7009
93+
*
94+
* RFC 7009 Section 2.2 states: "The authorization server responds with HTTP
95+
* status code 200 if the token has been revoked successfully or if the
96+
* client submitted an invalid token."
97+
*
98+
* This means we always return 200 OK, even if:
99+
* - The token is invalid
100+
* - The token doesn't exist
101+
* - The client is not authorized
102+
*
103+
* This is intentional to prevent token scanning attacks.
104+
*/
105+
private def handleRevocationRequest(req: Request[IO]): IO[Response[IO]] = {
106+
val result = for {
107+
// Parse form data
108+
formData <- req.as[UrlForm]
109+
110+
// Extract token (required)
111+
token = formData.getFirst("token")
112+
113+
// Extract token_type_hint (optional)
114+
tokenTypeHint = formData.getFirst("token_type_hint")
115+
116+
// Extract client credentials from Basic Auth header
117+
basicCredentialsOpt = extractBasicAuthCredentials(req)
118+
clientIdFromBasic = basicCredentialsOpt.map(_._1)
119+
clientSecretFromBasic = basicCredentialsOpt.map(_._2)
120+
121+
// Extract client credentials from form body (fallback)
122+
clientIdFromForm = formData.getFirst("client_id")
123+
clientSecretFromForm = formData.getFirst("client_secret")
124+
125+
// Resolve which credentials to use (Basic Auth takes precedence)
126+
resolvedClientId = clientIdFromBasic.orElse(clientIdFromForm)
127+
resolvedClientSecret = clientSecretFromBasic.orElse(clientSecretFromForm)
128+
129+
_ <- IO(
130+
logger.info(
131+
s"Revocation request received - client: ${resolvedClientId
132+
.getOrElse("unknown")}, token_type_hint: ${tokenTypeHint.getOrElse("none")}"
133+
)
134+
)
135+
136+
// Validate that token parameter is present
137+
tokenValue <- token match {
138+
case Some(t) if t.nonEmpty => IO.pure(t)
139+
case _ =>
140+
// RFC 7009: Missing token parameter returns 400 Bad Request (only error case)
141+
IO(logger.warn("Revocation request missing 'token' parameter"))
142+
IO.raiseError(
143+
new IllegalArgumentException("Missing required parameter: token")
144+
)
145+
}
146+
147+
// Validate client credentials if provided
148+
// Note: RFC 7009 allows public clients (no authentication), but we require it
149+
clientValidated <- (resolvedClientId, resolvedClientSecret) match {
150+
case (Some(clientId), Some(clientSecret)) =>
151+
authService
152+
.authenticateClient(clientId, clientSecret)
153+
.flatMap {
154+
case Right(_) =>
155+
IO(logger.info(s"Client authenticated: $clientId"))
156+
IO.pure(true)
157+
case Left(oidcError) =>
158+
IO(
159+
logger.warn(
160+
s"Client authentication failed for revocation: ${oidcError.error}"
161+
)
162+
)
163+
// Per RFC 7009, we return 200 OK even for auth failures (to prevent token scanning)
164+
IO.pure(false)
165+
}
166+
case (Some(clientId), None) =>
167+
// Client ID provided but no secret - could be public client
168+
IO(
169+
logger.warn(
170+
s"Revocation request with client_id but no client_secret: $clientId"
171+
)
172+
)
173+
IO.pure(false)
174+
case _ =>
175+
// No client credentials provided
176+
IO(logger.warn("Revocation request with no client credentials"))
177+
IO.pure(false)
178+
}
179+
180+
// RFC 7009: Always revoke the token, even if client auth fails
181+
// This is debatable, but the spec says to return 200 OK regardless
182+
// We'll only revoke if client is authenticated (more secure)
183+
_ <-
184+
if (clientValidated) {
185+
revocationService
186+
.revokeToken(tokenValue, tokenTypeHint)
187+
.flatMap { _ =>
188+
IO(
189+
logger.info(
190+
s"Token revoked successfully: ${tokenValue
191+
.take(8)}... (hint: ${tokenTypeHint.getOrElse("none")})"
192+
)
193+
)
194+
}
195+
} else {
196+
IO(
197+
logger.warn(
198+
"Token revocation skipped due to client authentication failure"
199+
)
200+
)
201+
}
202+
203+
// Always return 200 OK per RFC 7009 Section 2.2
204+
response <- Ok("")
205+
206+
} yield response
207+
208+
// Handle errors
209+
result.handleErrorWith { error =>
210+
error match {
211+
case _: IllegalArgumentException =>
212+
// Missing token parameter - only case where we return 400
213+
BadRequest("invalid_request")
214+
case _ =>
215+
// Any other error - return 200 OK per RFC 7009
216+
logger.error(
217+
s"Error processing revocation request: ${error.getMessage}",
218+
error
219+
)
220+
Ok("")
221+
}
222+
}
223+
}
224+
}
225+
226+
object RevocationEndpoint {
227+
def apply(
228+
authService: DatabaseAuthService,
229+
revocationService: TokenRevocationService[IO],
230+
config: OidcConfig
231+
): RevocationEndpoint = new RevocationEndpoint(
232+
authService,
233+
revocationService,
234+
config
235+
)
236+
}

0 commit comments

Comments
 (0)