Skip to content

Commit 419b92d

Browse files
committed
fixes
1 parent 4adde09 commit 419b92d

5 files changed

Lines changed: 38 additions & 18 deletions

File tree

samples/android/manifest.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ scenarios:
2929
app_type: mobile_desktop
3030
display_name: "Token Refresh"
3131
grant: "Refresh Token"
32-
description: "Use refresh tokens to renew access tokens without re-prompting. Requires offline_access. Refresh tokens are stored in Android EncryptedSharedPreferences (Keystore-backed)."
32+
description: "Use refresh tokens to renew access tokens without re-prompting. Requires offline_access. Refresh tokens are stored in platform secure storage (Keychain / EncryptedSharedPreferences)."
3333
callout: "Requires refresh_token grant type enabled in workspace [OAuth settings](workspace-oauth-general) and in the application's [Grant Types](workspace-applications-oauth). Also requires the offline_access scope."
3434
run_command: "./gradlew :app:installDebug"
3535
required_scopes:

samples/android/token-refresh/app/src/main/AndroidManifest.xml

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,7 @@
88
android:name=".QuickstartApp"
99
android:label="@string/app_name"
1010
android:theme="@android:style/Theme.DeviceDefault.Light.NoActionBar"
11-
android:allowBackup="false"
12-
tools:targetApi="33">
11+
android:allowBackup="false">
1312

1413
<activity
1514
android:name=".MainActivity"

samples/android/token-refresh/app/src/main/java/com/secureauth/quickstart/AuthViewModel.kt

Lines changed: 31 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -115,9 +115,10 @@ class AuthViewModel(app: Application) : AndroidViewModel(app) {
115115
)
116116
.setScopes(AuthConfig.scopes)
117117
.build()
118+
val intent = authService.getAuthorizationRequestIntent(request)
118119
pendingAuthState = AuthState(config)
119-
authService.getAuthorizationRequestIntent(request)
120-
} catch (e: Throwable) {
120+
intent
121+
} catch (e: Exception) {
121122
_error.value = e.localizedMessage ?: "Authorization failed"
122123
null
123124
}
@@ -132,6 +133,7 @@ class AuthViewModel(app: Application) : AndroidViewModel(app) {
132133
val ex = AuthorizationException.fromIntent(intent)
133134
val pending = pendingAuthState
134135
if (response == null || pending == null) {
136+
pendingAuthState = null
135137
_error.value = ex?.localizedMessage ?: "Authorization failed"
136138
return
137139
}
@@ -142,9 +144,9 @@ class AuthViewModel(app: Application) : AndroidViewModel(app) {
142144
pending.update(tokenResponse, null)
143145
val next = toTokens(tokenResponse, fallback = null)
144146
_tokens.value = next
145-
next.refreshToken?.let { store.save(it) }
147+
next.refreshToken?.let { persistRefreshToken(it) }
146148
scheduleRefreshTimer()
147-
} catch (e: Throwable) {
149+
} catch (e: Exception) {
148150
_error.value = e.localizedMessage ?: "Token exchange failed"
149151
} finally {
150152
pendingAuthState = null
@@ -158,6 +160,9 @@ class AuthViewModel(app: Application) : AndroidViewModel(app) {
158160
_error.value = null
159161
val issuerJavaUri = AuthConfig.issuer
160162
?: run { _error.value = "CIAM_ISSUER_HOST is missing — fill in local.properties"; return }
163+
if (AuthConfig.clientId.isEmpty()) {
164+
_error.value = "CIAM_CLIENT_ID is missing — fill in local.properties"; return
165+
}
161166
val storedRefresh = _tokens.value?.refreshToken?.takeIf { it.isNotEmpty() }
162167
?: store.load()
163168
?: run { _error.value = "No refresh token available. Sign in first."; return }
@@ -172,10 +177,10 @@ class AuthViewModel(app: Application) : AndroidViewModel(app) {
172177
_tokens.value = next
173178
// If the IdP rotated the refresh token, persist the new one.
174179
if (next.refreshToken != null && next.refreshToken != storedRefresh) {
175-
store.save(next.refreshToken)
180+
persistRefreshToken(next.refreshToken)
176181
}
177182
scheduleRefreshTimer()
178-
} catch (e: Throwable) {
183+
} catch (e: Exception) {
179184
// Refresh tokens can be revoked or expire — clear local state and force re-login.
180185
store.clear()
181186
_tokens.value = null
@@ -187,7 +192,7 @@ class AuthViewModel(app: Application) : AndroidViewModel(app) {
187192

188193
suspend fun signOut() {
189194
_tokens.value?.accessToken?.let { token ->
190-
try { revokeToken(token) } catch (_: Throwable) { /* best-effort */ }
195+
try { revokeToken(token) } catch (_: Exception) { /* best-effort */ }
191196
}
192197
store.clear()
193198
refreshJob?.cancel()
@@ -202,6 +207,7 @@ class AuthViewModel(app: Application) : AndroidViewModel(app) {
202207
if (_tokens.value != null) return
203208
val stored = store.load()?.takeIf { it.isNotEmpty() } ?: return
204209
val issuerJavaUri = AuthConfig.issuer ?: return
210+
if (AuthConfig.clientId.isEmpty()) return
205211
try {
206212
val config = discoverConfiguration(Uri.parse(issuerJavaUri.toString()))
207213
val request = TokenRequest.Builder(config, AuthConfig.clientId)
@@ -214,10 +220,10 @@ class AuthViewModel(app: Application) : AndroidViewModel(app) {
214220
val next = toTokens(response, fallback = null)
215221
_tokens.value = next
216222
if (next.refreshToken != null && next.refreshToken != stored) {
217-
store.save(next.refreshToken)
223+
persistRefreshToken(next.refreshToken)
218224
}
219225
scheduleRefreshTimer()
220-
} catch (_: Throwable) {
226+
} catch (_: Exception) {
221227
// Stored token is no longer valid — let the user sign in.
222228
store.clear()
223229
}
@@ -242,6 +248,18 @@ class AuthViewModel(app: Application) : AndroidViewModel(app) {
242248
}
243249
}
244250

251+
/** Save the refresh token to EncryptedSharedPreferences. If the write fails (e.g.,
252+
* Keystore unavailable), surface a warning so the user knows the README's silent
253+
* re-login promise won't hold on next launch — but keep the in-memory token so
254+
* the current session still refreshes the access token. */
255+
private fun persistRefreshToken(refresh: String) {
256+
try {
257+
store.save(refresh)
258+
} catch (e: Exception) {
259+
_error.value = "Sign-in succeeded but refresh token could not be saved to secure storage (${e.localizedMessage}). Silent re-login on next launch will not work."
260+
}
261+
}
262+
245263
/** Project an AppAuth TokenResponse onto our flat Tokens data class. Refresh
246264
* responses sometimes omit a fresh refresh_token or id_token — when missing,
247265
* fall back to the previously-known values so display state stays populated. */
@@ -283,13 +301,16 @@ class AuthViewModel(app: Application) : AndroidViewModel(app) {
283301
val conn = (URL(revokeUrl).openConnection() as HttpURLConnection).apply {
284302
requestMethod = "POST"
285303
doOutput = true
304+
connectTimeout = 5_000
305+
readTimeout = 5_000
286306
setRequestProperty("Content-Type", "application/x-www-form-urlencoded")
287307
}
288308
try {
289309
val body = "token=${URLEncoder.encode(token, "UTF-8")}" +
290310
"&client_id=${URLEncoder.encode(AuthConfig.clientId, "UTF-8")}"
291311
conn.outputStream.use { it.write(body.toByteArray(Charsets.UTF_8)) }
292-
conn.inputStream.use { it.readBytes() }
312+
val stream = if (conn.responseCode in 200..299) conn.inputStream else conn.errorStream
313+
stream?.use { it.readBytes() }
293314
} finally {
294315
conn.disconnect()
295316
}

snippet-manifest.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ scenarios:
8181
grant: Refresh Token
8282
description: >-
8383
Use refresh tokens to renew access tokens without re-prompting. Requires offline_access. Refresh tokens are stored
84-
in Android EncryptedSharedPreferences (Keystore-backed).
84+
in platform secure storage (Keychain / EncryptedSharedPreferences).
8585
required_scopes:
8686
- offline_access
8787
config_rows:

snippets.json

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -155,10 +155,10 @@
155155
{
156156
"step": 3,
157157
"description": "Trigger login with offline_access and capture the refresh token",
158-
"code": " suspend fun signIn(): Intent? {\n _error.value = null\n val issuerJavaUri = AuthConfig.issuer\n ?: run { _error.value = \"CIAM_ISSUER_HOST is missing — fill in local.properties\"; return null }\n if (AuthConfig.clientId.isEmpty()) {\n _error.value = \"CIAM_CLIENT_ID is missing — fill in local.properties\"; return null\n }\n val redirect = AuthConfig.redirectUri\n ?: run { _error.value = \"CIAM_REDIRECT_SCHEME is missing — fill in local.properties\"; return null }\n return try {\n val issuerAndroidUri = Uri.parse(issuerJavaUri.toString())\n val config = discoverConfiguration(issuerAndroidUri)\n val request = AuthorizationRequest.Builder(\n config,\n AuthConfig.clientId,\n ResponseTypeValues.CODE,\n redirect,\n )\n .setScopes(AuthConfig.scopes)\n .build()\n pendingAuthState = AuthState(config)\n authService.getAuthorizationRequestIntent(request)\n } catch (e: Throwable) {\n _error.value = e.localizedMessage ?: \"Authorization failed\"\n null\n }\n }",
158+
"code": " suspend fun signIn(): Intent? {\n _error.value = null\n val issuerJavaUri = AuthConfig.issuer\n ?: run { _error.value = \"CIAM_ISSUER_HOST is missing — fill in local.properties\"; return null }\n if (AuthConfig.clientId.isEmpty()) {\n _error.value = \"CIAM_CLIENT_ID is missing — fill in local.properties\"; return null\n }\n val redirect = AuthConfig.redirectUri\n ?: run { _error.value = \"CIAM_REDIRECT_SCHEME is missing — fill in local.properties\"; return null }\n return try {\n val issuerAndroidUri = Uri.parse(issuerJavaUri.toString())\n val config = discoverConfiguration(issuerAndroidUri)\n val request = AuthorizationRequest.Builder(\n config,\n AuthConfig.clientId,\n ResponseTypeValues.CODE,\n redirect,\n )\n .setScopes(AuthConfig.scopes)\n .build()\n // Assign pendingAuthState only after the Intent is successfully created so\n // a thrown getAuthorizationRequestIntent doesn't leave stale state behind.\n // Catch Exception (not Throwable) so coroutine CancellationException still\n // propagates and structured concurrency works correctly.\n val intent = authService.getAuthorizationRequestIntent(request)\n pendingAuthState = AuthState(config)\n intent\n } catch (e: Exception) {\n _error.value = e.localizedMessage ?: \"Authorization failed\"\n null\n }\n }",
159159
"file": "app/src/main/java/com/secureauth/quickstart/AuthViewModel.kt",
160160
"lang": "kotlin",
161-
"lines": "96-124"
161+
"lines": "96-129"
162162
},
163163
{
164164
"step": 4,
@@ -171,10 +171,10 @@
171171
{
172172
"step": 5,
173173
"description": "Exchange the refresh token for a new access token; on failure clear local state and require re-login",
174-
"code": " suspend fun refreshTokens() {\n _error.value = null\n val issuerJavaUri = AuthConfig.issuer\n ?: run { _error.value = \"CIAM_ISSUER_HOST is missing — fill in local.properties\"; return }\n val storedRefresh = _tokens.value?.refreshToken?.takeIf { it.isNotEmpty() }\n ?: store.load()\n ?: run { _error.value = \"No refresh token available. Sign in first.\"; return }\n try {\n val config = discoverConfiguration(Uri.parse(issuerJavaUri.toString()))\n val request = TokenRequest.Builder(config, AuthConfig.clientId)\n .setGrantType(GrantTypeValues.REFRESH_TOKEN)\n .setRefreshToken(storedRefresh)\n .build()\n val response = performTokenRequest(request)\n val next = toTokens(response, fallback = _tokens.value)\n _tokens.value = next\n // If the IdP rotated the refresh token, persist the new one.\n if (next.refreshToken != null && next.refreshToken != storedRefresh) {\n store.save(next.refreshToken)\n }\n scheduleRefreshTimer()\n } catch (e: Throwable) {\n // Refresh tokens can be revoked or expire — clear local state and force re-login.\n store.clear()\n _tokens.value = null\n refreshJob?.cancel()\n _error.value = \"Refresh failed (${e.localizedMessage}). Sign in again.\"\n }\n }",
174+
"code": " suspend fun refreshTokens() {\n _error.value = null\n val issuerJavaUri = AuthConfig.issuer\n ?: run { _error.value = \"CIAM_ISSUER_HOST is missing — fill in local.properties\"; return }\n if (AuthConfig.clientId.isEmpty()) {\n _error.value = \"CIAM_CLIENT_ID is missing — fill in local.properties\"; return\n }\n val storedRefresh = _tokens.value?.refreshToken?.takeIf { it.isNotEmpty() }\n ?: store.load()\n ?: run { _error.value = \"No refresh token available. Sign in first.\"; return }\n try {\n val config = discoverConfiguration(Uri.parse(issuerJavaUri.toString()))\n val request = TokenRequest.Builder(config, AuthConfig.clientId)\n .setGrantType(GrantTypeValues.REFRESH_TOKEN)\n .setRefreshToken(storedRefresh)\n .build()\n val response = performTokenRequest(request)\n val next = toTokens(response, fallback = _tokens.value)\n _tokens.value = next\n // If the IdP rotated the refresh token, persist the new one.\n if (next.refreshToken != null && next.refreshToken != storedRefresh) {\n persistRefreshToken(next.refreshToken)\n }\n scheduleRefreshTimer()\n } catch (e: Exception) {\n // Refresh tokens can be revoked or expire — clear local state and force re-login.\n store.clear()\n _tokens.value = null\n refreshJob?.cancel()\n _error.value = \"Refresh failed (${e.localizedMessage}). Sign in again.\"\n }\n }",
175175
"file": "app/src/main/java/com/secureauth/quickstart/AuthViewModel.kt",
176176
"lang": "kotlin",
177-
"lines": "155-185"
177+
"lines": "163-196"
178178
}
179179
],
180180
"framework": "android",

0 commit comments

Comments
 (0)