diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 711cadf..9878cf2 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -55,6 +55,7 @@ dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
implementation("androidx.browser:browser:1.7.0")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
+ implementation("com.journeyapps:zxing-android-embedded:4.1.0")
testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.1.5")
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index edc567d..46af155 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -1,32 +1,35 @@
+ xmlns:tools="http://schemas.android.com/tools">
-
+
+ android:allowBackup="true"
+ android:dataExtractionRules="@xml/data_extraction_rules"
+ android:fullBackupContent="@xml/backup_rules"
+ android:icon="@mipmap/ic_launcher"
+ android:label="@string/app_name_short"
+ android:roundIcon="@mipmap/ic_launcher_round"
+ android:supportsRtl="true"
+ android:theme="@style/AppTheme"
+ android:usesCleartextTraffic="true"
+ tools:targetApi="31">
+ android:name=".DeviceLoginActivity"
+ android:exported="false"/>
+
-
+
-
+
-
-
+
-
\ No newline at end of file
+
diff --git a/app/src/main/java/io/fusionauth/sdk/DeviceLoginActivity.kt b/app/src/main/java/io/fusionauth/sdk/DeviceLoginActivity.kt
new file mode 100644
index 0000000..6d06af4
--- /dev/null
+++ b/app/src/main/java/io/fusionauth/sdk/DeviceLoginActivity.kt
@@ -0,0 +1,120 @@
+package io.fusionauth.sdk
+
+import android.content.Intent
+import android.os.Bundle
+import android.util.Log
+import android.view.View
+import android.widget.ImageView
+import android.widget.TextView
+import androidx.annotation.MainThread
+import androidx.appcompat.app.AppCompatActivity
+import androidx.lifecycle.lifecycleScope
+import com.google.zxing.BarcodeFormat
+import com.journeyapps.barcodescanner.BarcodeEncoder
+import io.fusionauth.mobilesdk.AuthorizationManager
+import io.fusionauth.mobilesdk.ExperimentalApi
+import io.fusionauth.mobilesdk.exceptions.AuthorizationException
+import kotlinx.coroutines.launch
+
+/**
+ * Demonstrates the usage of the FusionAuth SDK to authorize a user utilizing the Device Authorization
+ * Grant. This is useful for devices that do not have a browser or other input mechanism.
+ */
+class DeviceLoginActivity : AppCompatActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ setContentView(R.layout.activity_device_login)
+
+ findViewById(R.id.device_login).setOnClickListener {
+ startAuth()
+ }
+
+ displayAuthOptions()
+ }
+
+ @OptIn(ExperimentalApi::class)
+ @MainThread
+ fun startAuth() {
+ displayLoading("Starting device authorization request")
+
+ lifecycleScope.launch {
+ try {
+ val deviceCodeResponse = AuthorizationManager
+ .oAuth(this@DeviceLoginActivity)
+ .deviceAuthorize()
+
+ findViewById(R.id.device_code_code).let {
+ (it as TextView).text = deviceCodeResponse.user_code
+ }
+ findViewById(R.id.device_code_link).let {
+ (it as TextView).text = deviceCodeResponse.verification_uri
+ }
+
+ BarcodeEncoder().encodeBitmap(
+ deviceCodeResponse.verification_uri_complete,
+ BarcodeFormat.QR_CODE,
+ QR_CODE_DIMENSION, QR_CODE_DIMENSION
+ )
+ .let { bitmap ->
+ findViewById(R.id.device_code_qr).setImageBitmap(bitmap)
+ }
+
+ displayDeviceCode()
+
+ displayLoading("Polling for authorization")
+
+ val authState = AuthorizationManager
+ .oAuth(this@DeviceLoginActivity)
+ .getDeviceFusionAuthState(deviceCodeResponse)
+
+ // Is logged in!
+ startActivity(Intent(this@DeviceLoginActivity, TokenActivity::class.java))
+ } catch (e: AuthorizationException) {
+ Log.e(DeviceLoginActivity.TAG, "Error while authorizing", e)
+ displayError(e.message ?: "Error while authorizing", true)
+ }
+ }
+ }
+
+ @MainThread
+ private fun displayLoading(loadingMessage: String) {
+ findViewById(R.id.loading_container).visibility = View.VISIBLE
+ findViewById(R.id.auth_container).visibility = View.GONE
+ findViewById(R.id.error_container).visibility = View.GONE
+
+ (findViewById(R.id.loading_description) as TextView).text = loadingMessage
+ }
+
+ @MainThread
+ private fun displayError(error: String, recoverable: Boolean) {
+ findViewById(R.id.error_container).visibility = View.VISIBLE
+ findViewById(R.id.loading_container).visibility = View.GONE
+ findViewById(R.id.auth_container).visibility = View.GONE
+
+ (findViewById(R.id.error_description) as TextView).text = error
+ findViewById(R.id.retry).visibility = if (recoverable) View.VISIBLE else View.GONE
+ }
+
+ @MainThread
+ private fun displayAuthOptions() {
+ findViewById(R.id.device_code_container).visibility = View.GONE
+ findViewById(R.id.auth_container).visibility = View.VISIBLE
+ findViewById(R.id.loading_container).visibility = View.GONE
+ findViewById(R.id.error_container).visibility = View.GONE
+ }
+
+ @MainThread
+ private fun displayDeviceCode() {
+ findViewById(R.id.device_code_container).visibility = View.VISIBLE
+ findViewById(R.id.auth_container).visibility = View.GONE
+ findViewById(R.id.loading_container).visibility = View.GONE
+ findViewById(R.id.error_container).visibility = View.GONE
+ }
+
+ companion object {
+ private const val TAG = "DeviceLoginActivity"
+ private const val QR_CODE_DIMENSION = 512
+ }
+
+}
diff --git a/app/src/main/java/io/fusionauth/sdk/LoginActivity.kt b/app/src/main/java/io/fusionauth/sdk/LoginActivity.kt
index 4636482..27be324 100644
--- a/app/src/main/java/io/fusionauth/sdk/LoginActivity.kt
+++ b/app/src/main/java/io/fusionauth/sdk/LoginActivity.kt
@@ -24,8 +24,8 @@ import androidx.lifecycle.lifecycleScope
import com.google.android.material.snackbar.Snackbar
import io.fusionauth.mobilesdk.AuthorizationConfiguration
import io.fusionauth.mobilesdk.AuthorizationManager
-import io.fusionauth.mobilesdk.oauth.OAuthAuthorizeOptions
import io.fusionauth.mobilesdk.exceptions.AuthorizationException
+import io.fusionauth.mobilesdk.oauth.OAuthAuthorizeOptions
import io.fusionauth.mobilesdk.storage.SharedPreferencesStorage
import kotlinx.coroutines.launch
@@ -69,6 +69,9 @@ class LoginActivity : AppCompatActivity() {
findViewById(R.id.start_auth).setOnClickListener {
startAuth()
}
+ findViewById(R.id.device_login).setOnClickListener {
+ startActivity(Intent(this, DeviceLoginActivity::class.java))
+ }
if (AuthorizationManager.oAuth(this@LoginActivity).isCancelled(intent)) {
displayAuthCancelled()
diff --git a/app/src/main/res/layout/activity_device_login.xml b/app/src/main/res/layout/activity_device_login.xml
new file mode 100644
index 0000000..dfa81dc
--- /dev/null
+++ b/app/src/main/res/layout/activity_device_login.xml
@@ -0,0 +1,210 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/activity_login.xml b/app/src/main/res/layout/activity_login.xml
index 66c1b12..3c3e775 100644
--- a/app/src/main/res/layout/activity_login.xml
+++ b/app/src/main/res/layout/activity_login.xml
@@ -90,7 +90,7 @@
android:layout_height="match_parent"
android:layout_marginTop="@dimen/section_margin"
android:layout_marginBottom="8dp"
- android:orientation="vertical">
+ android:orientation="horizontal" android:gravity="center">
-
+ android:layout_gravity="center"/>
@@ -134,4 +137,4 @@
-
\ No newline at end of file
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 5fc39c8..90aa03f 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -17,4 +17,11 @@
0.00
Make change
We can make change for %1$s with %2$d nickels and %3$d pennies!
+ Device Login
+ QR Code with link to user code login
+ Sign in to activate your device
+ To complete activation, navigate to the following site and enter the
+ following activation code:
+
+ Or scan the QR code below to log in.
diff --git a/library/src/main/java/io/fusionauth/mobilesdk/ExperimentalApi.kt b/library/src/main/java/io/fusionauth/mobilesdk/ExperimentalApi.kt
new file mode 100644
index 0000000..e213bac
--- /dev/null
+++ b/library/src/main/java/io/fusionauth/mobilesdk/ExperimentalApi.kt
@@ -0,0 +1,12 @@
+package io.fusionauth.mobilesdk
+
+/**
+ * Marks the API as experimental and may be subject to change in the future.
+ */
+@RequiresOptIn(
+ message = "This API is experimental and may be subject to change in the future.",
+ level = RequiresOptIn.Level.WARNING
+)
+@Retention(AnnotationRetention.BINARY)
+@Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION)
+annotation class ExperimentalApi
diff --git a/library/src/main/java/io/fusionauth/mobilesdk/oauth/OAuthAuthorizationService.kt b/library/src/main/java/io/fusionauth/mobilesdk/oauth/OAuthAuthorizationService.kt
index 00862d4..fdf1463 100644
--- a/library/src/main/java/io/fusionauth/mobilesdk/oauth/OAuthAuthorizationService.kt
+++ b/library/src/main/java/io/fusionauth/mobilesdk/oauth/OAuthAuthorizationService.kt
@@ -6,6 +6,7 @@ import android.content.Intent
import android.net.Uri
import android.os.Bundle
import io.fusionauth.mobilesdk.AuthorizationManager
+import io.fusionauth.mobilesdk.ExperimentalApi
import io.fusionauth.mobilesdk.FusionAuthState
import io.fusionauth.mobilesdk.SingletonUnsecureConnectionBuilder
import io.fusionauth.mobilesdk.TokenManager
@@ -16,6 +17,14 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.cancellable
+import kotlinx.coroutines.flow.catch
+import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.flow.flowOn
+import kotlinx.coroutines.flow.retry
+import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.json.Json
@@ -34,6 +43,8 @@ import net.openid.appauth.TokenResponse
import net.openid.appauth.connectivity.ConnectionBuilder
import net.openid.appauth.connectivity.DefaultConnectionBuilder
import java.net.HttpURLConnection
+import java.net.URLEncoder
+import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicReference
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
@@ -228,6 +239,130 @@ class OAuthAuthorizationService internal constructor(
return intent.getBooleanExtra(EXTRA_AUTHORIZED, false)
}
+ /**
+ * Initiates the device authorization process with the OAuth authorization service.
+ *
+ * @throws AuthorizationException if the device authorization is not supported by the service.
+ * @see OAuthDeviceAuthorizationResponse
+ */
+ @ExperimentalApi
+ @OptIn(ExperimentalSerializationApi::class)
+ suspend fun deviceAuthorize(): OAuthDeviceAuthorizationResponse {
+ val config = getConfiguration()
+
+ return withContext(defaultDispatcher) {
+ // If the endpoint allows device authorization, discovery doc must contain the device_authorization_endpoint
+ val deviceAuthorizationEndpoint = config.discoveryDoc?.docJson?.getString("device_authorization_endpoint")
+ ?: throw AuthorizationException("Device authorization is not supported")
+
+ val uri = Uri.parse(deviceAuthorizationEndpoint)
+ .buildUpon()
+ .appendQueryParameter("client_id", clientId)
+ .appendQueryParameter("scope", scopes)
+ .build()
+
+ val conn: HttpURLConnection = getConnectionBuilder()
+ .openConnection(uri)
+ conn.requestMethod = "POST"
+ conn.setRequestProperty("Content-Type", "application/x-www-form-urlencoded")
+ conn.instanceFollowRedirects = false
+
+ json.decodeFromStream(conn.inputStream)
+ }
+ }
+
+ /**
+ * Retrieves the FusionAuthState for the given OAuthDeviceAuthorizationResponse by performing device authorization
+ * polling.
+ *
+ * @param response The OAuthDeviceAuthorizationResponse received from the device authorization process.
+ * @return The FusionAuthState object that contains the access token, access token expiration time, and id token.
+ */
+ @ExperimentalApi
+ suspend fun getDeviceFusionAuthState(response: OAuthDeviceAuthorizationResponse): FusionAuthState {
+ val flow = deviceAuthorizePolling(response)
+
+ return suspendCoroutine { continuation ->
+ CoroutineScope(defaultDispatcher).launch {
+ flow.collect {
+ continuation.resume(it)
+ }
+ flow.catch {
+ continuation.resumeWithException(it)
+ }
+ }
+ }
+ }
+
+ /**
+ * Retrieves the FusionAuthState for the given OAuthDeviceAuthorizationResponse by performing device authorization
+ * polling.
+ *
+ * @param response The OAuthDeviceAuthorizationResponse received from the device authorization process.
+ * @return The FusionAuthState object that contains the access token, access token expiration time, and id token.
+ */
+ @OptIn(ExperimentalSerializationApi::class)
+ private suspend fun deviceAuthorizePolling(response: OAuthDeviceAuthorizationResponse): Flow {
+ val config = getConfiguration()
+
+ return flow {
+ val conn: HttpURLConnection = config.discoveryDoc?.tokenEndpoint?.let {
+ getConnectionBuilder().openConnection(it)
+ } ?: return@flow
+
+ conn.setRequestProperty("Content-Type", "application/x-www-form-urlencoded")
+ conn.requestMethod = "POST"
+ conn.instanceFollowRedirects = false
+
+ val additionalParameters = mutableMapOf(
+ "client_id" to clientId,
+ "device_code" to response.device_code,
+ "grant_type" to "urn:ietf:params:oauth:grant-type:device_code",
+ )
+
+ val postData = additionalParameters
+ .map { (k, v) -> URLEncoder.encode(k, "UTF-8") + "=" + URLEncoder.encode(v, "UTF-8") }
+ .joinToString("&")
+
+ conn.outputStream.use { os ->
+ os.write(postData.toByteArray())
+ }
+
+ if (conn.responseCode == HttpURLConnection.HTTP_OK) {
+ val tokenResponse = json.decodeFromStream(conn.inputStream)
+
+ val authState = FusionAuthState(
+ accessToken = tokenResponse.access_token,
+ accessTokenExpirationTime = System.currentTimeMillis() +
+ TimeUnit.SECONDS.toMillis(tokenResponse.expires_in),
+ idToken = tokenResponse.id_token,
+ refreshToken = tokenResponse.refresh_token,
+ )
+ tokenManager?.saveAuthState(authState)
+ emit(authState)
+ } else if (conn.responseCode == HttpURLConnection.HTTP_BAD_REQUEST) {
+ // Try to parse the error response
+ val errorResponse = json.decodeFromStream(conn.errorStream)
+
+ if (errorResponse.error == "authorization_pending") {
+ throw AuthorizationPendingException()
+ } else {
+ throw AuthorizationException(errorResponse.error_description)
+ }
+ }
+ }
+ .cancellable()
+ .retry((response.expires_in / response.interval)) { cause ->
+ if (cause is AuthorizationPendingException) {
+ delay(TimeUnit.SECONDS.toMillis(response.interval))
+ return@retry true
+ } else {
+ return@retry false
+ }
+ }
+ .flowOn(defaultDispatcher)
+ }
+
/**
* Retrieves the user information for the authenticated user.
*
@@ -562,4 +697,5 @@ class OAuthAuthorizationService internal constructor(
private val EXTRAS = setOf(EXTRA_STATE, EXTRA_CANCELLED, EXTRA_AUTHORIZED, EXTRA_LOGGED_OUT)
}
+ class AuthorizationPendingException : Exception("Authorization pending")
}
diff --git a/library/src/main/java/io/fusionauth/mobilesdk/oauth/OAuthDeviceAuthorizationResponse.kt b/library/src/main/java/io/fusionauth/mobilesdk/oauth/OAuthDeviceAuthorizationResponse.kt
new file mode 100644
index 0000000..145f3bd
--- /dev/null
+++ b/library/src/main/java/io/fusionauth/mobilesdk/oauth/OAuthDeviceAuthorizationResponse.kt
@@ -0,0 +1,17 @@
+package io.fusionauth.mobilesdk.oauth
+
+import kotlinx.serialization.Serializable
+
+/**
+ * Represents the device authorization response retrieved from FusionAuth.
+ */
+@Suppress("PropertyName", "ConstructorParameterNaming")
+@Serializable
+data class OAuthDeviceAuthorizationResponse (
+ val device_code: String,
+ val expires_in: Long,
+ val interval: Long,
+ val user_code: String,
+ val verification_uri: String,
+ val verification_uri_complete: String,
+)
diff --git a/library/src/main/java/io/fusionauth/mobilesdk/oauth/OAuthErrorResponse.kt b/library/src/main/java/io/fusionauth/mobilesdk/oauth/OAuthErrorResponse.kt
new file mode 100644
index 0000000..66d525a
--- /dev/null
+++ b/library/src/main/java/io/fusionauth/mobilesdk/oauth/OAuthErrorResponse.kt
@@ -0,0 +1,20 @@
+package io.fusionauth.mobilesdk.oauth
+
+import kotlinx.serialization.Serializable
+
+/**
+ * Represents an OAuth error response.
+ *
+ * @property error The error code.
+ * @property error_description A human-readable description of the error.
+ * @property error_reason The reason for the error, if available.
+ * @property state The state parameter, if provided in the request.
+ */
+@Suppress("PropertyName", "ConstructorParameterNaming")
+@Serializable
+data class OAuthErrorResponse(
+ val error: String,
+ val error_description: String,
+ val error_reason: String? = null,
+ val state: String? = null
+)
diff --git a/library/src/main/java/io/fusionauth/mobilesdk/oauth/OAuthTokenResponse.kt b/library/src/main/java/io/fusionauth/mobilesdk/oauth/OAuthTokenResponse.kt
new file mode 100644
index 0000000..81af227
--- /dev/null
+++ b/library/src/main/java/io/fusionauth/mobilesdk/oauth/OAuthTokenResponse.kt
@@ -0,0 +1,30 @@
+package io.fusionauth.mobilesdk.oauth
+
+import kotlinx.serialization.Serializable
+
+/**
+ * Represents the response received from the OAuth token request.
+ *
+ * See [FusionAuth OAuth Endpoints](https://fusionauth.io/docs/lifecycle/authenticate-users/oauth/endpoints#response-3)
+ *
+ * @property access_token The access token.
+ * @property expires_in The expiration time of the access token in seconds.
+ * @property id_token The ID token.
+ * @property refresh_token The refresh token.
+ * @property refresh_token_id The ID of the refresh token.
+ * @property scope The scope of the access token.
+ * @property token_type The token type as defined by RFC 6749 Section 7.1. This value will always be Bearer.
+ * @property userId The unique Id of the user that has been authenticated.
+ */
+@Suppress("PropertyName", "ConstructorParameterNaming")
+@Serializable
+data class OAuthTokenResponse(
+ val access_token: String,
+ val expires_in: Long,
+ val id_token: String,
+ val refresh_token: String,
+ val refresh_token_id: String,
+ val scope: String,
+ val token_type: String,
+ val userId: String,
+)