09. Native Auth Screens and Laravel Sanctum
- Introduction
- Adding Jetpack Compose
- The Auth Client Service
- The Native Login Screen
- Setting up Sanctum and the API Endpoints
- Testing Out
Introduction
Let's tackle the mobile authentication first. We're now able to use the web authentication flow, but as we discussed earlier, sometimes we need to implement fully native screens in our app that uses JSON endpoints with an Access Token. For that reason, we're gonna change our login flow just for our Turbo Native Android client. If you were building a Native iOS app, that could also use this same flow.
Laravel has essentially two first-party packages when it comes to Token-based authentication: Laravel Sanctum and Laravel Passport.
For this Bootcamp, we're gonna be using Laravel Sanctum, as our flow is a mix of an SPA and mobile authentication flows, both provided by Sanctum.
Oh, and we're going to use Jetpack Compose to build the Login screen. I don't know about you, but building screens in XML ain't I'd be proud to show you. Let's start by adding the Gradle dependencies we need and making some tweaks to our app so Compose works properly.
Adding Jetpack Compose
Open the Module's build.gradle
file and add the following lines to it:
plugins {
id 'com.android.application' id 'org.jetbrains.kotlin.android' } android {
namespace 'com.example.turbochirpernative' compileSdk 33 defaultConfig { applicationId "com.example.turbochirpernative" minSdk 24 targetSdk 33 versionCode 1 versionName "1.0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } buildTypes { release { minifyEnabled false proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' } } compileOptions { sourceCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8 } kotlinOptions { jvmTarget = '1.8' } buildFeatures { viewBinding true compose true } composeOptions { kotlinCompilerExtensionVersion "1.4.0-dev-k1.7.20-RC-a143c065804" } } dependencies { def lifecycle_version = '2.5.1' def compose_version = '1.3.0-rc01'
implementation 'androidx.core:core-ktx:1.9.0' implementation 'androidx.appcompat:appcompat:1.5.1' implementation 'com.google.android.material:material:1.6.1' implementation 'androidx.constraintlayout:constraintlayout:2.1.4' implementation 'dev.hotwire:turbo:7.0.0-rc12' implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version" implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version" implementation "androidx.lifecycle:lifecycle-runtime-ktx:$lifecycle_version" implementation "androidx.compose.ui:ui:$compose_version" implementation "androidx.compose.material:material:$compose_version" implementation "com.squareup.okhttp3:okhttp:4.10.0" implementation "com.google.code.gson:gson:2.9.1" testImplementation 'junit:junit:4.13.2' androidTestImplementation 'androidx.test.ext:junit:1.1.3' androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'}
We added a bunch of dependencies here, let's dissect that:
- The
androidx.compose.ui:ui:*
one is the main Jetpack Compose package - The
androidx.compose.material:material:*
comes with a set of Material UI components we can use - The
com.squareup.okhttp3:okhttp:4.10.0
is the lib we're gonna use to make HTTP requests from our Native screens - The
com.google.code.gson:gson:2.9.1
is the lib we're gonna use to serialize our JSON responses to objects we can use in Kotlin
We also made some changes to the buildFeatures
to enable compose and also configured the composeOptions
to use a specific version Jetcompose Compose Compiler to work with the Kotlin version I have (which is 1.7.20-RC
). I was getting weird compilation errors without this. It was after many attempts that I found this fix (again, I'm no mobile Android expert). If you happen to use a different Kotlin version, make sure you visit the compatibility table to see which version of the Compose Compiler you need.
For that compiler customization to work, we need to make a change to our settings.gradle
file to add the compose compiler's repository to Maven:
pluginManagement {
repositories { gradlePluginPortal() google() mavenCentral() } }dependencyResolutionManagement { repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) repositories { google() mavenCentral() maven { url "https://androidx.dev/storage/compose-compiler/repository/" } }}
rootProject.name = "Turbo Chirper Native"include ':app'
Okay, now press that "Sync now" link at the top and fingers crossed!
The Auth Client Service
Now that we have all the libs in place, let's start sketching out how this is gonna work.
As mentioned earlier, we're gonna need both the Cookie AND the Token authentication in our case. We need the Cookie-based authentication for our shared WebView while the Access Token we'll store in our app settings so we can reuse it whenever we need to build a fully native screen.
We'll handle the Laravel side of this soon. Add these new constants to our util.Constants
file:
package com.example.turbochirpernative.util const val BASE_URL = "http://10.0.2.2"const val CHIRPS_HOME_URL = "$BASE_URL/chirps" const val API_BASE_URL = "$BASE_URL/api"const val API_CSRF_COOKIES_URL = "$BASE_URL/sanctum/csrf-cookie"const val API_LOGIN_URL = "$API_BASE_URL/login"
Next, let's create our AuthClient
to interact with the API. First, add an "api" package to the root of the project (next to the "features" package) by right-clicking on the root package then choosing "New > Package". Inside of it, add a new Kotlin class:
package com.example.turbochirpernative.api import android.util.Logimport android.webkit.CookieManagerimport com.example.turbochirpernative.util.API_CSRF_COOKIES_URLimport com.example.turbochirpernative.util.API_LOGIN_URLimport com.example.turbochirpernative.util.BASE_URLimport com.google.gson.Gsonimport com.google.gson.JsonObjectimport okhttp3.*import okhttp3.HttpUrl.Companion.toHttpUrlimport okhttp3.RequestBody.Companion.toRequestBodyimport okio.IOExceptionimport java.net.URLDecoder class AuthClient() { private var CLIENT_USER_AGENT = "Turbo Native Android HTTP Client" private var csrfToken: String = "" private var csrfCookieStored: String = "" private var sessionCookieStored: String = "" @Throws(IOException::class) fun fetchCsrfToken(onCsrfTokenFetched: () -> Unit) { val client = OkHttpClient() val request: Request = Request.Builder() .url(API_CSRF_COOKIES_URL) .addHeader("Content-Type", "application/json; charset=utf-8") .addHeader("Accept", "application/json; charset=utf-8") .addHeader("User-Agent", CLIENT_USER_AGENT) .get() .build() client.newCall(request).enqueue(object: Callback { override fun onFailure(call: Call, e: java.io.IOException) { e.printStackTrace() } override fun onResponse(call: Call, response: Response) { response.use { val cookieManager = CookieManager.getInstance() val cookies = Cookie.parseAll(BASE_URL.toHttpUrl(), it.headers) cookies.forEach { cookie -> if (cookie.name == "XSRF-TOKEN") { csrfToken = URLDecoder.decode(cookie.value.toString(), Charsets.UTF_8.name()) csrfCookieStored = cookie.toString() } else if (cookie.name == "turbo_chirp_native_session") { sessionCookieStored = cookie.toString() } cookieManager.setCookie(BASE_URL, cookie.toString()) } } onCsrfTokenFetched() } }) } @Throws(IOException::class) fun attempt(email: String, password: String, onResponse: (AuthResponse) -> Unit) { val client = OkHttpClient() val body = JsonObject() body.addProperty("email", email) body.addProperty("password", password) val request: Request = Request.Builder() .url(API_LOGIN_URL) .addHeader("Content-Type", "application/json; charset=utf-8") .addHeader("Accept", "application/json; charset=utf-8") .addHeader("User-Agent", CLIENT_USER_AGENT) .addHeader("X-XSRF-TOKEN", csrfToken) .addHeader("Cookie", "$sessionCookieStored; $csrfCookieStored") .post(body.toString().toRequestBody()) .build() client.newCall(request).enqueue(object: Callback { override fun onFailure(call: Call, e: java.io.IOException) { e.printStackTrace() } override fun onResponse(call: Call, response: Response) { response.use { val gson = Gson() when { it.isSuccessful -> { Log.i("AuthClient", "HTTP request worked!") val cookieManager = CookieManager.getInstance() val cookies = Cookie.parseAll(BASE_URL.toHttpUrl(), it.headers) cookies.forEach { cookie -> Log.i("AuthClient", "Received cookie " + cookie.name + " adding to the CookieManager...") cookieManager.setCookie(BASE_URL, cookie.toString()) } val login = gson.fromJson(it.body?.string(), LoginSuccessful::class.java) onResponse(AuthResponse( ok = true, successResponse = login, failedResponse = null, )) } else -> { Log.i("AuthClient", "HTTP request failed!") onResponse(AuthResponse( ok = false, failedResponse = gson.fromJson(it.body?.string(), FailedResponse::class.java), successResponse = null, )) } } } } }) }} data class AuthResponse( var ok: Boolean, var successResponse: LoginSuccessful?, var failedResponse: FailedResponse?,) data class UserData( val name: String,) data class LoginSuccessful( val token: String, val data: UserData, val redirectTo: String?,) data class InvalidLoginResponse( val email: Array<String>?, val password: Array<String>?,) data class FailedResponse( val message: String, val errors: InvalidLoginResponse,)
I know there's a lot here. I'm not that good in Kotlin, but this is not the point of this bootcamp. We're essentially adding a method to fetch the CSRF token used to get the Cookie authentication working, which we can use like so:
AuthClient().fetchCsrfToken { // It worked, do something after...}
This method will make an HTTP call to the /sanctum/csrf-cookie
route provided by Sanctum. That route will return the Cookies we need in order to successfully authenticate, since we're gonna use the CSRF protection middleware from Laravel in the API routes.
We'd need a way to store the cookies so they'd automatically be added to the CookieManager
whenever a response is received by the HTTP Client in whatever context, not just for the auth one. Anyways, for our purposes, this will do.
This code also adds the method to attempt authenticating, which should call an endpoint at /api/login
, which will create soon when we're handling the Laravel side of things. This is how the attempt
method may be used:
AuthClient().attempt(email, password) { it -> // "it" in this case is an instance of the `AuthResponse` class // we created in the same file as the `AuthClient`...}
With that in place, let's build the Login Screen!
The Native Login Screen
Create the features.auth
package. And, before we create the LoginFragment
, we'll add two interfaces that it's gonna use. First, add a CsrfTokenCallback
interface to the features.auth
package with the following contents:
package com.example.turbochirpernative.features.auth interface CsrfTokenCallback { fun onCsrfTokenFetched()}
Then, add a LoginRequestCallback
interface also in the features.auth
package:
package com.example.turbochirpernative.features.auth interface LoginRequestCallback { fun onLoginSucceeded(url: String) fun onLoginFailed(msg: String)}
Next, add the LoginFragment
also to the features.auth
package:
package com.example.turbochirpernative.features.auth import android.os.Bundleimport android.view.LayoutInflaterimport android.view.Viewimport android.view.ViewGroupimport android.widget.Toastimport androidx.compose.foundation.layout.Arrangementimport androidx.compose.foundation.layout.Columnimport androidx.compose.foundation.layout.fillMaxWidthimport androidx.compose.foundation.layout.paddingimport androidx.compose.material.Buttonimport androidx.compose.material.MaterialThemeimport androidx.compose.material.OutlinedTextFieldimport androidx.compose.material.Textimport androidx.compose.runtime.Composableimport androidx.compose.runtime.DisposableEffectimport androidx.compose.runtime.mutableStateOfimport androidx.compose.runtime.rememberimport androidx.compose.ui.Alignmentimport androidx.compose.ui.Modifierimport androidx.compose.ui.platform.ComposeViewimport androidx.compose.ui.text.TextStyleimport androidx.compose.ui.text.font.FontWeightimport androidx.compose.ui.text.input.PasswordVisualTransformationimport androidx.compose.ui.text.input.TextFieldValueimport androidx.compose.ui.unit.dpimport androidx.compose.ui.unit.spimport com.example.turbochirpernative.api.AuthClientimport dev.hotwire.turbo.fragments.TurboFragmentimport dev.hotwire.turbo.nav.TurboNavGraphDestination @TurboNavGraphDestination(uri = "turbo://fragment/auth/login")class LoginFragment : TurboFragment(), LoginRequestCallback, CsrfTokenCallback { private val authClient = AuthClient() override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { return ComposeView(requireContext()).apply { setContent { MaterialTheme { LoginScreen() } } } } @Composable private fun LoginScreen() { DisposableEffect(true) { fetchCsrfToken() onDispose { } } Column(Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center) { val email = remember { mutableStateOf(TextFieldValue("")) } val password = remember { mutableStateOf(TextFieldValue("")) } Text("Turbo Chirp Native", fontSize = 30.sp, style = TextStyle(fontWeight = FontWeight.Bold)) Text("Login", fontSize = 24.sp, modifier = Modifier.padding(top = 10.dp)) Column(modifier = Modifier.padding(vertical = 16.dp)) { OutlinedTextField( label = { Text("Email") }, singleLine = true, value = email.value, onValueChange = { email.value = it } ) } Column(modifier = Modifier.padding(vertical = 10.dp)) { OutlinedTextField( label = { Text("Password") }, singleLine = true, value = password.value, onValueChange = { password.value = it }, visualTransformation = PasswordVisualTransformation(), ) } Column(modifier = Modifier.padding(vertical = 10.dp)) { Button(onClick = { if (! email.value.text.isEmpty() && ! password.value.text.isEmpty()) { authClient.attempt(email.value.text, password.value.text) { if (it.ok) { onLoginSucceeded(it.successResponse?.redirectTo + "") } else { onLoginFailed(it.failedResponse?.message ?: "Something went wrong!") } } } }) { Text("Login") } } } } override fun onLoginSucceeded(url: String) { activity?.runOnUiThread { navigate(url) } } override fun onLoginFailed(msg: String) { activity?.runOnUiThread { Toast.makeText(context, msg, Toast.LENGTH_SHORT).show() } } override fun onCsrfTokenFetched() { activity?.runOnUiThread { Toast.makeText(context, "CSRF Token fetched!", Toast.LENGTH_SHORT).show() } } private fun fetchCsrfToken() { authClient.fetchCsrfToken() { onCsrfTokenFetched() } }}
There's a lot going on in this screen, and we're not gonna talk about it much. The important thing to note here is that we're instanciating the authClient
as an attribute to the LoginFragment
instance. This is important because we're storing the CSRF Token, Cookie, and the Laravel Session cookie in the AuthClient
instance, so we can't just create a new instance of the AuthClient
whenever we want.
Also, we're calling the authClient.fetchCsrfToken()
in a DisposableEffect
to make sure it only calls it once when Compose mounts this screen. Then, whenever the user types non-blank email and password fields to the inputs we have just created, we'll call the authClient.attempt()
with it. We're using Toast messages here and there to give some feedback on what's going on. That's more for us than for actual users of our app, so feel free to remove those if you want to.
Finally, let's register our fragment in the MainSessionNavHostFragment
:
package com.example.turbochirpernative.main
import android.webkit.WebViewimport androidx.appcompat.app.AppCompatActivityimport androidx.fragment.app.Fragmentimport com.example.turbochirpernative.BuildConfig import com.example.turbochirpernative.features.auth.LoginFragment import com.example.turbochirpernative.features.web.WebFragment
import com.example.turbochirpernative.util.CHIRPS_HOME_URLimport dev.hotwire.turbo.config.TurboPathConfigurationimport dev.hotwire.turbo.session.TurboSessionNavHostFragmentimport kotlin.reflect.KClass class MainSessionNavHostFragment : TurboSessionNavHostFragment() {
override val sessionName = "main" override val startLocation = CHIRPS_HOME_URL override val registeredActivities: List<KClass<out AppCompatActivity>> get() = listOf() override val registeredFragments: List<KClass<out Fragment>> get() = listOf( WebFragment::class, LoginFragment::class, )
override val pathConfigurationLocation: TurboPathConfiguration.Location get() = TurboPathConfiguration.Location( assetFilePath = "json/configuration.json", ) override fun onSessionCreated() { super.onSessionCreated() session.webView.settings.userAgentString = customUserAgent(session.webView) if (BuildConfig.DEBUG) { session.setDebugLoggingEnabled(true) WebView.setWebContentsDebuggingEnabled(true) } } private fun customUserAgent(webView: WebView): String { return "Turbo Native Android ${webView.settings.userAgentString}" } }
Then, add the new entry to the assets/json/configuration.json
to tell Turbo to render the LoginFragment
whenever we're visiting the /login
route:
{ "settings": { "screenshots_enabled": true }, "rules": [ { "patterns": [ ".*" ], "properties": { "context": "default", "uri": "turbo://fragment/web", "pull_to_refresh_enabled": true } }, { "patterns": [ "login$", "login/$" ], "properties": { "context": "default", "uri": "turbo://fragment/auth/login", "pull_to_refresh_enabled": false } } ]}
This is what the project structure should look like for you:
Before we're able to test this, we need add Sanctum and our API routes to the Laravel side of our Chirper web app!
Setting up Sanctum and the API Endpoints
Our Sanctum installation process will follow the docs with one exception. First thing is the Sanctum installation. In the chirper web app root folder, install Sanctum via Composer:
composer require laravel/sanctum
Next, publish the configuration file:
php artisan vendor:publish --provider="Laravel\Sanctum\SanctumServiceProvider"
Then, migrate the database:
php artisan migrate
Now is the exception part. Sanctum's default installation recommends adding the EnsureFrontendRequestsAreStateful
middleware that ships with Sanctum at the top of the API route group middleware stack. Instead, we're gonna create our own. We're also adding the TurboMiddleware
to that route group:
<?php
namespace App\Http; use Illuminate\Foundation\Http\Kernel as HttpKernel; class Kernel extends HttpKernel{
/** * The application's global HTTP middleware stack. * * These middleware are run during every request to your application. * * @var array<int, class-string|string> */ protected $middleware = [ // \App\Http\Middleware\TrustHosts::class, \App\Http\Middleware\TrustProxies::class, \Illuminate\Http\Middleware\HandleCors::class, \App\Http\Middleware\PreventRequestsDuringMaintenance::class, \Illuminate\Foundation\Http\Middleware\ValidatePostSize::class, \App\Http\Middleware\TrimStrings::class, \Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull::class, ]; /** * The application's route middleware groups. * * @var array<string, array<int, class-string|string>> */ protected $middlewareGroups = [ 'web' => [
\App\Http\Middleware\EncryptCookies::class, \Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class, \Illuminate\Session\Middleware\StartSession::class, \Illuminate\View\Middleware\ShareErrorsFromSession::class, \App\Http\Middleware\VerifyCsrfToken::class, \Illuminate\Routing\Middleware\SubstituteBindings::class, ], 'api' => [ // \Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class, \Tonysm\TurboLaravel\Http\Middleware\TurboMiddleware::class, \App\Http\Middleware\EnsureTurboNativeRequestsAreStateful::class, 'throttle:api', \Illuminate\Routing\Middleware\SubstituteBindings::class, ], ];
/** * The application's route middleware. * * These middleware may be assigned to groups or used individually. * * @var array<string, class-string|string> */ protected $routeMiddleware = [ 'auth' => \App\Http\Middleware\Authenticate::class, 'auth.basic' => \Illuminate\Auth\Middleware\AuthenticateWithBasicAuth::class, 'auth.session' => \Illuminate\Session\Middleware\AuthenticateSession::class, 'cache.headers' => \Illuminate\Http\Middleware\SetCacheHeaders::class, 'can' => \Illuminate\Auth\Middleware\Authorize::class, 'guest' => \App\Http\Middleware\RedirectIfAuthenticated::class, 'password.confirm' => \Illuminate\Auth\Middleware\RequirePassword::class, 'signed' => \App\Http\Middleware\ValidateSignature::class, 'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class, 'verified' => \Illuminate\Auth\Middleware\EnsureEmailIsVerified::class, ]; }
Now, let's create our own EnsureFrontendRequestsAreStateful
in the app/Http/Middleware/
folder:
<?php namespace App\Http\Middleware; use Illuminate\Routing\Pipeline;use Illuminate\Support\Collection;use Illuminate\Support\Str; class EnsureTurboNativeRequestsAreStateful{ /** * Handle the incoming requests. * * @param \Illuminate\Http\Request $request * @param callable $next * @return \Illuminate\Http\Response */ public function handle($request, $next) { $this->configureSecureCookieSessions(); return (new Pipeline(app()))->send($request)->through($request->wasFromTurboNative() ? [ function ($request, $next) { $request->attributes->set('turbo-native', true); return $next($request); }, config('turbo-laravel.middleware.encrypt_cookies', \Illuminate\Cookie\Middleware\EncryptCookies::class), \Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class, \Illuminate\Session\Middleware\StartSession::class, config('turbo-laravel.middleware.verify_csrf_token', \Illuminate\Foundation\Http\Middleware\VerifyCsrfToken::class), ] : [])->then(function ($request) use ($next) { return $next($request); }); } /** * Configure secure cookie sessions. * * @return void */ protected function configureSecureCookieSessions() { config([ 'session.http_only' => true, 'session.same_site' => 'lax', ]); }}
Now, let's add our login route to the routes/api.php
entrypoint:
<?php
use App\Models\User;use Illuminate\Http\Request;use Illuminate\Support\Facades\Auth;use Illuminate\Support\Facades\Hash;use Illuminate\Support\Facades\Route;use Illuminate\Validation\ValidationException; /*|--------------------------------------------------------------------------| API Routes|--------------------------------------------------------------------------|| Here is where you can register API routes for your application. These| routes are loaded by the RouteServiceProvider within a group which| is assigned the "api" middleware group. Enjoy building your API!|*/ Route::post('/login', function (Request $request) { $credentials = $request->validate([ 'email' => 'required|email', 'password' => 'required', ]); if (! Auth::attempt($credentials, true)) { throw ValidationException::withMessages([ 'email' => ['The provided credentials are incorrect.'], ]); } $request->session()->regenerate(); return [ 'token' => Auth::user()->createToken('Turbo Native')->plainTextToken, 'redirectTo' => route('chirps.index'), 'data' => [ 'name' => Auth::user()->name, ], ];}); Route::middleware('auth:sanctum')->get('/user', function (Request $request) { return $request->user();});
Now, we're ready to test it!
Testing Out
If you want to, purge all your sessions in the Laravel app by deleting all the files in the storage/framework/sessions/
folder (assuming you're using the file session driver):
rm -f storage/framework/sessions/*
Now, go back to Android Studio and build the client again. You should see the login screen and if you authenticate, you should then be sent to the chirps home page!
Our access token is being stored in the preferences section of our app, which means we can reuse it whenever we need to make an HTTP call with a native screen and our WebView is successfully authenticated, so we don't need to worry about those screens that don't need native authentication anymore. Yay! Next, let's take a look at improving our mobile UX for creating Chirps.