Finding: OBP-OIDC is not fully compliant with OAuth 2.0/OIDC specifications for the authorization_code grant type. Specifically, it does not properly support the client_secret_basic authentication method despite advertising it in its discovery document.
Impact: Portal requires a workaround to authenticate with OBP-OIDC by always sending client credentials in the POST body (client_secret_post) instead of using HTTP Basic authentication, which is the standard approach used by compliant OIDC providers like Keycloak.
The OAuth 2.0 specification defines multiple ways for clients to authenticate with the authorization server:
-
client_secret_basic: Client credentials sent via HTTP Basic authentication- Authorization header:
Basic base64(client_id:client_secret) - Client credentials should NOT be in the request body
- Authorization header:
-
client_secret_post: Client credentials sent in the POST bodyclient_idandclient_secretas form parameters
-
none: Public clients without a client secret- Only
client_idin the form body
- Only
The Portal uses the Arctic library's OAuth2Client as the base class. Arctic implements OAuth 2.0 correctly:
// From node_modules/arctic/dist/client.js
async validateAuthorizationCode(tokenEndpoint, code, codeVerifier) {
const body = new URLSearchParams();
body.set("grant_type", "authorization_code");
body.set("code", code);
if (this.redirectURI !== null) {
body.set("redirect_uri", this.redirectURI);
}
// KEY: Only includes client_id in body if there's no password
if (this.clientPassword === null) {
body.set("client_id", this.clientId);
}
const request = createOAuth2Request(tokenEndpoint, body);
// KEY: Uses Basic Auth when password exists
if (this.clientPassword !== null) {
const encodedCredentials = encodeBasicCredentials(this.clientId, this.clientPassword);
request.headers.set("Authorization", `Basic ${encodedCredentials}`);
}
return await sendTokenRequest(request);
}Arctic's Behavior:
- ✅ If
clientPasswordexists: Uses HTTP Basic Auth (client_secret_basic) - ✅ If
clientPasswordis null: Uses body params only (none) - ✅ Follows OAuth 2.0 specification correctly
Portal overrides the validateAuthorizationCode method to work around OBP-OIDC's limitations:
// From src/lib/oauth/client.ts
async validateAuthorizationCode(tokenEndpoint: string, code: string, codeVerifier: string | null): Promise<any> {
logger.debug('Validating authorization code with explicit client_id');
const body = new URLSearchParams();
body.set('grant_type', 'authorization_code');
body.set('code', code);
body.set('redirect_uri', this.redirectURI);
body.set('client_id', this.clientId); // ALWAYS in body
if (this.clientSecret) {
body.set('client_secret', this.clientSecret); // ALSO in body
}
if (codeVerifier) {
body.set('code_verifier', codeVerifier);
}
// NO Basic Auth header - everything in body
const response = await fetch(tokenEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Accept': 'application/json'
},
body: body.toString()
});
// ... handle response
}Portal's Workaround Behavior:
⚠️ ALWAYS includesclient_idin the body⚠️ ALWAYS includesclient_secretin the body (if present)⚠️ NEVER uses HTTP Basic Auth⚠️ Effectively forcesclient_secret_postauthentication method
OBP-OIDC advertises support for multiple authentication methods:
// From src/main/scala/com/tesobe/oidc/endpoints/DiscoveryEndpoint.scala
token_endpoint_auth_methods_supported =
List("client_secret_post", "client_secret_basic", "none")Claims: Supports all three standard authentication methods ✓
However, the token endpoint implementation tells a different story:
// From src/main/scala/com/tesobe/oidc/endpoints/TokenEndpoint.scala
private def handleTokenRequest(req: Request[IO], form: UrlForm): IO[Response[IO]] = {
val formData = form.values.view.mapValues(_.headOption.getOrElse("")).toMap
val grantType = formData.get("grant_type")
val code = formData.get("code")
val redirectUri = formData.get("redirect_uri")
val clientId = formData.get("client_id") // Only reads from form body!
val refreshToken = formData.get("refresh_token")
grantType match {
case Some("authorization_code") =>
(code, redirectUri, clientId) match {
case (Some(authCode), Some(redirectUriValue), Some(clientIdValue)) =>
// Requires clientId from form body
processAuthorizationCodeGrant(authCode, redirectUriValue, clientIdValue)
case _ =>
BadRequest(OidcError("invalid_request",
Some("Missing required parameters for authorization_code grant")).asJson)
}
// ... other grant types
}
}Problem: For authorization_code grant, OBP-OIDC:
- ❌ Only extracts
client_idfrom form body - ❌ Does NOT call
extractBasicAuthCredentialsfor this grant type - ❌ Will fail if client credentials are sent via Basic Auth
- ❌ Does not implement
client_secret_basicdespite advertising it
OBP-OIDC DOES handle Basic Auth correctly for client_credentials grant:
case Some("client_credentials") =>
// Extract client credentials from Basic Auth header OR form data
val credentials = extractBasicAuthCredentials(req).orElse {
(formData.get("client_id"), formData.get("client_secret")) match {
case (Some(id), Some(secret)) => Some((id, secret))
case _ => None
}
}This shows that:
- ✅ OBP-OIDC knows how to extract Basic Auth credentials
- ✅
client_credentialsgrant properly supports both authentication methods - ❌ But this logic is NOT applied to
authorization_codegrant
Keycloak is a mature, fully compliant OIDC provider that:
- ✅ Properly supports
client_secret_basicauthentication - ✅ Properly supports
client_secret_postauthentication - ✅ Handles both methods for ALL grant types consistently
- ✅ Portal can use the standard Arctic OAuth2Client without modifications
When Portal authenticates with Keycloak:
- Uses Arctic's standard
validateAuthorizationCodemethod - Sends credentials via HTTP Basic Auth
- Works perfectly without any workarounds
OBP-OIDC has partial OIDC compliance:
- ❌ Does NOT properly support
client_secret_basicfor authorization_code - ✅ Does support
client_secret_postfor authorization_code - ✅ Does support both methods for client_credentials grant
⚠️ Advertises support it doesn't fully implement- ❌ Portal requires custom workaround to function
| Aspect | Keycloak | OBP-OIDC | Spec Compliant? |
|---|---|---|---|
| Discovery document accuracy | ✅ Accurate | ❌ Inaccurate | OBP-OIDC: No |
client_secret_basic for authorization_code |
✅ Works | ❌ Broken | OBP-OIDC: No |
client_secret_post for authorization_code |
✅ Works | ✅ Works | Both: Yes |
client_secret_basic for client_credentials |
✅ Works | ✅ Works | Both: Yes |
client_secret_post for client_credentials |
✅ Works | ✅ Works | Both: Yes |
| Consistent authentication across grant types | ✅ Yes | ❌ No | OBP-OIDC: No |
Update handleTokenRequest to extract client credentials from both sources:
private def handleTokenRequest(req: Request[IO], form: UrlForm): IO[Response[IO]] = {
val formData = form.values.view.mapValues(_.headOption.getOrElse("")).toMap
// Extract client credentials from Basic Auth OR form body
val clientCredentials = extractBasicAuthCredentials(req).orElse {
formData.get("client_id").map { id =>
(id, formData.get("client_secret").getOrElse(""))
}
}
val grantType = formData.get("grant_type")
val code = formData.get("code")
val redirectUri = formData.get("redirect_uri")
val refreshToken = formData.get("refresh_token")
grantType match {
case Some("authorization_code") =>
(code, redirectUri, clientCredentials) match {
case (Some(authCode), Some(redirectUriValue), Some((clientId, clientSecret))) =>
processAuthorizationCodeGrant(authCode, redirectUriValue, clientId)
case _ =>
BadRequest(OidcError("invalid_request",
Some("Missing required parameters")).asJson)
}
// ... rest of implementation
}
}This would:
- ✅ Support both
client_secret_basicandclient_secret_post - ✅ Make discovery document accurate
- ✅ Allow Portal to use standard Arctic client
- ✅ Achieve OAuth 2.0/OIDC compliance
- ✅ Maintain backward compatibility
If fixing the implementation is not feasible, update the discovery document to be honest:
token_endpoint_auth_methods_supported =
List("client_secret_post", "none") // Remove "client_secret_basic"This would:
⚠️ Still require Portal workaround- ✅ Make documentation accurate
⚠️ Acknowledge non-compliance- ✅ Set correct client expectations
Portal maintains a custom workaround in OAuth2ClientWithConfig class:
Benefits:
- ✅ Works with both Keycloak and OBP-OIDC
- ✅ Provides consistent behavior across providers
Drawbacks:
- ❌ Deviates from OAuth 2.0 best practices
- ❌ Doesn't use HTTP Basic Auth (less secure in some contexts)
- ❌ Requires maintaining custom OAuth client code
- ❌ Can't leverage Arctic library updates directly
- ❌ May not work with other OIDC providers
Portal could:
- Remove the
validateAuthorizationCodeoverride - Use Arctic's standard
OAuth2Clientdirectly - Simplify the codebase
- Work with any compliant OIDC provider
- Benefit from Arctic library improvements automatically
Files involved in the workaround:
src/lib/oauth/client.ts: ContainsOAuth2ClientWithConfigwith the workaroundsrc/lib/oauth/providerFactory.ts: Creates OAuth clients for both providerssrc/routes/login/obp/callback/+server.ts: Uses the OAuth client
HTTP Basic Auth (client_secret_basic):
- ✅ Client secret not in URL or easily logged request bodies
- ✅ Standard practice for confidential clients
- ✅ Easier to filter from logs
- ✅ Recommended by OAuth 2.0 specification
Body Parameters (client_secret_post):
⚠️ Client secret in request body (less secure)- ✅ Works with all clients/libraries
⚠️ May appear in request logs⚠️ Alternative method, not preferred
Both methods work over HTTPS, but Basic Auth is generally preferred for confidential clients.
To verify OAuth 2.0 compliance, test:
-
Authorization Code Flow with Basic Auth:
curl -X POST http://localhost:9000/obp-oidc/token \ -H "Content-Type: application/x-www-form-urlencoded" \ -H "Authorization: Basic $(echo -n 'client_id:client_secret' | base64)" \ -d "grant_type=authorization_code" \ -d "code=AUTH_CODE" \ -d "redirect_uri=http://localhost:5174/login/obp/callback"
Expected: Should work (currently fails)
-
Authorization Code Flow with Body Params:
curl -X POST http://localhost:9000/obp-oidc/token \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "grant_type=authorization_code" \ -d "code=AUTH_CODE" \ -d "redirect_uri=http://localhost:5174/login/obp/callback" \ -d "client_id=CLIENT_ID" \ -d "client_secret=CLIENT_SECRET"
Expected: Should work (currently works)
-
Verify both methods work for all grant types
OBP-OIDC is not fully OAuth 2.0/OIDC compliant because:
- It advertises support for
client_secret_basicbut doesn't implement it for authorization_code grant - It implements authentication methods inconsistently across grant types
- It requires clients to use workarounds to function properly
Portal's workaround is necessary to authenticate with OBP-OIDC but:
- It forces Portal to use non-standard authentication approach
- It prevents Portal from working with fully compliant OIDC providers that only accept Basic Auth
- It adds maintenance burden to Portal codebase
Recommendation: Fix OBP-OIDC's token endpoint to properly support client_secret_basic for all grant types, which would:
- Achieve OAuth 2.0 compliance
- Allow Portal to remove workarounds
- Enable OBP-OIDC to work with any OAuth 2.0 compliant client
- Improve security posture
- Match the behavior of mature OIDC providers like Keycloak
- RFC 6749 - OAuth 2.0 Authorization Framework
- RFC 6749 Section 2.3 - Client Authentication
- OpenID Connect Core 1.0
- Arctic OAuth Library
- Keycloak Documentation
Document Version: 1.0 Date: 2025-01-20 Author: Technical Analysis Status: Active Issue - OBP-OIDC Non-Compliance Confirmed