Implement basic OIDC debugging functionality

This commit is contained in:
Radek Goláň jr. 2025-01-29 22:06:10 +01:00
parent ade0f62568
commit 5427aa2032
Signed by: shield
GPG Key ID: D86423BFC31F3591
7 changed files with 427 additions and 24 deletions

View File

@ -4,6 +4,14 @@
<selectionStates>
<SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2025-01-29T21:02:35.084938400Z">
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="PhysicalDevice" identifier="serial=38091JEHN10130" />
</handle>
</Target>
</DropdownSelection>
<DialogSelection />
</SelectionState>
<SelectionState runConfigName="MainActivity">
<option name="selectionMode" value="DROPDOWN" />

View File

@ -1,27 +1,62 @@
package com.shielddagger.auth.oidc_debugger
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.shielddagger.auth.oidc_debugger.oidc.ClientAuthType
import com.shielddagger.auth.oidc_debugger.oidc.OIDCCore
import com.shielddagger.auth.oidc_debugger.oidc.OIDCResponseType
import com.shielddagger.auth.oidc_debugger.ui.theme.OIDCDebuggerTheme
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.decodeFromStream
class MainActivity : ComponentActivity() {
@OptIn(ExperimentalMaterial3Api::class)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
OIDCDebuggerTheme {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
Greeting(
name = "Android",
Scaffold(
modifier = Modifier.fillMaxSize(),
topBar = {
TopAppBar(title = {
Text("Issuer Configuration")
}
)
},
) { innerPadding ->
IssuerForm(
modifier = Modifier.padding(innerPadding)
)
}
@ -30,18 +65,87 @@ class MainActivity : ComponentActivity() {
}
}
@OptIn(ExperimentalSerializationApi::class)
@Composable
fun Greeting(name: String, modifier: Modifier = Modifier) {
Text(
text = "Hello $name!",
modifier = modifier
)
fun IssuerForm(modifier: Modifier = Modifier) {
val context = LocalContext.current
var client: OIDCCore? = null;
if (context.fileList().contains("issuerConfig")) {
val ifo = context.openFileInput("issuerConfig")
try {
client = Json.decodeFromStream<OIDCCore>(ifo);
}
catch (e: Throwable) {
Log.e("persistence", "IssuerForm: Unable to parse issuerConfig, ignoring")
}
ifo.close()
}
var authorizeUrl by remember { mutableStateOf(client?.authorizeUri ?: "") }
var tokenUrl by remember { mutableStateOf(client?.tokenUri ?: "") }
var userinfoUrl by remember { mutableStateOf(client?.userinfoUri ?: "") }
var clientId by remember { mutableStateOf(client?.clientId ?: "") }
var clientSecret by remember { mutableStateOf(client?.clientSecret ?: "") }
var scopes by remember { mutableStateOf(client?.scope ?: listOf("")) }
Column (modifier = modifier
.padding(10.dp)
.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalAlignment = Alignment.CenterHorizontally) {
TextField(authorizeUrl, { authorizeUrl = it },
label = { Text("Authorize URL")},
modifier = Modifier.fillMaxWidth())
TextField(tokenUrl, { tokenUrl = it },
label = { Text("Token URL")},
modifier = Modifier.fillMaxWidth())
TextField(userinfoUrl, { userinfoUrl = it },
label = { Text("Userinfo URL")},
modifier = Modifier.fillMaxWidth())
TextField(clientId, { clientId = it },
label = { Text("Client ID")},
modifier = Modifier.fillMaxWidth())
TextField(clientSecret, { clientSecret = it },
label = { Text("Client Secret")},
modifier = Modifier.fillMaxWidth())
TextField(scopes.joinToString(" "), { scopes = it.split(" ")},
label = { Text("Scopes")},
modifier = Modifier.fillMaxWidth())
Button({
val oidcClient = OIDCCore(
listOf(OIDCResponseType.CODE),
scopes,
clientId,
context.resources.getString(R.string.redirect_uri),
authorizeUrl,
tokenUrl,
userinfoUrl,
clientSecret,
ClientAuthType.POST
)
val authUri = oidcClient.beginAuth()
Log.d("oidcdebugger", "IssuerForm: authUri: $authUri")
val of = context.openFileOutput("issuerConfig", Context.MODE_PRIVATE)
val bv = of.bufferedWriter()
val data = Json.encodeToString(oidcClient)
bv.write(data)
bv.close()
of.close()
val intent = Intent(Intent.ACTION_VIEW, authUri)
context.startActivity(intent)
}) {
Text("Authorize")
}
}
}
@Preview(showBackground = true)
@Composable
fun GreetingPreview() {
OIDCDebuggerTheme {
Greeting("Android")
IssuerForm()
}
}

View File

@ -1,19 +1,49 @@
package com.shielddagger.auth.oidc_debugger
import android.net.Uri
import android.os.Bundle
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.android.volley.toolbox.Volley
import com.shielddagger.auth.oidc_debugger.oidc.OIDCCore
import com.shielddagger.auth.oidc_debugger.oidc.OIDCTokenErrorResponse
import com.shielddagger.auth.oidc_debugger.oidc.TokenResponse
import com.shielddagger.auth.oidc_debugger.ui.theme.OIDCDebuggerTheme
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.decodeFromStream
import org.json.JSONObject
class ValidateActivity : ComponentActivity() {
@OptIn(ExperimentalMaterial3Api::class)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@ -23,10 +53,16 @@ class ValidateActivity : ComponentActivity() {
enableEdgeToEdge()
setContent {
OIDCDebuggerTheme {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
Greeting2(
data = data.toString(),
action = action.toString(),
Scaffold(
modifier = Modifier.fillMaxSize(),
topBar = {TopAppBar(
title = {
Text("Response Analysis")
}
)}
) { innerPadding ->
Analysis(
data = data,
modifier = Modifier.padding(innerPadding)
)
}
@ -35,18 +71,239 @@ class ValidateActivity : ComponentActivity() {
}
}
@OptIn(ExperimentalSerializationApi::class)
@Composable
fun Greeting2(data: String, action: String, modifier: Modifier = Modifier) {
Text(
text = "$data\n$action",
modifier = modifier
)
fun Analysis(data: Uri?, modifier: Modifier = Modifier) {
val context = LocalContext.current
var client: OIDCCore? = null;
val tokenExchange = remember {
mutableStateOf(true)
}
val tokenData = remember {
mutableStateOf(TokenResponse())
}
val userinfoExchange = remember {
mutableStateOf(true)
}
val userinfoError = remember {
mutableStateOf<String?>(null)
}
val userinfoData = remember {
mutableStateOf(JSONObject())
}
if (context.fileList().contains("issuerConfig")) {
val ifo = context.openFileInput("issuerConfig")
try {
client = Json.decodeFromStream<OIDCCore>(ifo);
}
catch (e: Throwable) {
Log.e("persistence", "IssuerForm: Unable to parse issuerConfig, ignoring")
}
ifo.close()
}
Column (modifier = modifier
.padding(10.dp)
.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalAlignment = Alignment.CenterHorizontally) {
ElevatedCard(
elevation = CardDefaults.cardElevation(
defaultElevation = 6.dp
),
modifier = Modifier.fillMaxWidth()
) {
Text(
text = "Authorization State",
modifier = Modifier
.padding(16.dp),
textAlign = TextAlign.Center,
)
if (client == null) {
Text(
"Client Missing",
modifier = Modifier
.padding(32.dp, 0.dp, 0.dp),
color = Color.Red
)
Spacer(Modifier.height(32.dp))
return@ElevatedCard
}
if (data == null) {
Text(
"Data Missing",
modifier = Modifier
.padding(32.dp, 0.dp, 0.dp),
color = Color.Red
)
Spacer(Modifier.height(32.dp))
return@ElevatedCard
}
client.validateAuthResponse(data).forEach {
Text(
it.message,
modifier = Modifier
.padding(32.dp, 0.dp, 0.dp),
color = if (it.success) Color(context.resources.getColor(R.color.success))
else Color(context.resources.getColor(R.color.failure))
)
}
Spacer(Modifier.height(32.dp))
}
ElevatedCard(
elevation = CardDefaults.cardElevation(
defaultElevation = 6.dp
),
modifier = Modifier.fillMaxWidth()
) {
Text(
text = "Token Exchange",
modifier = Modifier
.padding(16.dp),
textAlign = TextAlign.Center,
)
if (tokenExchange.value) {
CircularProgressIndicator(
modifier = Modifier
.padding(32.dp, 0.dp)
)
Spacer(Modifier.height(32.dp))
return@ElevatedCard
}
if (tokenData.value.error != null) {
Text(
text="Error: ${tokenData.value.error!!.message}",
modifier = Modifier
.padding(32.dp, 0.dp, 0.dp),
color = Color.Red
)
Spacer(Modifier.height(32.dp))
return@ElevatedCard
}
Text(
text = "Type: ${tokenData.value.tokenType.toString()}",
modifier = Modifier
.padding(32.dp, 0.dp, 0.dp),
textAlign = TextAlign.Center
)
Text(
text = "Scopes: ${tokenData.value.scope?.joinToString(", ")}",
modifier = Modifier
.padding(32.dp, 0.dp, 0.dp),
textAlign = TextAlign.Center
)
Text(
text = "Expires In: ${tokenData.value.expiresIn?.toString() ?: Int.MAX_VALUE}s",
modifier = Modifier
.padding(32.dp, 0.dp, 0.dp),
textAlign = TextAlign.Center
)
Text(
text = "Refresh Token: ${tokenData.value.refreshToken != null}",
modifier = Modifier
.padding(32.dp, 0.dp, 0.dp),
textAlign = TextAlign.Center
)
Text(
text = "ID Token: ${tokenData.value.idToken != null}",
modifier = Modifier
.padding(32.dp, 0.dp, 0.dp),
textAlign = TextAlign.Center
)
Spacer(Modifier.height(32.dp))
}
ElevatedCard(
elevation = CardDefaults.cardElevation(
defaultElevation = 6.dp
),
modifier = Modifier.fillMaxWidth()
) {
Text(
text = "User Info",
modifier = Modifier
.padding(16.dp),
textAlign = TextAlign.Center,
)
if (userinfoExchange.value) {
CircularProgressIndicator(
modifier = Modifier
.padding(32.dp, 0.dp)
)
Spacer(Modifier.height(32.dp))
return@ElevatedCard
}
if (userinfoError.value != null){
Text(
userinfoError.value!!,
modifier = Modifier
.padding(32.dp, 0.dp, 0.dp),
color = Color.Red
)
Spacer(Modifier.height(32.dp))
return@ElevatedCard
}
userinfoData.value.keys().forEach {
Text(
"$it: ${userinfoData.value[it]}",
modifier = Modifier
.padding(32.dp, 0.dp, 0.dp)
)
}
Spacer(Modifier.height(32.dp))
}
}
if (client == null){
tokenExchange.value = false
tokenData.value = TokenResponse(OIDCTokenErrorResponse.INVALID_CLIENT)
userinfoExchange.value = false
return
}
val queue = Volley.newRequestQueue(context)
val tokenRequest = client.getTokenFromCode(data!!, { tokenResponse ->
val tokenResponseData = client.validateTokenResponse(tokenResponse)
tokenData.value = tokenResponseData
tokenExchange.value = false
if (tokenResponseData.error != null) {
return@getTokenFromCode
}
val userinfoRequest = client.getUserinfo(tokenData.value.accessToken!!, { userdataResponse ->
userinfoData.value = userdataResponse
userinfoExchange.value = false
Log.d("oidcdebugger", "userinfo: ${userdataResponse.toString(4)}")
}, {
userinfoError.value = (it.message ?: "Unknown Error")
userinfoExchange.value = false
Log.d("oidcdebugger", it.message.toString())
})
queue.add(userinfoRequest)
}, {
tokenData.value = TokenResponse(OIDCTokenErrorResponse.INVALID_REQUEST)
tokenExchange.value = false
userinfoExchange.value = false
})
queue.add(tokenRequest)
}
@Preview(showBackground = true)
@Composable
fun GreetingPreview2() {
OIDCDebuggerTheme {
Greeting2("data", "action")
Analysis(null)
}
}

View File

@ -1,6 +1,7 @@
package com.shielddagger.auth.oidc_debugger.oidc
import android.net.Uri
import android.util.Log
import com.android.volley.Request
import com.android.volley.Response
import com.android.volley.toolbox.JsonObjectRequest
@ -74,7 +75,8 @@ enum class OIDCTokenErrorResponse(private val type: String, val message: String)
INVALID_GRANT("invalid_grant", "Invalid Grant"),
UNAUTHORIZED_CLIENT("unauthorized_client", "Unauthorized Client"),
UNAUTHORIZED_GRANT_TYPE("unsupported_grant_type", "Unauthorized Grant Type"),
INVALID_SCOPE("invalid_scope", "Invalid Scope");
INVALID_SCOPE("invalid_scope", "Invalid Scope"),
BAD_STATE("bad_state", "Incorrect Session State");
override fun toString(): String {
return this.type
@ -110,6 +112,8 @@ data class TokenResponse(
val tokenType: OIDCTokenType? = null,
val expiresIn: Int? = null,
val refreshToken: String? = null,
val refreshExpiresIn: Int? = null,
val idToken: String? = null,
val scope: List<String>? = null
)
@ -218,16 +222,28 @@ class OIDCCore(
)
}
Log.d("oidccore", "validateTokenResponse: ${response.toString(4)}")
return TokenResponse(
accessToken = response.getString("access_token"),
tokenType = OIDCTokenType.fromString(response.getString("token_type")),
tokenType = OIDCTokenType.fromString(response.getString("token_type").lowercase()),
idToken = response.getString("id_token"),
expiresIn = if (response.has("expires_in")) response.getInt("expires_in") else null,
refreshToken = if (response.has("refresh_token")) response.getString("refresh_token") else null,
refreshExpiresIn = if (response.has("refresh_expires_in")) response.getInt("refresh_expires_in") else null,
scope = if (response.has("scope")) response.getString("scope").split(" ") else null
)
}
fun getUserinfo(accessToken: String?): JsonObjectRequest? {
return null
fun getUserinfo(accessToken: String,
responseHandler: Response.Listener<JSONObject> = Response.Listener {},
errorHandler: Response.ErrorListener = Response.ErrorListener {}): JsonObjectRequest {
return JsonAuthRequest(
Request.Method.GET,
userinfoUri,
accessToken,
responseHandler,
errorHandler
)
}
}

View File

@ -47,4 +47,19 @@ class JsonFormRequest(
}
return null
}
}
class JsonAuthRequest(
method: Int,
url: String?,
private var token: String,
listener: Response.Listener<JSONObject>?,
errorListener: Response.ErrorListener?
) : JsonObjectRequest(method, url, null, listener, errorListener) {
override fun getHeaders(): MutableMap<String, String> {
val headers = mutableMapOf<String,String>()
headers.putAll(super.getHeaders())
headers["Authorization"] = "Bearer $token"
return headers;
}
}

View File

@ -7,4 +7,6 @@
<color name="teal_700">#FF018786</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
<color name="success">#FF009900</color>
<color name="failure">#FF990000</color>
</resources>

View File

@ -20,4 +20,5 @@ kotlin.code.style=official
# Enables namespacing of each library's R class so that its R class includes only the
# resources declared in the library itself and none from the library's dependencies,
# thereby reducing the size of the R class for that library
android.nonTransitiveRClass=true
android.nonTransitiveRClass=true
org.gradle.configuration-cache=true