Skip to content

Commit 82b3e66

Browse files
committed
OBP-OIDC must support the code id_token hybrid response type. This means
the authorization endpoint should return both an authorization code and a signed ID token (containing c_hash, s_hash, and at_hash claims) directly in the redirect, as required by UK Open Banking and Berlin Group security profiles.
1 parent d6036e6 commit 82b3e66

5 files changed

Lines changed: 113 additions & 16 deletions

File tree

src/main/scala/com/tesobe/oidc/endpoints/AuthEndpoint.scala

Lines changed: 46 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import com.tesobe.oidc.endpoints.HtmlUtils.htmlEncode
2525
import com.tesobe.oidc.models.{OidcError, User}
2626
import com.tesobe.oidc.ratelimit.RateLimitService
2727
import com.tesobe.oidc.config.OidcConfig
28+
import com.tesobe.oidc.tokens.JwtService
2829
import org.http4s._
2930
import org.http4s.dsl.io._
3031
import org.http4s.headers.Location
@@ -36,7 +37,8 @@ class AuthEndpoint(
3637
codeService: CodeService[IO],
3738
statsService: StatsService[IO],
3839
rateLimitService: RateLimitService[IO],
39-
config: OidcConfig
40+
config: OidcConfig,
41+
jwtService: JwtService[IO]
4042
) {
4143

4244
private val logger = LoggerFactory.getLogger(getClass)
@@ -109,12 +111,12 @@ class AuthEndpoint(
109111
)
110112
) *>
111113
// Validate request parameters
112-
(if (responseType != "code") {
114+
(if (responseType != "code" && responseType != "code id_token") {
113115
IO(logger.warn(s"Unsupported response_type: $responseType")) *>
114116
IO(println(s"Unsupported response_type: $responseType")) *> {
115117
val error = OidcError(
116118
"unsupported_response_type",
117-
Some("Only 'code' response type is supported"),
119+
Some("Supported response types: 'code', 'code id_token'"),
118120
state = state
119121
)
120122
redirectWithError(redirectUri, error)
@@ -163,7 +165,7 @@ class AuthEndpoint(
163165
logger.info(s"Client validated, showing login form...")
164166
) *>
165167
IO(println(s"Client validated, showing login form...")) *>
166-
showLoginForm(clientId, redirectUri, scope, state, nonce)
168+
showLoginForm(clientId, redirectUri, scope, state, nonce, responseType = responseType)
167169
}
168170
}
169171
})
@@ -268,6 +270,7 @@ class AuthEndpoint(
268270
)
269271
state = formData.get("state")
270272
nonce = formData.get("nonce")
273+
responseType = formData.get("response_type").getOrElse("code")
271274

272275
_ <- IO(
273276
logger.info(
@@ -295,7 +298,8 @@ class AuthEndpoint(
295298
redirectUri,
296299
scope,
297300
state,
298-
nonce
301+
nonce,
302+
responseType
299303
)
300304
case Left(error) =>
301305
// Authentication failed - record failed attempt for rate limiting
@@ -316,7 +320,8 @@ class AuthEndpoint(
316320
scope,
317321
state,
318322
nonce,
319-
Some("Incorrect username/password")
323+
Some("Incorrect username/password"),
324+
responseType
320325
)
321326
}
322327
} yield response
@@ -334,13 +339,22 @@ class AuthEndpoint(
334339
redirectUri: String,
335340
scope: String,
336341
state: Option[String],
337-
nonce: Option[String]
342+
nonce: Option[String],
343+
responseType: String = "code"
338344
): IO[Response[IO]] = {
339345
for {
340346
_ <- statsService.incrementLoginSuccess(user.username)
341347
code <- codeService
342348
.generateCode(clientId, redirectUri, user.sub, scope, state, nonce, user.provider)
343-
response <- redirectWithCode(redirectUri, code, state)
349+
response <- responseType match {
350+
case "code id_token" =>
351+
for {
352+
idToken <- jwtService.generateHybridIdToken(user, clientId, code, state, nonce)
353+
resp <- redirectWithCodeAndIdToken(redirectUri, code, idToken, state)
354+
} yield resp
355+
case _ =>
356+
redirectWithCode(redirectUri, code, state)
357+
}
344358
} yield response
345359
}
346360

@@ -350,7 +364,8 @@ class AuthEndpoint(
350364
scope: String,
351365
state: Option[String],
352366
nonce: Option[String],
353-
errorMessage: Option[String] = None
367+
errorMessage: Option[String] = None,
368+
responseType: String = "code"
354369
): IO[Response[IO]] = {
355370

356371
IO(logger.info(s"showLoginForm called for clientId: $clientId")) *>
@@ -484,6 +499,7 @@ class AuthEndpoint(
484499
<input type="hidden" name="client_id" value="${htmlEncode(clientId)}">
485500
<input type="hidden" name="redirect_uri" value="${htmlEncode(redirectUri)}">
486501
<input type="hidden" name="scope" value="${htmlEncode(scope)}">
502+
<input type="hidden" name="response_type" value="${htmlEncode(responseType)}">
487503
$stateParam
488504
$nonceParam
489505

@@ -614,6 +630,23 @@ class AuthEndpoint(
614630
SeeOther(Location(Uri.unsafeFromString(location)))
615631
}
616632

633+
/** Redirect with both code and id_token in the fragment (hybrid flow).
634+
* Per OIDC Core 3.3.2.5, when response_type includes a token or id_token,
635+
* parameters MUST be returned in the URI fragment.
636+
*/
637+
private def redirectWithCodeAndIdToken(
638+
redirectUri: String,
639+
code: String,
640+
idToken: String,
641+
state: Option[String]
642+
): IO[Response[IO]] = {
643+
val stateParam = state.map(s => s"&state=${java.net.URLEncoder.encode(s, "UTF-8")}").getOrElse("")
644+
val location = s"$redirectUri#code=$code&id_token=$idToken$stateParam"
645+
IO(logger.info(s"Redirecting with code and id_token (hybrid flow) to: ${redirectUri}#code=...&id_token=...")) *>
646+
IO(println(s"Redirecting with code and id_token (hybrid flow)")) *>
647+
SeeOther(Location(Uri.unsafeFromString(location)))
648+
}
649+
617650
private def redirectWithError(
618651
redirectUri: String,
619652
error: OidcError
@@ -634,13 +667,15 @@ object AuthEndpoint {
634667
codeService: CodeService[IO],
635668
statsService: StatsService[IO],
636669
rateLimitService: RateLimitService[IO],
637-
config: OidcConfig
670+
config: OidcConfig,
671+
jwtService: JwtService[IO]
638672
): AuthEndpoint =
639673
new AuthEndpoint(
640674
authService,
641675
codeService,
642676
statsService,
643677
rateLimitService,
644-
config
678+
config,
679+
jwtService
645680
)
646681
}

src/main/scala/com/tesobe/oidc/endpoints/DiscoveryEndpoint.scala

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ class DiscoveryEndpoint(config: OidcConfig) {
4646
jwks_uri = s"${config.issuer}/jwks",
4747
revocation_endpoint = s"${config.issuer}/revoke",
4848
registration_endpoint = if (config.enableDynamicClientRegistration) Some(s"${config.issuer}/connect/register") else None,
49-
response_types_supported = List("code"),
49+
response_types_supported = List("code", "code id_token"),
5050
subject_types_supported = List("public"),
5151
id_token_signing_alg_values_supported = List("RS256"),
5252
scopes_supported = List("openid", "profile", "email"),
@@ -71,7 +71,7 @@ class DiscoveryEndpoint(config: OidcConfig) {
7171
jwks_uri = s"${config.issuer}/jwks",
7272
revocation_endpoint = s"${config.issuer}/revoke",
7373
registration_endpoint = if (config.enableDynamicClientRegistration) Some(s"${config.issuer}/connect/register") else None,
74-
response_types_supported = List("code"),
74+
response_types_supported = List("code", "code id_token"),
7575
subject_types_supported = List("public"),
7676
id_token_signing_alg_values_supported = List("RS256"),
7777
scopes_supported = List("openid", "profile", "email"),

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -276,7 +276,8 @@ object OidcServer extends IOApp {
276276
codeService,
277277
statsService,
278278
rateLimitService,
279-
config
279+
config,
280+
jwtService
280281
)
281282
tokenEndpoint = TokenEndpoint(
282283
authService,

src/main/scala/com/tesobe/oidc/tokens/JwtService.scala

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ import com.tesobe.oidc.models.{
3737
import com.tesobe.oidc.config.OidcConfig
3838

3939
import java.security.interfaces.{RSAPrivateKey, RSAPublicKey}
40-
import java.security.{KeyPair, KeyPairGenerator, SecureRandom}
40+
import java.security.{KeyPair, KeyPairGenerator, MessageDigest, SecureRandom}
4141
import java.time.Instant
4242
import java.util.{Base64, Date}
4343
import scala.util.{Failure, Success, Try}
@@ -49,6 +49,13 @@ trait JwtService[F[_]] {
4949
clientId: String,
5050
nonce: Option[String] = None
5151
): F[String]
52+
def generateHybridIdToken(
53+
user: User,
54+
clientId: String,
55+
code: String,
56+
state: Option[String] = None,
57+
nonce: Option[String] = None
58+
): F[String]
5259
def generateAccessToken(
5360
user: User,
5461
clientId: String,
@@ -140,6 +147,59 @@ class JwtServiceImpl(config: OidcConfig, keyPairRef: Ref[IO, KeyPair])
140147
} yield signedToken
141148
}
142149

150+
/** Compute the left-half hash for OIDC hybrid flow claims (c_hash, at_hash, s_hash).
151+
* Per OIDC Core 3.3.2.11: SHA-256 the input, take the left half, base64url-encode.
152+
*/
153+
private def computeHalfHash(value: String): String = {
154+
val digest = MessageDigest.getInstance("SHA-256").digest(value.getBytes("ASCII"))
155+
val leftHalf = digest.take(digest.length / 2)
156+
Base64.getUrlEncoder.withoutPadding().encodeToString(leftHalf)
157+
}
158+
159+
/** Generate an ID token for the hybrid flow (response_type=code id_token).
160+
* Includes c_hash (code hash) and optionally s_hash (state hash).
161+
* at_hash is not included because no access token is returned from the authorization endpoint.
162+
*/
163+
def generateHybridIdToken(
164+
user: User,
165+
clientId: String,
166+
code: String,
167+
state: Option[String] = None,
168+
nonce: Option[String] = None
169+
): IO[String] = {
170+
for {
171+
algorithm <- getAlgorithm
172+
now = Instant.now()
173+
exp = now.plusSeconds(config.tokenExpirationSeconds)
174+
issuer = config.issuer
175+
176+
_ = logger.info(s"Generating hybrid ID token for user: ${user.sub}, client: $clientId")
177+
178+
cHash = computeHalfHash(code)
179+
_ = logger.info(s"Computed c_hash for hybrid ID token: $cHash")
180+
181+
token = JWT
182+
.create()
183+
.withIssuer(issuer)
184+
.withSubject(user.sub)
185+
.withAudience(user.provider.get)
186+
.withIssuedAt(Date.from(now))
187+
.withExpiresAt(Date.from(exp))
188+
.withKeyId(config.keyId)
189+
.withClaim("azp", clientId)
190+
.withClaim("name", user.name.orNull)
191+
.withClaim("email", user.email.getOrElse(s"${user.sub}@noemail.local"))
192+
.withClaim("provider", user.provider.getOrElse(config.issuer))
193+
.withClaim("c_hash", cHash)
194+
195+
tokenWithState = state.fold(token)(s => token.withClaim("s_hash", computeHalfHash(s)))
196+
tokenWithNonce = nonce.fold(tokenWithState)(n => tokenWithState.withClaim("nonce", n))
197+
signedToken = tokenWithNonce.sign(algorithm)
198+
199+
_ = logger.info(s"Hybrid ID token generated successfully with azp: $clientId, c_hash: $cHash")
200+
} yield signedToken
201+
}
202+
143203
def generateAccessToken(
144204
user: User,
145205
clientId: String,

src/test/scala/com/tesobe/oidc/OidcProviderIntegrationTest.scala

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,8 @@ class OidcProviderIntegrationTest extends AnyFlatSpec with Matchers {
6666
codeService,
6767
statsService,
6868
rateLimitService,
69-
testConfig
69+
testConfig,
70+
jwtService
7071
)
7172
tokenEndpoint = TokenEndpoint(
7273
authService,

0 commit comments

Comments
 (0)