Add options for requesting Token and ID Token responses

Fix handling in OIDCCore for cases where token/id_token responses are requested and response data is encoded in fragment instead of query string.
This commit is contained in:
Radek Goláň jr. 2025-01-30 09:31:42 +01:00
parent b23dc252b9
commit d08c9ac36e
Signed by: shield
GPG Key ID: D86423BFC31F3591
3 changed files with 88 additions and 24 deletions

View File

@ -9,17 +9,17 @@ import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextField import androidx.compose.material3.TextField
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@ -71,7 +71,7 @@ fun IssuerForm(modifier: Modifier = Modifier) {
val context = LocalContext.current val context = LocalContext.current
var client: OIDCCore? = null; var client: OIDCCore? = null;
if (context.fileList().contains("issuerConfig")) { if (context.fileList()?.contains("issuerConfig") == true) {
val ifo = context.openFileInput("issuerConfig") val ifo = context.openFileInput("issuerConfig")
try { try {
client = Json.decodeFromStream<OIDCCore>(ifo); client = Json.decodeFromStream<OIDCCore>(ifo);
@ -89,6 +89,10 @@ fun IssuerForm(modifier: Modifier = Modifier) {
var clientSecret by remember { mutableStateOf(client?.clientSecret ?: "") } var clientSecret by remember { mutableStateOf(client?.clientSecret ?: "") }
var scopes by remember { mutableStateOf(client?.scope ?: listOf("")) } var scopes by remember { mutableStateOf(client?.scope ?: listOf("")) }
var authCodeResponse by remember { mutableStateOf(client?.responseType?.contains(OIDCResponseType.CODE) ?: true) }
var tokenResponse by remember { mutableStateOf(client?.responseType?.contains(OIDCResponseType.TOKEN) ?: false) }
var idTokenResponse by remember { mutableStateOf(client?.responseType?.contains(OIDCResponseType.ID_TOKEN) ?: false) }
Column (modifier = modifier Column (modifier = modifier
.padding(10.dp) .padding(10.dp)
.fillMaxWidth(), .fillMaxWidth(),
@ -111,10 +115,40 @@ fun IssuerForm(modifier: Modifier = Modifier) {
modifier = Modifier.fillMaxWidth()) modifier = Modifier.fillMaxWidth())
TextField(scopes.joinToString(" "), { scopes = it.split(" ")}, TextField(scopes.joinToString(" "), { scopes = it.split(" ")},
label = { Text("Scopes")}, label = { Text("Scopes")},
modifier = Modifier.fillMaxWidth()) modifier = Modifier.fillMaxWidth()
)
Row(
Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(authCodeResponse, {authCodeResponse = it})
Text("Authorization Code")
}
Row(
Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(tokenResponse, {tokenResponse = it})
Text("Token")
}
Row(
Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(idTokenResponse, {idTokenResponse = it})
Text("ID Token")
}
Button({ Button({
val responseTypes = mutableListOf<OIDCResponseType>()
if (authCodeResponse)
responseTypes.add(OIDCResponseType.CODE)
if (tokenResponse)
responseTypes.add(OIDCResponseType.TOKEN)
if (idTokenResponse)
responseTypes.add(OIDCResponseType.ID_TOKEN)
val oidcClient = OIDCCore( val oidcClient = OIDCCore(
listOf(OIDCResponseType.CODE), responseTypes,
scopes, scopes,
clientId, clientId,
context.resources.getString(R.string.redirect_uri), context.resources.getString(R.string.redirect_uri),
@ -136,7 +170,7 @@ fun IssuerForm(modifier: Modifier = Modifier) {
val intent = Intent(Intent.ACTION_VIEW, authUri) val intent = Intent(Intent.ACTION_VIEW, authUri)
context.startActivity(intent) context.startActivity(intent)
}) { }, enabled = authCodeResponse || tokenResponse || idTokenResponse) {
Text("Authorize") Text("Authorize")
} }
} }

View File

@ -1,5 +1,6 @@
package com.shielddagger.auth.oidc_debugger package com.shielddagger.auth.oidc_debugger
import android.media.session.MediaSession.Token
import android.net.Uri import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.util.Log import android.util.Log
@ -17,11 +18,9 @@ import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ElevatedCard import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@ -148,7 +147,7 @@ fun Analysis(data: Uri?, modifier: Modifier = Modifier) {
modifier = Modifier modifier = Modifier
.padding(32.dp, 0.dp, 0.dp), .padding(32.dp, 0.dp, 0.dp),
color = if (it.success) Color(context.resources.getColor(R.color.success)) color = if (it.success) Color(context.resources.getColor(R.color.success))
else Color(context.resources.getColor(R.color.failure)) else Color.Red
) )
} }
@ -293,10 +292,12 @@ fun Analysis(data: Uri?, modifier: Modifier = Modifier) {
}, { }, {
tokenData.value = TokenResponse(OIDCTokenErrorResponse.INVALID_REQUEST) tokenData.value = TokenResponse(OIDCTokenErrorResponse.INVALID_REQUEST)
tokenExchange.value = false tokenExchange.value = false
userinfoError.value = "Token Exchange Failed"
userinfoExchange.value = false userinfoExchange.value = false
}) })
queue.add(tokenRequest) queue.add(tokenRequest)
} }
@Preview(showBackground = true) @Preview(showBackground = true)

View File

@ -6,6 +6,7 @@ import com.android.volley.Request
import com.android.volley.Response import com.android.volley.Response
import com.android.volley.toolbox.JsonObjectRequest import com.android.volley.toolbox.JsonObjectRequest
import org.json.JSONObject import org.json.JSONObject
import java.net.URLDecoder
import java.util.ArrayList import java.util.ArrayList
import kotlin.io.encoding.Base64 import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
@ -55,7 +56,8 @@ enum class OIDCAuthState(private val type: String, val message: String, val succ
INVALID_REQUEST_OBJECT("invalid_request_object", "Invalid Request Object", false), INVALID_REQUEST_OBJECT("invalid_request_object", "Invalid Request Object", false),
REQUEST_NOT_SUPPORTED("request_not_supported", "Request Not Supported", false), REQUEST_NOT_SUPPORTED("request_not_supported", "Request Not Supported", false),
REQUEST_URI_NOT_SUPPORTED("request_uri_not_supported", "Request URI Not Supported", false), REQUEST_URI_NOT_SUPPORTED("request_uri_not_supported", "Request URI Not Supported", false),
REGISTRATION_NOT_SUPPORTED("registration_not_supported", "Registration Not Supported", false); REGISTRATION_NOT_SUPPORTED("registration_not_supported", "Registration Not Supported", false),
BAD_RESPONSE("bad_response", "Bad Response from IdP", false);
override fun toString(): String { override fun toString(): String {
return this.type return this.type
@ -117,9 +119,26 @@ data class TokenResponse(
val scope: List<String>? = null val scope: List<String>? = null
) )
private fun parseQueryString(query:String): Map<String, List<String>> {
return query.split("&")
.mapNotNull { kvItem ->
val kvList = kvItem.split("=")
kvList.takeIf { it.size == 2 }
}
.mapNotNull { kvList ->
val (k, v) = kvList
(k to URLDecoder.decode(v.trim(), "UTF-8")).takeIf { v.isNotBlank() }
}
.groupBy { (key, _) -> key }
.map { mapEntry ->
mapEntry.key to mapEntry.value.map { it.second }
}
.toMap()
}
@kotlinx.serialization.Serializable @kotlinx.serialization.Serializable
class OIDCCore( class OIDCCore(
private val responseType: List<OIDCResponseType>, val responseType: List<OIDCResponseType>,
val scope: List<String>, val scope: List<String>,
val clientId: String, val clientId: String,
val redirectUri: String, val redirectUri: String,
@ -127,7 +146,7 @@ class OIDCCore(
val tokenUri: String, val tokenUri: String,
val userinfoUri: String, val userinfoUri: String,
val clientSecret: String = "", val clientSecret: String = "",
private val clientAuth: ClientAuthType = ClientAuthType.BASIC private val clientAuth: ClientAuthType = ClientAuthType.POST
) { ) {
private var nonce:String = "" private var nonce:String = ""
private var state:String = "" private var state:String = ""
@ -152,18 +171,31 @@ class OIDCCore(
} }
fun validateAuthResponse(returnUri:Uri): List<OIDCAuthState>{ fun validateAuthResponse(returnUri:Uri): List<OIDCAuthState>{
if (returnUri.getQueryParameter("state") != state){ Log.d("oidccore", "validateAuthResponse: query: ${returnUri.query}")
Log.d("oidccore", "validateAuthResponse: fragment: ${returnUri.fragment}")
val data = returnUri.encodedQuery ?: returnUri.encodedFragment
if (data == null){
return listOf(OIDCAuthState.BAD_RESPONSE)
}
val params = parseQueryString(data)
Log.d("oidccore", "validateAuthResponse: params: $params")
if (params["state"]!![0] != state){
Log.e("oidccore", "validateAuthResponse: expected: $state")
Log.e("oidccore", "validateAuthResponse: received: ${params["state"]!![0]}")
return listOf(OIDCAuthState.INVALID_STATE) return listOf(OIDCAuthState.INVALID_STATE)
} }
if (returnUri.getQueryParameter("error") != null){ if (params["error"]?.isNotEmpty() == true){
return listOf(OIDCAuthState.fromString(returnUri.getQueryParameter("error")!!)) return listOf(OIDCAuthState.fromString(returnUri.getQueryParameter("error")!!))
} }
val stateList = ArrayList<OIDCAuthState>(3) val stateList = ArrayList<OIDCAuthState>(3)
if (responseType.contains(OIDCResponseType.CODE)) { if (responseType.contains(OIDCResponseType.CODE)) {
if (returnUri.getQueryParameter("code") != null){ if (params["code"]?.get(0) != null){
stateList.add(OIDCAuthState.CODE_OK) stateList.add(OIDCAuthState.CODE_OK)
} else { } else {
stateList.add(OIDCAuthState.CODE_FAIL) stateList.add(OIDCAuthState.CODE_FAIL)
@ -171,7 +203,7 @@ class OIDCCore(
} }
if (responseType.contains(OIDCResponseType.TOKEN)) { if (responseType.contains(OIDCResponseType.TOKEN)) {
if (returnUri.getQueryParameter("token") != null){ if (params["token"]?.get(0) != null){
stateList.add(OIDCAuthState.TOKEN_OK) stateList.add(OIDCAuthState.TOKEN_OK)
} else { } else {
stateList.add(OIDCAuthState.TOKEN_FAIL) stateList.add(OIDCAuthState.TOKEN_FAIL)
@ -179,7 +211,7 @@ class OIDCCore(
} }
if (responseType.contains(OIDCResponseType.ID_TOKEN)) { if (responseType.contains(OIDCResponseType.ID_TOKEN)) {
if (returnUri.getQueryParameter("id_token") != null){ if (params["id_token"]?.get(0) != null){
stateList.add(OIDCAuthState.ID_TOKEN_OK) stateList.add(OIDCAuthState.ID_TOKEN_OK)
} else { } else {
stateList.add(OIDCAuthState.ID_TOKEN_FAIL) stateList.add(OIDCAuthState.ID_TOKEN_FAIL)
@ -192,16 +224,13 @@ class OIDCCore(
fun getTokenFromCode(returnUri: Uri, fun getTokenFromCode(returnUri: Uri,
responseHandler: Response.Listener<JSONObject> = Response.Listener {}, responseHandler: Response.Listener<JSONObject> = Response.Listener {},
errorHandler: Response.ErrorListener = Response.ErrorListener {}): JsonFormRequest { errorHandler: Response.ErrorListener = Response.ErrorListener {}): JsonFormRequest {
if (!responseType.contains(OIDCResponseType.CODE)){ val authData = returnUri.query ?: returnUri.fragment
throw RuntimeException("Can't get token from code for a client not requesting a code response type")
} val params = parseQueryString(authData ?: "")
if (returnUri.getQueryParameter("code") == null){
throw RuntimeException("Getting token from code impossible - no code in return URL")
}
val data = mutableMapOf<String,String>() val data = mutableMapOf<String,String>()
data["grant_type"] = "authorization_code" data["grant_type"] = "authorization_code"
data["code"] = returnUri.getQueryParameter("code")!! data["code"] = params["code"]?.get(0) ?: ""
data["client_id"] = clientId data["client_id"] = clientId
data["client_secret"] = clientSecret data["client_secret"] = clientSecret
data["redirect_uri"] = redirectUri data["redirect_uri"] = redirectUri