|
| 1 | +// @snippet:step1:start |
| 2 | +// @description Import the AppAuth wrapper and the env loader |
| 3 | +import Config from "react-native-config"; |
| 4 | +import { |
| 5 | + authorize, |
| 6 | + revoke, |
| 7 | + type AuthConfiguration, |
| 8 | +} from "react-native-app-auth"; |
| 9 | +// @snippet:step1:end |
| 10 | + |
| 11 | +// @snippet:step2:start |
| 12 | +// @description Configure the OIDC client with your SecureAuth app settings |
| 13 | +const authConfig: AuthConfiguration = { |
| 14 | + issuer: Config.ISSUER_URL!, |
| 15 | + clientId: Config.CLIENT_ID!, |
| 16 | + redirectUrl: Config.REDIRECT_URI!, |
| 17 | + scopes: Config.SCOPES!.split(" "), |
| 18 | +}; |
| 19 | +// @snippet:step2:end |
| 20 | + |
| 21 | +import React, { useEffect, useState } from "react"; |
| 22 | +import { Alert, Button, StyleSheet, Text, View } from "react-native"; |
| 23 | +import { SafeAreaProvider, SafeAreaView } from "react-native-safe-area-context"; |
| 24 | +import type { AuthorizeResult } from "react-native-app-auth"; |
| 25 | + |
| 26 | +// Hermes provides atob/btoa globally; declare for TypeScript since the RN |
| 27 | +// typescript-config preset doesn't include the DOM lib. |
| 28 | +declare const atob: (data: string) => string; |
| 29 | + |
| 30 | +type IdTokenClaims = { |
| 31 | + given_name?: string; |
| 32 | + family_name?: string; |
| 33 | + name?: string; |
| 34 | + email?: string; |
| 35 | + sub?: string; |
| 36 | +}; |
| 37 | + |
| 38 | +function decodeIdToken(idToken: string): IdTokenClaims { |
| 39 | + const [, payload] = idToken.split("."); |
| 40 | + if (!payload) return {}; |
| 41 | + const b64 = payload.replace(/-/g, "+").replace(/_/g, "/"); |
| 42 | + const padded = b64.padEnd(b64.length + ((4 - (b64.length % 4)) % 4), "="); |
| 43 | + return JSON.parse(atob(padded)) as IdTokenClaims; |
| 44 | +} |
| 45 | + |
| 46 | +function formatLocal(iso: string): string { |
| 47 | + const d = new Date(iso); |
| 48 | + return Number.isNaN(d.getTime()) ? iso : d.toLocaleString(); |
| 49 | +} |
| 50 | + |
| 51 | +function welcomeMessage(authState: AuthorizeResult): string { |
| 52 | + const claims = decodeIdToken(authState.idToken); |
| 53 | + const name = |
| 54 | + [claims.given_name, claims.family_name].filter(Boolean).join(" ") || |
| 55 | + claims.name || |
| 56 | + claims.email || |
| 57 | + claims.sub || |
| 58 | + "there"; |
| 59 | + return `Welcome, ${name}!`; |
| 60 | +} |
| 61 | + |
| 62 | +export default function App() { |
| 63 | + const [authState, setAuthState] = useState<AuthorizeResult | null>(null); |
| 64 | + const [expired, setExpired] = useState(false); |
| 65 | + const [error, setError] = useState<string | null>(null); |
| 66 | + |
| 67 | + // @snippet:step3:start |
| 68 | + // @description Open the system browser, run Auth Code + PKCE, and receive the tokens |
| 69 | + async function signIn() { |
| 70 | + setError(null); |
| 71 | + setExpired(false); |
| 72 | + try { |
| 73 | + const result = await authorize(authConfig); |
| 74 | + setAuthState(result); |
| 75 | + } catch (e) { |
| 76 | + setError(e instanceof Error ? e.message : String(e)); |
| 77 | + } |
| 78 | + } |
| 79 | + // @snippet:step3:end |
| 80 | + |
| 81 | + // @snippet:step4:start |
| 82 | + // @description Revoke the access token at the IdP and clear local auth state |
| 83 | + async function signOut() { |
| 84 | + if (!authState) return; |
| 85 | + try { |
| 86 | + await revoke(authConfig, { |
| 87 | + tokenToRevoke: authState.accessToken, |
| 88 | + sendClientId: true, |
| 89 | + }); |
| 90 | + } catch (e) { |
| 91 | + // Revocation is best-effort — proceed with local logout regardless. |
| 92 | + Alert.alert( |
| 93 | + "Sign-out warning", |
| 94 | + e instanceof Error ? e.message : String(e), |
| 95 | + ); |
| 96 | + } |
| 97 | + setAuthState(null); |
| 98 | + setExpired(false); |
| 99 | + } |
| 100 | + // @snippet:step4:end |
| 101 | + |
| 102 | + // Without offline_access there's no refresh token — once the access token |
| 103 | + // expires the only path forward is a fresh sign-in. Schedule a timer that |
| 104 | + // surfaces this to the user the moment the token elapses. |
| 105 | + useEffect(() => { |
| 106 | + if (!authState) return; |
| 107 | + const ms = |
| 108 | + new Date(authState.accessTokenExpirationDate).getTime() - Date.now(); |
| 109 | + if (ms <= 0) { |
| 110 | + setAuthState(null); |
| 111 | + setExpired(true); |
| 112 | + return; |
| 113 | + } |
| 114 | + const timer = setTimeout(() => { |
| 115 | + setAuthState(null); |
| 116 | + setExpired(true); |
| 117 | + }, ms); |
| 118 | + return () => clearTimeout(timer); |
| 119 | + }, [authState]); |
| 120 | + |
| 121 | + return ( |
| 122 | + <SafeAreaProvider> |
| 123 | + <SafeAreaView style={styles.container}> |
| 124 | + <Text style={styles.heading}>SecureAuth React Native PKCE Demo</Text> |
| 125 | + {error && ( |
| 126 | + <View style={styles.errorBox}> |
| 127 | + <Text style={styles.errorText}>Error: {error}</Text> |
| 128 | + <Button title="Try again" onPress={signIn} /> |
| 129 | + </View> |
| 130 | + )} |
| 131 | + {authState ? ( |
| 132 | + <View> |
| 133 | + <Text style={styles.welcome}>{welcomeMessage(authState)}</Text> |
| 134 | + <Text> |
| 135 | + Access token expires:{" "} |
| 136 | + {formatLocal(authState.accessTokenExpirationDate)} |
| 137 | + </Text> |
| 138 | + <View style={{ height: 24 }} /> |
| 139 | + <Button title="Sign out" onPress={signOut} /> |
| 140 | + </View> |
| 141 | + ) : ( |
| 142 | + !error && ( |
| 143 | + <View> |
| 144 | + {expired && ( |
| 145 | + <Text style={styles.expired}> |
| 146 | + Session expired. Please sign in again. |
| 147 | + </Text> |
| 148 | + )} |
| 149 | + <Button title="Sign in" onPress={signIn} /> |
| 150 | + </View> |
| 151 | + ) |
| 152 | + )} |
| 153 | + </SafeAreaView> |
| 154 | + </SafeAreaProvider> |
| 155 | + ); |
| 156 | +} |
| 157 | + |
| 158 | +const styles = StyleSheet.create({ |
| 159 | + container: { |
| 160 | + flex: 1, |
| 161 | + padding: 20, |
| 162 | + justifyContent: "center", |
| 163 | + alignItems: "center", |
| 164 | + }, |
| 165 | + heading: { fontSize: 20, fontWeight: "600", marginBottom: 24 }, |
| 166 | + welcome: { fontSize: 18, fontWeight: "600", marginBottom: 8 }, |
| 167 | + expired: { color: "#900", marginBottom: 12 }, |
| 168 | + errorBox: { padding: 12, backgroundColor: "#fee", marginBottom: 16 }, |
| 169 | + errorText: { color: "#900" }, |
| 170 | +}); |
0 commit comments