/
AuthenticatorImpl.kt
505 lines (412 loc) · 18.4 KB
/
AuthenticatorImpl.kt
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
package com.authsamples.basicmobileapp.plumbing.oauth
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import com.authsamples.basicmobileapp.configuration.OAuthConfiguration
import com.authsamples.basicmobileapp.plumbing.errors.ErrorCodes
import com.authsamples.basicmobileapp.plumbing.errors.ErrorFactory
import com.authsamples.basicmobileapp.plumbing.oauth.logout.CognitoLogoutUrlBuilder
import com.authsamples.basicmobileapp.plumbing.oauth.logout.LogoutUrlBuilder
import com.authsamples.basicmobileapp.plumbing.oauth.logout.StandardLogoutUrlBuilder
import com.authsamples.basicmobileapp.plumbing.utilities.ConcurrentActionHandler
import net.openid.appauth.AppAuthConfiguration
import net.openid.appauth.AuthorizationException
import net.openid.appauth.AuthorizationRequest
import net.openid.appauth.AuthorizationResponse
import net.openid.appauth.AuthorizationService
import net.openid.appauth.AuthorizationServiceConfiguration
import net.openid.appauth.GrantTypeValues
import net.openid.appauth.NoClientAuthentication
import net.openid.appauth.ResponseTypeValues
import net.openid.appauth.TokenRequest
import net.openid.appauth.TokenResponse
import net.openid.appauth.browser.BrowserAllowList
import net.openid.appauth.browser.BrowserMatcher
import net.openid.appauth.browser.VersionedBrowserMatcher
import net.openid.appauth.connectivity.DefaultConnectionBuilder
import java.util.Locale
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlin.coroutines.suspendCoroutine
/*
* The authenticator class manages integration with the AppAuth libraries
*/
@Suppress("TooManyFunctions")
class AuthenticatorImpl(
private val configuration: OAuthConfiguration,
private val applicationContext: Context
) : Authenticator {
private var metadata: AuthorizationServiceConfiguration? = null
private var loginAuthService: AuthorizationService? = null
private var logoutAuthService: AuthorizationService? = null
private val concurrencyHandler = ConcurrentActionHandler()
private val tokenStorage = TokenStorage(this.applicationContext)
/*
* One time initialization on application startup
*/
override suspend fun initialize() {
// Load OpenID Connect metadata
this.getMetadata()
// Load tokens from storage
this.tokenStorage.loadTokens()
}
/*
* Try to get an access token, which most commonly involves returning the current one
*/
override suspend fun getAccessToken(): String? {
// See if there is a token in storage
val accessToken = this.tokenStorage.getTokens()?.accessToken
if (!accessToken.isNullOrBlank()) {
return accessToken
}
// Indicate no access token
return null
}
/*
* Try to refresh an access token
*/
override suspend fun synchronizedRefreshAccessToken(): String {
val refreshToken = this.tokenStorage.getTokens()?.refreshToken
if (!refreshToken.isNullOrBlank()) {
// Perform token refresh and manage concurrency
this.concurrencyHandler.execute(this::performRefreshTokenGrant)
// Return the token on success
val accessToken = this.tokenStorage.getTokens()?.accessToken
if (!accessToken.isNullOrBlank()) {
return accessToken
}
}
// Otherwise abort the API call via a known exception
throw ErrorFactory().fromLoginRequired()
}
/*
* Do the work to perform an authorization redirect
*/
override fun startLogin(launchAction: (i: Intent) -> Unit) {
try {
val authService = AuthorizationService(this.applicationContext, this.getBrowserConfiguration())
this.loginAuthService = authService
// Create the AppAuth request object and use Authorization Code Flow (PKCE)
// If required, call builder.setAdditionalParameters to supply details such as acr_values
val builder = AuthorizationRequest.Builder(
this.metadata!!,
this.configuration.clientId,
ResponseTypeValues.CODE,
Uri.parse(this.getLoginRedirectUri())
)
.setScope(this.configuration.scope)
val request = builder.build()
// Do the AppAuth redirect
val authIntent = authService.getAuthorizationRequestIntent(request)
launchAction(authIntent)
} catch (ex: Throwable) {
throw ErrorFactory().fromLoginOperationError(ex, ErrorCodes.loginRequestFailed)
}
}
/*
* When a login redirect completes, process the login response here
*/
override suspend fun finishLogin(intent: Intent) {
// Get the response details
val authorizationResponse = AuthorizationResponse.fromIntent(intent)
val ex = AuthorizationException.fromIntent(intent)
// Free custom tab resources after a login
// https://github.com/openid/AppAuth-Android/issues/91
this.loginAuthService?.dispose()
this.loginAuthService = null
when {
ex != null -> {
// Handle the case where the user closes the Chrome Custom Tab rather than logging in
if (ex.type == AuthorizationException.TYPE_GENERAL_ERROR &&
ex.code == AuthorizationException.GeneralErrors.USER_CANCELED_AUTH_FLOW.code
) {
throw ErrorFactory().fromRedirectCancelled()
}
// Translate AppAuth errors to the display format
throw ErrorFactory().fromLoginOperationError(ex, ErrorCodes.loginResponseFailed)
}
authorizationResponse != null -> {
// Swap the authorization code for tokens and update state
this.exchangeAuthorizationCode(authorizationResponse)
}
}
}
/*
* Remove local state and start the logout redirect to remove the OAuth session cookie
*/
override fun startLogout(launchAction: (i: Intent) -> Unit) {
// First force removal of tokens from storage
val tokens = this.tokenStorage.getTokens()
val idToken = tokens?.idToken
this.clearLoginState()
try {
// Fail if there is no id token
if (idToken == null) {
val message = "Logout is not possible because tokens have already been removed"
throw IllegalStateException(message)
}
// Create an object to manage logout and use it to form the end session request
val logoutUrlBuilder = this.createLogoutUrlBuilder()
val logoutUrl = logoutUrlBuilder.getEndSessionRequestUrl(
this.metadata!!,
this.getPostLogoutRedirectUri(),
idToken
)
// Launch the intent to sign the user out
launchAction(this.getLogoutIntent(logoutUrl))
} catch (ex: Throwable) {
throw ErrorFactory().fromLogoutOperationError(ex)
}
}
/*
* Free resources after a logout
* https://github.com/openid/AppAuth-Android/issues/91
*/
override fun finishLogout() {
if (this.logoutAuthService != null) {
this.logoutAuthService?.dispose()
this.logoutAuthService = null
}
}
/*
* Allow the login state to be cleared when required
*/
override fun clearLoginState() {
this.tokenStorage.removeTokens()
}
/*
* For testing, make the access token act like it is expired
*/
override fun expireAccessToken() {
this.tokenStorage.expireAccessToken()
}
/*
* For testing, make the refresh token act like it is expired
*/
override fun expireRefreshToken() {
this.tokenStorage.expireRefreshToken()
}
/*
* Get metadata and convert the callback to a suspendable function
*/
private suspend fun getMetadata() {
// Form the metadata URL
val metadataAddress = "${this.configuration.authority}/.well-known/openid-configuration"
val metadataUri = Uri.parse(metadataAddress)
// Wrap the callback in a coroutine to support cleaner async await based calls
return suspendCoroutine { continuation ->
// Receive the result of the metadata request
val callback =
AuthorizationServiceConfiguration.RetrieveConfigurationCallback { serviceConfiguration, ex ->
when {
// Report errors
ex != null -> {
val error = ErrorFactory().fromMetadataLookupError(ex)
continuation.resumeWithException(error)
}
// Sanity check
serviceConfiguration == null -> {
val error = RuntimeException("Metadata request returned an empty response")
continuation.resumeWithException(error)
}
// Save metadata on success
else -> {
this.metadata = serviceConfiguration
continuation.resume(Unit)
}
}
}
// Trigger the metadata lookup
AuthorizationServiceConfiguration.fetchFromUrl(metadataUri, callback, DefaultConnectionBuilder.INSTANCE)
}
}
/*
* When a login succeeds, exchange the authorization code for tokens
*/
private suspend fun exchangeAuthorizationCode(authResponse: AuthorizationResponse) {
// Wrap the request in a coroutine
return suspendCoroutine { continuation ->
// Define a callback to handle the result of the authorization code grant
val callback =
AuthorizationService.TokenResponseCallback { tokenResponse, ex ->
when {
// Translate AppAuth errors to the display format
ex != null -> {
val error = ErrorFactory().fromTokenError(ex, ErrorCodes.authorizationCodeGrantFailed)
continuation.resumeWithException(error)
}
// Sanity check
tokenResponse == null -> {
val empty = RuntimeException("Authorization code grant returned an empty response")
continuation.resumeWithException(empty)
}
// Process the response by saving tokens to secure storage
else -> {
this.saveTokens(tokenResponse)
continuation.resume(Unit)
}
}
}
// Create the authorization code grant request
val tokenRequest = authResponse.createTokenExchangeRequest()
// Trigger the request
val authService = AuthorizationService(this.applicationContext)
authService.performTokenRequest(tokenRequest, NoClientAuthentication.INSTANCE, callback)
}
}
/*
* Do the work of refreshing an access token
*/
private suspend fun performRefreshTokenGrant() {
// Check we have a refresh token
val refreshToken = this.tokenStorage.getTokens()?.refreshToken
if (refreshToken.isNullOrBlank()) {
return
}
// Wrap the request in a coroutine
return suspendCoroutine { continuation ->
// Define a callback to handle the result of the refresh token grant
val callback =
AuthorizationService.TokenResponseCallback { tokenResponse, ex ->
when {
// Translate AppAuth errors to the display format
ex != null -> {
// If we get an invalid_grant error it means the refresh token has expired
if (ex.type == AuthorizationException.TYPE_OAUTH_TOKEN_ERROR &&
ex.code == AuthorizationException.TokenRequestErrors.INVALID_GRANT.code
) {
// Remove tokens and indicate success, since this is an expected error
// The caller will throw a login required error to redirect the user to login again
this.clearLoginState()
continuation.resume(Unit)
} else {
// Process real errors
val error = ErrorFactory().fromTokenError(ex, ErrorCodes.tokenRenewalError)
continuation.resumeWithException(error)
}
}
// Sanity check
tokenResponse == null -> {
val error = RuntimeException("Refresh token grant returned an empty response")
continuation.resumeWithException(error)
}
// Process the response by saving tokens to secure storage
else -> {
this.saveTokens(tokenResponse)
continuation.resume(Unit)
}
}
}
// Create the refresh token grant request
val tokenRequest = TokenRequest.Builder(
this.metadata!!,
this.configuration.clientId
)
.setGrantType(GrantTypeValues.REFRESH_TOKEN)
.setRefreshToken(refreshToken)
.build()
// Trigger the request
val authService = AuthorizationService(this.applicationContext)
authService.performTokenRequest(tokenRequest, callback)
}
}
/*
* Common handling of token responses, for authorization code grant and refresh token messages
*/
private fun saveTokens(tokenResponse: TokenResponse) {
// Create token data from the response
val tokenData = TokenData()
tokenData.accessToken = tokenResponse.accessToken
tokenData.refreshToken = tokenResponse.refreshToken
tokenData.idToken = tokenResponse.idToken
// The response may have blank values for these tokens
if (tokenData.refreshToken.isNullOrBlank() || tokenData.idToken.isNullOrBlank()) {
// See if there is any existing token data
val oldTokenData = this.tokenStorage.getTokens()
if (oldTokenData != null) {
// Maintain the existing refresh token unless we received a new 'rolling' refresh token
if (tokenData.refreshToken.isNullOrBlank()) {
tokenData.refreshToken = oldTokenData.refreshToken
}
// Maintain the existing id token if required, which may be needed for logout
if (tokenData.idToken.isNullOrBlank()) {
tokenData.idToken = oldTokenData.idToken
}
}
}
this.tokenStorage.saveTokens(tokenData)
}
/*
* Return the URL to the interstitial page used for login redirects
* https://mobile.authsamples.com/mobile/postlogin.html
*/
private fun getLoginRedirectUri(): String {
return "${this.configuration.webBaseUrl}${this.configuration.loginRedirectPath}"
}
/*
* Return the URL to the interstitial page used for logout redirects
* https://web.mobile.authsamples.com/mobile/postlogout.html
*/
private fun getPostLogoutRedirectUri(): String {
return "${this.configuration.webBaseUrl}${this.configuration.postLogoutRedirectPath}"
}
/*
* Return a builder object depending on which of our 2 providers we are using, which have different implementations
*/
private fun createLogoutUrlBuilder(): LogoutUrlBuilder {
return if (this.configuration.authority.lowercase(Locale.ROOT).contains("cognito")) {
CognitoLogoutUrlBuilder(this.configuration)
} else {
StandardLogoutUrlBuilder(this.configuration, this.metadata!!)
}
}
/*
* Create the intent for the logout redirect
*/
private fun getLogoutIntent(logoutUrl: String): Intent {
// Create the auth service and set the browser to use
val authService = AuthorizationService(this.applicationContext, this.getBrowserConfiguration())
this.logoutAuthService = authService
if (this.getBrowser() == VersionedBrowserMatcher.CHROME_CUSTOM_TAB) {
// Start a logout intent on a Chrome Custom tab
val customTabsIntent = authService.customTabManager.createTabBuilder().build()
val logoutIntent = customTabsIntent.intent
logoutIntent.setPackage(authService.browserDescriptor.packageName)
logoutIntent.data = Uri.parse(logoutUrl)
return logoutIntent
} else {
// Start a logout intent in the Chrome browser
val logoutIntent = Intent(Intent.ACTION_VIEW)
logoutIntent.data = Uri.parse(logoutUrl)
return logoutIntent
}
}
/*
* Control the browser to use for login and logout redirects
*/
private fun getBrowserConfiguration(): AppAuthConfiguration {
return AppAuthConfiguration.Builder()
.setBrowserMatcher(BrowserAllowList(this.getBrowser()))
.build()
}
/*
* Android emulators on levels 31 and 32 do not reliably return to the app after custom tab redirects
* Work around this bug using the Chrome system browser until these emulator issues stabilize
*/
private fun getBrowser(): BrowserMatcher {
if (this.isEmulator() && (Build.VERSION.SDK_INT == 31 || Build.VERSION.SDK_INT == 32)) {
return VersionedBrowserMatcher.CHROME_BROWSER
}
return VersionedBrowserMatcher.CHROME_CUSTOM_TAB
}
/*
* Basic detection on whether the app is running on an emulator
*/
private fun isEmulator(): Boolean {
val model = Build.MODEL.lowercase()
val manufacturer = Build.MANUFACTURER.lowercase()
return model.contains("emulator") ||
(model.startsWith("sdk_gphone") && manufacturer.contains("google"))
}
}