Skip to content

Commit 177ccdb

Browse files
antspriggsclaude
andcommitted
Replace policies endpoint with hardcoded policies, fix sandbox OAuth flow
- Remove broken /api/public/v3/policies endpoint; replace with hardcoded Login, NIST AAL2/IAL2, and Military policies in AuthViewModel - Add LOGIN and NIST_AAL2_IAL2 scopes to IDmeScope enum - Read sandbox client ID and client_secret from local.properties via BuildConfig fields injected in demo/build.gradle.kts - Include client_secret in token exchange body (ID.me sandbox requires it even for PKCE flows — only client_secret_post/basic auth methods supported) - Include scope in token exchange request body - Fix HTTP POST body writing to use explicit byte array + Content-Length header - Remove deprecated policies() method from IDmeAuth and APIEndpoint - Remove isLoadingPolicies state and LaunchedEffect policy fetch from LoginScreen Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent bafd57a commit 177ccdb

10 files changed

Lines changed: 54 additions & 146 deletions

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,3 +33,6 @@ local.properties
3333
# Signing files
3434
*.jks
3535
*.keystore
36+
37+
# Claude
38+
CLAUDE.md

demo/build.gradle.kts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,14 @@
11
import com.android.build.gradle.internal.dsl.BaseAppModuleExtension
2+
import java.util.Properties
23

34
apply(plugin = "com.android.application")
45
apply(plugin = "kotlin-android")
56

7+
val localProps = Properties().also { props ->
8+
val f = rootProject.file("local.properties")
9+
if (f.exists()) props.load(f.inputStream())
10+
}
11+
612
configure<BaseAppModuleExtension> {
713
namespace = "com.idme.auth.demo"
814
compileSdk = 34
@@ -15,6 +21,11 @@ configure<BaseAppModuleExtension> {
1521
versionName = "1.0.0"
1622

1723
manifestPlaceholders["idmeRedirectScheme"] = "idmedemo"
24+
25+
buildConfigField("String", "SANDBOX_CLIENT_ID",
26+
"\"${localProps.getProperty("idme.sandbox.client_id", "")}\"")
27+
buildConfigField("String", "SANDBOX_CLIENT_SECRET",
28+
"\"${localProps.getProperty("idme.sandbox.client_secret", "")}\"")
1829
}
1930

2031
buildTypes {
@@ -34,6 +45,7 @@ configure<BaseAppModuleExtension> {
3445

3546
buildFeatures {
3647
compose = true
48+
buildConfig = true
3749
}
3850

3951
composeOptions {

demo/src/main/kotlin/com/idme/auth/demo/AuthViewModel.kt

Lines changed: 13 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import androidx.compose.runtime.mutableStateOf
77
import androidx.compose.runtime.setValue
88
import androidx.lifecycle.AndroidViewModel
99
import androidx.lifecycle.viewModelScope
10+
import com.idme.auth.demo.BuildConfig
1011
import com.idme.auth.IDmeAuth
1112
import com.idme.auth.configuration.IDmeAuthMode
1213
import com.idme.auth.configuration.IDmeConfiguration
@@ -18,6 +19,12 @@ import com.idme.auth.models.Credentials
1819
import com.idme.auth.models.Policy
1920
import kotlinx.coroutines.launch
2021

22+
private val STANDARD_POLICIES = listOf(
23+
Policy(name = "Login", handle = "login", active = true, groups = emptyList()),
24+
Policy(name = "NIST AAL2 / IAL2", handle = "http://idmanagement.gov/ns/assurance/ial/2/aal/2", active = true, groups = emptyList()),
25+
Policy(name = "Military", handle = "military", active = true, groups = emptyList())
26+
)
27+
2128
class AuthViewModel(application: Application) : AndroidViewModel(application) {
2229

2330
// MARK: - Configuration Inputs
@@ -26,15 +33,14 @@ class AuthViewModel(application: Application) : AndroidViewModel(application) {
2633
private set
2734

2835
var authMode by mutableStateOf(IDmeAuthMode.OAUTH_PKCE)
29-
var environment by mutableStateOf(IDmeEnvironment.PRODUCTION)
36+
var environment by mutableStateOf(IDmeEnvironment.SANDBOX)
3037
private set
3138

3239
var verificationType by mutableStateOf(IDmeVerificationType.SINGLE)
3340

3441
// MARK: - State
3542

36-
var policies by mutableStateOf(listOf<Policy>())
37-
private set
43+
val policies: List<Policy> = STANDARD_POLICIES
3844

3945
var credentials by mutableStateOf<Credentials?>(null)
4046
private set
@@ -45,9 +51,6 @@ class AuthViewModel(application: Application) : AndroidViewModel(application) {
4551
var isLoading by mutableStateOf(false)
4652
private set
4753

48-
var isLoadingPolicies by mutableStateOf(false)
49-
private set
50-
5154
var errorMessage by mutableStateOf<String?>(null)
5255

5356
val hasPayload: Boolean get() = payloadClaims.isNotEmpty()
@@ -60,13 +63,13 @@ class AuthViewModel(application: Application) : AndroidViewModel(application) {
6063
private val clientId: String
6164
get() = when (environment) {
6265
IDmeEnvironment.PRODUCTION -> "YOUR_PRODUCTION_CLIENT_ID"
63-
IDmeEnvironment.SANDBOX -> "YOUR_SANDBOX_CLIENT_ID"
66+
IDmeEnvironment.SANDBOX -> BuildConfig.SANDBOX_CLIENT_ID
6467
}
6568

6669
private val clientSecret: String
6770
get() = when (environment) {
6871
IDmeEnvironment.PRODUCTION -> "YOUR_PRODUCTION_CLIENT_SECRET"
69-
IDmeEnvironment.SANDBOX -> "YOUR_SANDBOX_CLIENT_SECRET"
72+
IDmeEnvironment.SANDBOX -> BuildConfig.SANDBOX_CLIENT_SECRET
7073
}
7174

7275
// MARK: - Private
@@ -84,32 +87,7 @@ class AuthViewModel(application: Application) : AndroidViewModel(application) {
8487
}
8588

8689
fun updateEnvironment(env: IDmeEnvironment) {
87-
if (env != environment) {
88-
environment = env
89-
viewModelScope.launch { fetchPolicies() }
90-
}
91-
}
92-
93-
// MARK: - Policies
94-
95-
fun fetchPolicies() {
96-
viewModelScope.launch {
97-
isLoadingPolicies = true
98-
99-
try {
100-
val auth = buildAuth(listOf(IDmeScope.MILITARY))
101-
val fetched = auth.policies()
102-
policies = fetched.filter { it.active }
103-
// Clear selections that no longer exist
104-
val validHandles = policies.map { it.handle }.toSet()
105-
selectedPolicies = selectedPolicies.intersect(validHandles)
106-
} catch (e: Exception) {
107-
android.util.Log.e("AuthViewModel", "Failed to fetch policies", e)
108-
policies = emptyList()
109-
}
110-
111-
isLoadingPolicies = false
112-
}
90+
environment = env
11391
}
11492

11593
// MARK: - Actions
@@ -197,9 +175,7 @@ class AuthViewModel(application: Application) : AndroidViewModel(application) {
197175
finalScopes.add(0, IDmeScope.OPENID)
198176
}
199177

200-
// Client secret is only applicable for server-side OAuth flows; never embed in PKCE or OIDC.
201-
// TODO: Load credentials from local.properties via BuildConfig rather than hardcoding.
202-
val secret = if (authMode == IDmeAuthMode.OAUTH) clientSecret else null
178+
val secret = clientSecret.ifBlank { null }
203179

204180
val config = IDmeConfiguration(
205181
clientId = clientId,

demo/src/main/kotlin/com/idme/auth/demo/ui/LoginScreen.kt

Lines changed: 10 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ import androidx.compose.foundation.layout.fillMaxSize
1010
import androidx.compose.foundation.layout.fillMaxWidth
1111
import androidx.compose.foundation.layout.height
1212
import androidx.compose.foundation.layout.padding
13-
import androidx.compose.foundation.layout.size
1413
import androidx.compose.foundation.lazy.LazyColumn
1514
import androidx.compose.foundation.lazy.items
1615
import androidx.compose.foundation.shape.RoundedCornerShape
@@ -27,7 +26,6 @@ import androidx.compose.material3.Surface
2726
import androidx.compose.material3.Switch
2827
import androidx.compose.material3.Text
2928
import androidx.compose.runtime.Composable
30-
import androidx.compose.runtime.LaunchedEffect
3129
import androidx.compose.ui.Alignment
3230
import androidx.compose.ui.Modifier
3331
import androidx.compose.ui.platform.LocalContext
@@ -40,12 +38,6 @@ import com.idme.auth.demo.AuthViewModel
4038
fun LoginScreen(viewModel: AuthViewModel, modifier: Modifier = Modifier) {
4139
val activity = LocalContext.current as? Activity
4240

43-
LaunchedEffect(Unit) {
44-
if (viewModel.policies.isEmpty()) {
45-
viewModel.fetchPolicies()
46-
}
47-
}
48-
4941
Box(modifier = modifier.fillMaxSize()) {
5042
LazyColumn(
5143
modifier = Modifier
@@ -105,55 +97,16 @@ fun LoginScreen(viewModel: AuthViewModel, modifier: Modifier = Modifier) {
10597
)
10698
}
10799

108-
if (viewModel.isLoadingPolicies) {
109-
item {
110-
Row(
111-
verticalAlignment = Alignment.CenterVertically,
112-
horizontalArrangement = Arrangement.spacedBy(8.dp)
113-
) {
114-
CircularProgressIndicator(modifier = Modifier.size(20.dp))
115-
Text(
116-
"Loading policies...",
117-
color = MaterialTheme.colorScheme.onSurfaceVariant
118-
)
119-
}
120-
}
121-
} else if (viewModel.policies.isEmpty()) {
122-
item {
123-
Text(
124-
"No policies available. Check your credentials.",
125-
color = MaterialTheme.colorScheme.onSurfaceVariant
126-
)
127-
}
128-
} else {
129-
items(viewModel.policies, key = { it.handle }) { policy ->
130-
Row(
131-
modifier = Modifier.fillMaxWidth(),
132-
verticalAlignment = Alignment.CenterVertically,
133-
horizontalArrangement = Arrangement.SpaceBetween
134-
) {
135-
Column(modifier = Modifier.weight(1f)) {
136-
Text(policy.name)
137-
if (policy.groups.isNotEmpty()) {
138-
Text(
139-
policy.groups.joinToString(", ") { it.name },
140-
style = MaterialTheme.typography.bodySmall,
141-
color = MaterialTheme.colorScheme.onSurfaceVariant
142-
)
143-
}
144-
}
145-
Switch(
146-
checked = policy.handle in viewModel.selectedPolicies,
147-
onCheckedChange = { viewModel.togglePolicy(policy.handle) }
148-
)
149-
}
150-
}
151-
152-
item {
153-
Text(
154-
"Policies are fetched from /api/public/v3/policies. The handle is used as the OAuth scope.",
155-
style = MaterialTheme.typography.bodySmall,
156-
color = MaterialTheme.colorScheme.onSurfaceVariant
100+
items(viewModel.policies, key = { it.handle }) { policy ->
101+
Row(
102+
modifier = Modifier.fillMaxWidth(),
103+
verticalAlignment = Alignment.CenterVertically,
104+
horizontalArrangement = Arrangement.SpaceBetween
105+
) {
106+
Text(policy.name)
107+
Switch(
108+
checked = policy.handle in viewModel.selectedPolicies,
109+
onCheckedChange = { viewModel.togglePolicy(policy.handle) }
157110
)
158111
}
159112
}

sdk/src/main/kotlin/com/idme/auth/IDmeAuth.kt

Lines changed: 1 addition & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@ import com.idme.auth.jwt.JWTDecoder
1818
import com.idme.auth.jwt.JWTValidator
1919
import com.idme.auth.models.AttributeResponse
2020
import com.idme.auth.models.Credentials
21-
import com.idme.auth.models.Policy
2221
import com.idme.auth.models.UserInfo
2322
import com.idme.auth.networking.APIEndpoint
2423
import com.idme.auth.networking.DefaultHTTPClient
@@ -110,6 +109,7 @@ class IDmeAuth(
110109
this.lastNonce = nonce
111110

112111
Log.info("Starting auth session: ${configuration.verificationType.value} mode")
112+
Log.debug("Authorize URL: $authURL")
113113

114114
val callbackURL = IDmeAuthManager.launchAuth(activity, authURL, state)
115115

@@ -144,42 +144,6 @@ class IDmeAuth(
144144
return tokenManager.validCredentials(minTTL)
145145
}
146146

147-
// MARK: - Policies
148-
149-
/**
150-
* Fetches the available verification policies for the organization.
151-
*
152-
* Uses the client credentials (client_id and client_secret) to authenticate via HTTP Basic Auth.
153-
* The policy `handle` can be used as the OAuth `scope` parameter.
154-
*
155-
* @return A list of available policies.
156-
*/
157-
suspend fun policies(): List<Policy> {
158-
val url = APIEndpoint.policies(configuration.environment)
159-
val credentials = "${configuration.clientId}:${configuration.clientSecret ?: ""}"
160-
val encoded = java.util.Base64.getEncoder()
161-
.encodeToString(credentials.toByteArray(Charsets.UTF_8))
162-
val headers = mapOf("Authorization" to "Basic $encoded")
163-
164-
val response = try {
165-
httpClient.get(url, headers)
166-
} catch (e: IDmeAuthError) {
167-
throw e
168-
} catch (e: Exception) {
169-
throw IDmeAuthError.NetworkError(e.localizedMessage ?: "Unknown error")
170-
}
171-
172-
if (response.statusCode !in 200..299) {
173-
throw IDmeAuthError.UnexpectedResponse(response.statusCode)
174-
}
175-
176-
return try {
177-
json.decodeFromString<List<Policy>>(response.body)
178-
} catch (e: Exception) {
179-
throw IDmeAuthError.DecodingFailed(e.localizedMessage ?: "Unknown error")
180-
}
181-
}
182-
183147
// MARK: - User Info
184148

185149
/**

sdk/src/main/kotlin/com/idme/auth/auth/TokenExchangeRequest.kt

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
package com.idme.auth.auth
22

33
import com.idme.auth.configuration.IDmeConfiguration
4+
import com.idme.auth.configuration.IDmeScope
45
import com.idme.auth.errors.IDmeAuthError
56
import com.idme.auth.models.TokenResponse
67
import com.idme.auth.networking.APIEndpoint
78
import com.idme.auth.networking.DefaultHTTPClient
89
import com.idme.auth.networking.HTTPClient
10+
import com.idme.auth.utilities.Log
911
import kotlinx.serialization.json.Json
1012

1113
/** Handles the OAuth token exchange (authorization code -> tokens). */
@@ -23,7 +25,8 @@ class TokenExchangeRequest(
2325
"grant_type" to "authorization_code",
2426
"code" to code,
2527
"redirect_uri" to configuration.redirectURI,
26-
"client_id" to configuration.clientId
28+
"client_id" to configuration.clientId,
29+
"scope" to IDmeScope.authorizeString(configuration.scopes)
2730
)
2831

2932
if (codeVerifier != null) {
@@ -34,6 +37,9 @@ class TokenExchangeRequest(
3437
body["client_secret"] = configuration.clientSecret
3538
}
3639

40+
Log.debug("Token exchange URL: $tokenURL")
41+
Log.debug("Token exchange body: $body")
42+
3743
val response = try {
3844
httpClient.postForm(tokenURL, body)
3945
} catch (e: IDmeAuthError) {

sdk/src/main/kotlin/com/idme/auth/configuration/IDmeScope.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ enum class IDmeScope(val value: String) {
77
PROFILE("profile"),
88
EMAIL("email"),
99

10+
// Standard verification scopes
11+
LOGIN("login"),
12+
NIST_AAL2_IAL2("http://idmanagement.gov/ns/assurance/ial/2/aal/2"),
13+
1014
// ID.me verification scopes
1115
MILITARY("military"),
1216
FIRST_RESPONDER("first_responder"),

sdk/src/main/kotlin/com/idme/auth/models/Policy.kt

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,7 @@ package com.idme.auth.models
22

33
import kotlinx.serialization.Serializable
44

5-
/**
6-
* A verification policy available for the organization.
7-
*
8-
* Returned by the `/api/public/v3/policies` endpoint.
9-
* The policy `handle` is used as the OAuth `scope` parameter.
10-
*/
5+
/** A verification policy. The `handle` is used as the OAuth `scope` parameter. */
116
@Serializable
127
data class Policy(
138
/** Human-readable name (e.g. "Military Verification"). */

sdk/src/main/kotlin/com/idme/auth/networking/APIEndpoint.kt

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,6 @@ object APIEndpoint {
2525
fun attributes(environment: IDmeEnvironment): String =
2626
"${environment.apiBaseURL}api/public/v3/attributes.json"
2727

28-
/** Policies endpoint. */
29-
fun policies(environment: IDmeEnvironment): String =
30-
"${environment.apiBaseURL}api/public/v3/policies"
31-
3228
/** OIDC discovery endpoint. */
3329
fun discovery(environment: IDmeEnvironment): String =
3430
environment.discoveryURL

sdk/src/main/kotlin/com/idme/auth/networking/HTTPClient.kt

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -52,10 +52,9 @@ class DefaultHTTPClient : HTTPClient {
5252
"${java.net.URLEncoder.encode(key, "UTF-8")}=${java.net.URLEncoder.encode(value, "UTF-8")}"
5353
}
5454

55-
OutputStreamWriter(connection.outputStream).use { writer ->
56-
writer.write(bodyString)
57-
writer.flush()
58-
}
55+
val bodyBytes = bodyString.toByteArray(Charsets.UTF_8)
56+
connection.setRequestProperty("Content-Length", bodyBytes.size.toString())
57+
connection.outputStream.write(bodyBytes)
5958

6059
readResponse(connection)
6160
} catch (e: IDmeAuthError) {

0 commit comments

Comments
 (0)