I hate auth. Quite simply. Over the years, it is the one feature of app development that has, over and over again tied me up in knots. Unlike other parts of the technical stack, I've never quite hit upon a solution that has become my "go-to", with either a manageable (or at learnable) list of downsides.
Particularly when you're trying to span two mobile platforms and web with the same solution, the options become trickier. If you were lucky enough to be developing iOS only, Apple login has lovely UI and drops in nicely. Likewise for Google Auth on Android. On web, there are plenty of solutions with nice UI, like Auth0 and Magic.link that rely on a popup-modal or redirect to a page hosted by them.
A confession, I started drafting this article steering you to Auth0, which I've used broadly successfully on my last two projects. However, midway through the mobile integration, I found "another" bug, impossible to debug preventing it from getting a token on iOS. For the size of the company, I've always been surprised by how little effort Auth0 put into their React Native SDK (it doesn't even ship with types).
Anyhow, rant over, here's how to neatly implement Cognito. AWS (through Amplify) provide prebuilt hosted login pages for both web (React) and mobile (React Native). Unless your audience is incredibly tolerant however, they are pretty repulsive and I'd expect a big drop off in credibility.
Given the overall objective of our app is to achieve conformity between the web and mobile (iOS plus Android) stacks, I, therefore, decided to write my own Tamagui screen(s) to implement auth.
If of use, please clone the branch: 2_auth_foundation from the Github repo:
https://github.com/BenjaminWatts/sst-tamagui-starter
NextJS Preparation
The best way of authenticating in a browser is using old-fashioned cookies, as (unlike JWT) you don't have to store credentials in the browser. Our Graphql setup (same origin ie /api/graphql
) enables us to use these.
I first took to implementing the iron-session
library. This is officially supported/maintained by Vercel (who maintain NextJS), so I figured this was a decent way to handle my cookie sessions.
If you don't know, you can search for active cookies in your browser in Chrome dev tools quite easily, as in the example below:
In brief, they're a cryptographically generated secret that the serverside (in our case AWS Lambda function) can use on each request to authenticate the user. The browser will automatically include it on each request sent to the same origin as the website. For our setup, we should expect to see one present when the user is logged in, and not when they are logged out.
Basic Setup
To cryptographically generate the cookies, our serverside NextJS Lambda function needs a secret value which it can use to validate cookies. This secret is INCREDIBLY SENSITIVE because anyone getting access to it could impersonate a user.
I create a .env file in the root of my project folder.
SECRET_COOKIE_PASSWORD='RANDOM_STRING_AT_LEAST_32_CHARACTERS'
In stacks/sstExpo.ts
I then import this and inject it to the NextJS App as an environment variable.
To install iron-session
go to apps/next
and run:
yarn add iron-session@6.1.3
There is a bug around the use of headers in more recent versions. Depending when you're reading this, you may be able to use the latest version!
Now I copied the official example for a typescript setup, with a couple of deviations. You should copy/note the following typescript modules:
apps/next/lib/auth.ts
. These are purely utility functions we can pass to our Tamagui components.apps/next/session.ts
. This defines parameters for the type of cookie.apps/next/lib/useUser.ts
- this looks like a helpful React Hook we might use at a later dateapps/next/lib/api/login.ts
- see below in more detailapps/next/lib/api/logout.ts
- see below in more detail
I've removed avatarUrl
from the User
session object. I also put redirects into the apps/next/pages/api/login.ts
and apps/next/pages/api/logout.ts
routes, I figure they make it easier to integrate later having a consistent login approach between web and mobile. I go through each of these in turn.
Our login route expects two query parameters, a token
(which we will get from Cognito), and a relative redirect URL /
, which will enable us to login from different pages. The logic to validate the token isn't there yet (you could replace it as you wish). Either way, it needs to create a User
object with the login
attribute equal to a unique identifier for the user. I've put my email. Under the hood, what iron-session
does is to append a header set-cookie
to the response, which tells the user browser to store it (as above) and re-use it on subsequent requests.
// pages/api/login.ts
import type { User } from "./user";
import { withIronSessionApiRoute } from "iron-session/next";
import { sessionOptions } from "lib/session";
import { NextApiRequest, NextApiResponse } from "next";
export default withIronSessionApiRoute(loginRoute, sessionOptions);
function isUrlAbsolute(url: string) {
if(url === '/') {return true}
return /^[a-z][a-z0-9+.-]*:/.test(url) === false
}
async function loginRoute(req: NextApiRequest, res: NextApiResponse) {
const {token} = req.query
if(typeof token !== 'string') {throw Error(`token is not a string: ${token}`)}
const {redirect} = req.query
if(typeof redirect !== 'string' || !isUrlAbsolute(redirect)) {throw Error(`redirect is not a string or is to an external site: ${redirect}`)}
try {
console.log(`processing login token ${token}`)
const user = { isLoggedIn: true, login: 'ben@kilowatts.io' } as User;
req.session.user = user;
console.log()
await req.session.save();
console.log('successfully created session')
// res.json(user);
res.redirect(redirect)
} catch (error) {
res.status(500).json({ message: (error as Error).message });
}
}
For logout, the process is simpler. We remove the session and return a redirect to the homepage of our site /
. Under the hood, this happens by just removing the cookie from the response.
//apps/next/pages/api/logout.ts
import { withIronSessionApiRoute } from "iron-session/next";
import { sessionOptions } from "lib/session";
import { NextApiRequest, NextApiResponse } from "next";
import type { User } from "pages/api/user";
export default withIronSessionApiRoute(logoutRoute, sessionOptions);
function logoutRoute(req: NextApiRequest, res: NextApiResponse<User>) {
console.log(`logoutRoute: ${JSON.stringify(req.session)}`)
req.session.destroy();
res.redirect('/')
}
Now to create the frontend UI. First, I create a new login screen in Tamagui with a login
callback:
//packages/app/features/login/screen.tsx
import { Button, H1, YStack } from '@my/ui'
import React from 'react'
type LoginScreenProps = {
login: () => void;
}
export const LoginScreen: React.FC<LoginScreenProps> = ({login}) => {
return (
<YStack f={1} jc="center" ai="center" p="$4" space>
<YStack space="$4" maw={600}>
<H1 ta="center">Welcome</H1>
<Button onPress={() => login()}>
Login
</Button>
</YStack>
</YStack>
)
}
I add a similar logout button to the existing HomeScreen
:
import { Anchor, Button, H1, Input, Paragraph, Separator, Sheet, Card, Spinner, XStack, YStack, ListItem, AlertDialog } from '@my/ui'
import { ChevronDown, ChevronUp } from '@tamagui/lucide-icons'
import React, { useState } from 'react'
import { useLink } from 'solito/link'
import { useQuery } from '@apollo/client'
import { RecentPostsDocument } from 'app/g/graphql'
type HomeScreenProps = {
logout: () => void
}
export const HomeScreen:React.FC<HomeScreenProps> = ({logout}) => {
const { data, loading, error, refetch } = useQuery(RecentPostsDocument, { variables: {} })
return (
<YStack f={1} jc="center" ai="center" p="$4" space>
<YStack space="$4" maw={600}>
<H1 ta="center">Recent Posts</H1>
{loading && <Spinner />}
{(data && data.recentPosts) && <Card>
{data.recentPosts.map(x => <ListItem key={x?.id}>{x?.title}</ListItem>)}
<Card.Footer>
<Button onPress={() => refetch()}>
Update
</Button>
</Card.Footer>
</Card>}
{error && <AlertDialog
>
<AlertDialog.Content>
<AlertDialog.Title >
{error.name}
</AlertDialog.Title>
<AlertDialog.Description>
{error.message}
</AlertDialog.Description>
<AlertDialog.Action>
<Button onPress={() => refetch()}>
Retry
</Button>
</AlertDialog.Action>
</AlertDialog.Content>
</AlertDialog>}
</YStack>
<XStack>
<Button onPress={() => logout()}>Logout</Button>
</XStack>
<SheetDemo />
</YStack>
)
}
function SheetDemo() {
const [open, setOpen] = useState(false)
const [position, setPosition] = useState(0)
return (
<>
<Button
size="$6"
icon={open ? ChevronDown : ChevronUp}
circular
onPress={() => setOpen((x) => !x)}
/>
<Sheet
modal
open={open}
onOpenChange={setOpen}
snapPoints={[80]}
position={position}
onPositionChange={setPosition}
dismissOnSnapToBottom
>
<Sheet.Overlay />
<Sheet.Frame ai="center" jc="center">
<Sheet.Handle />
<Button
size="$6"
circular
icon={ChevronDown}
onPress={() => {
setOpen(false)
}}
/>
</Sheet.Frame>
</Sheet>
</>
)
}
Now I refactor the home page as follows. When the server renders it, it runs the logic in getServerSideProps
which returns as a prop whether the user is authenticated or not. The page Home
that is rendered therefore conditionally renders the LoginScreen
(if the user is not authenticated) or the HomeScreen
. In each case we have a callback to the exchangeToken
(as login) and logout
actions.
Next I spin it up in a browser running npm run dev
from both the root and the apps/next
directories. I get a login screen:
I haven't opened dev tools by accident! When you click on Login
you can see the following happens. The browser makes a request to the api/login
endpoint and receives back in the Response Headers a cookie. It also gets a 307 redirect, leading the browser to request in this instance the same page again.
Now the browser is logged in, we can of course logout by clicking our new Logout button. A very similar process happens in reverse, but this time the cookie is set to null (i.e. deleted).
React Native Preparation
We hook into the login/logout process in a different way on mobile. Typical convention with React Navigation is to have a single Login Screen (or a small logged out stack) and then a main logged in navigation stack.
First, I modify the NativeNavigation
to have a logout prop which I pass to the HomeScreen
as the Tamagui component with expect:
//packages/app/navigation/native/index.tsx
import { createNativeStackNavigator } from '@react-navigation/native-stack'
import { HomeScreen } from '../../features/home/screen'
import { UserDetailScreen } from '../../features/user/detail-screen'
const Stack = createNativeStackNavigator<{
home: undefined
'user-detail': {
id: string
}
}>()
type NativeNavigationProps = {
logout: () => void
}
export const NativeNavigation = (props: NativeNavigationProps) => {
return (
<Stack.Navigator>
<Stack.Screen
name="home"
options={{
title: 'Home',
}}
>
{() => <HomeScreen logout={props.logout} />}
</Stack.Screen>
<Stack.Screen
name="user-detail"
component={UserDetailScreen}
options={{
title: 'User',
}}
/>
</Stack.Navigator>
)
}
Then in the app itself I create a simple logged in or logged out React Hook and then render conditionally the LoginScreen
or the existing NativeNavigation (logged in stack). To each I pass a simple callback which sets the state of the React Hook (don't worry, in time we can integrate some proper auth to the process.
///apps.expo.App.tsx
import 'expo-dev-client'
import React from 'react'
import { NativeNavigation } from 'app/navigation/native'
import {LoginScreen} from 'app/features/login/screen'
import { Provider } from 'app/provider'
import { useFonts } from 'expo-font'
import { ApolloClient, InMemoryCache, ApolloProvider } from '@apollo/client';
const API_URL = __DEV__ ? process.env.DEV_API_URL : process.env.PROD_API_URL;
console.log(`rendering with ${API_URL}`)
const client = new ApolloClient({
uri: API_URL,
cache: new InMemoryCache(),
});
export default function App() {
const [loggedIn, setLoggedIn] = React.useState(false)
const [loaded] = useFonts({
Inter: require('@tamagui/font-inter/otf/Inter-Medium.otf'),
InterBold: require('@tamagui/font-inter/otf/Inter-Bold.otf'),
})
if (!loaded) {
return null
}
return (
<ApolloProvider client={client}>
<Provider>
{loggedIn ? <NativeNavigation
logout={() => setLoggedIn(false)}
/> : <LoginScreen login={() => setLoggedIn(true)} />}
</Provider>
</ApolloProvider>
)
}
Finally, I spin this up into iOS simulator to check it out:
Clicking Login, all looks good (given we're totally mocking everything, it should!):
That's it for now. Next time I will integrate Cognito with our setup!