Skip to content

Commit f1c37d7

Browse files
ksroda-saclaude
andcommitted
feat(samples): React Native mobile login + token refresh
Adds the first mobile framework to the quickstart samples — two scenarios under app_type=mobile_desktop using react-native-app-auth: PKCE login and refresh-token continuation backed by react-native-keychain. Welcome message decoded from the ID token, expiration formatted in local time, login-pkce auto-flips to "Session expired" when the access token elapses, token-refresh auto-rotates via the refresh token. Snippet tooling extended for the new framework via additions to placeholder-map.yaml. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent c8168fa commit f1c37d7

105 files changed

Lines changed: 19411 additions & 4 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,3 +67,15 @@ target/
6767
*.p12
6868
*.pem
6969
*.jks
70+
71+
# Mobile (iOS / Android / RN) build artifacts
72+
.bundle/
73+
*.jsbundle
74+
.metro-health-check*
75+
**/build/
76+
**/Pods/
77+
**/.gradle/
78+
**/.xcode.env.local
79+
**/*.xcworkspace/
80+
**/local.properties
81+
**/debug.keystore

README.md

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,15 @@ Framework-specific sample apps demonstrating SecureAuth integration. Code is ext
55
## Structure
66

77
```
8-
samples/ # One folder per framework
9-
react/ # React sample apps
10-
login-pkce/ # Login with Auth Code + PKCE
11-
scripts/ # Extraction and validation tools
8+
samples/ # One folder per framework
9+
react/ # React SPA samples
10+
vue/ # Vue SPA samples
11+
angular/ # Angular SPA samples
12+
node/ # Node.js server samples (OIDC + SAML)
13+
java/ # Java/Spring Boot server samples (OIDC + SAML)
14+
dotnet/ # .NET server samples (OIDC + SAML)
15+
react-native/ # React Native mobile samples (PKCE + token refresh)
16+
scripts/ # Extraction and validation tools
1217
```
1318

1419
## Adding a new sample

placeholder-map.yaml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,14 @@ js:
1313
placeholder: "{{SCOPES}}"
1414
- pattern: "import.meta.env.POST_LOGOUT_URI"
1515
placeholder: "{{POST_LOGOUT_URI}}"
16+
- pattern: "Config.ISSUER_URL!"
17+
placeholder: "{{ISSUER_URL}}"
18+
- pattern: "Config.CLIENT_ID!"
19+
placeholder: "{{CLIENT_ID}}"
20+
- pattern: "Config.REDIRECT_URI!"
21+
placeholder: "{{REDIRECT_URI}}"
22+
- pattern: "Config.SCOPES!"
23+
placeholder: "{{SCOPES}}"
1624

1725
ts:
1826
- pattern: "environment.issuerUrl"
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
CLIENT_ID=your-client-id
2+
ISSUER_URL=https://your-tenant.us.connect.secureauth.com/your-workspace
3+
REDIRECT_URI=com.secureauth.quickstart://oauthredirect
4+
SCOPES=openid profile email

samples/react-native/login-pkce/.yarn/releases/yarn-4.13.0.cjs

Lines changed: 940 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
defaultSemverRangePrefix: ""
2+
3+
nodeLinker: node-modules
4+
5+
npmRegistryServer: "https://registry.npmjs.org"
6+
7+
yarnPath: .yarn/releases/yarn-4.13.0.cjs
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
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+
});
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
# React Native — Login with PKCE
2+
3+
Minimal React Native app demonstrating OIDC login using Authorization Code + PKCE via [`react-native-app-auth`](https://github.com/FormidableLabs/react-native-app-auth). The system browser (ASWebAuthenticationSession on iOS, Custom Tabs on Android) handles the auth UI — never an embedded WebView.
4+
5+
## Prerequisites
6+
7+
- Node 22.11+
8+
- Yarn 4 (via Corepack: `corepack enable`)
9+
- For iOS: Xcode 15+, CocoaPods (`sudo gem install cocoapods` or `brew install cocoapods`)
10+
- For Android: JDK 17, Android Studio with SDK 34, an emulator or device
11+
12+
## Setup
13+
14+
1. Copy `.env.example` to `.env` and fill in your SecureAuth values:
15+
- `ISSUER_URL` — your workspace URL
16+
- `CLIENT_ID` — the OAuth client ID
17+
- `REDIRECT_URI` — leave as `com.secureauth.quickstart://oauthredirect` (must match the scheme registered in `ios/Quickstart/Info.plist` and `android/app/build.gradle`)
18+
- `SCOPES` — space-separated list (e.g. `openid profile email`)
19+
2. In CIAM admin UI, register the redirect URI exactly: `com.secureauth.quickstart://oauthredirect`
20+
3. Install JS deps: `yarn install`
21+
4. Install iOS native deps: `cd ios && pod install && cd ..`
22+
5. (Android only, first run) Generate a debug keystore: `cd android/app && keytool -genkeypair -v -keystore debug.keystore -storepass android -alias androiddebugkey -keypass android -keyalg RSA -keysize 2048 -validity 10000 -dname "CN=Android Debug,O=Android,C=US" && cd ../..`
23+
6. Run on iOS: `yarn ios`
24+
7. Or run on Android: `yarn android`
25+
26+
## What this demonstrates
27+
28+
- OIDC configuration with PKCE on a public mobile client (no client secret)
29+
- System browser–driven login (AppAuth pattern — no embedded WebView)
30+
- Auth Code + PKCE token exchange
31+
- Local logout via `revoke()` + clearing app state
32+
33+
## Notes
34+
35+
- The redirect URI is a custom URL scheme, registered identically in both iOS (`Info.plist``CFBundleURLTypes`) and Android (`build.gradle``manifestPlaceholders["appAuthRedirectScheme"]`). The CIAM-side registration uses exactly that string.
36+
- `react-native-config` reads `.env` at build time. Changes to `.env` require a rebuild (re-run `yarn ios` / `yarn android`), not just a Metro reload.
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
apply plugin: "com.android.application"
2+
apply plugin: "org.jetbrains.kotlin.android"
3+
apply plugin: "com.facebook.react"
4+
5+
/**
6+
* This is the configuration block to customize your React Native Android app.
7+
* By default you don't need to apply any configuration, just uncomment the lines you need.
8+
*/
9+
react {
10+
/* Folders */
11+
// The root of your project, i.e. where "package.json" lives. Default is '../..'
12+
// root = file("../../")
13+
// The folder where the react-native NPM package is. Default is ../../node_modules/react-native
14+
// reactNativeDir = file("../../node_modules/react-native")
15+
// The folder where the react-native Codegen package is. Default is ../../node_modules/@react-native/codegen
16+
// codegenDir = file("../../node_modules/@react-native/codegen")
17+
// The cli.js file which is the React Native CLI entrypoint. Default is ../../node_modules/react-native/cli.js
18+
// cliFile = file("../../node_modules/react-native/cli.js")
19+
20+
/* Variants */
21+
// The list of variants to that are debuggable. For those we're going to
22+
// skip the bundling of the JS bundle and the assets. Default is "debug", "debugOptimized".
23+
// If you add flavors like lite, prod, etc. you'll have to list your debuggableVariants.
24+
// debuggableVariants = ["liteDebug", "liteDebugOptimized", "prodDebug", "prodDebugOptimized"]
25+
26+
/* Bundling */
27+
// A list containing the node command and its flags. Default is just 'node'.
28+
// nodeExecutableAndArgs = ["node"]
29+
//
30+
// The command to run when bundling. By default is 'bundle'
31+
// bundleCommand = "ram-bundle"
32+
//
33+
// The path to the CLI configuration file. Default is empty.
34+
// bundleConfig = file(../rn-cli.config.js)
35+
//
36+
// The name of the generated asset file containing your JS bundle
37+
// bundleAssetName = "MyApplication.android.bundle"
38+
//
39+
// The entry file for bundle generation. Default is 'index.android.js' or 'index.js'
40+
// entryFile = file("../js/MyApplication.android.js")
41+
//
42+
// A list of extra flags to pass to the 'bundle' commands.
43+
// See https://github.com/react-native-community/cli/blob/main/docs/commands.md#bundle
44+
// extraPackagerArgs = []
45+
46+
/* Hermes Commands */
47+
// The hermes compiler command to run. By default it is 'hermesc'
48+
// hermesCommand = "$rootDir/my-custom-hermesc/bin/hermesc"
49+
//
50+
// The list of flags to pass to the Hermes compiler. By default is "-O", "-output-source-map"
51+
// hermesFlags = ["-O", "-output-source-map"]
52+
53+
/* Autolinking */
54+
autolinkLibrariesWithApp()
55+
}
56+
57+
/**
58+
* Set this to true to Run Proguard on Release builds to minify the Java bytecode.
59+
*/
60+
def enableProguardInReleaseBuilds = false
61+
62+
/**
63+
* The preferred build flavor of JavaScriptCore (JSC)
64+
*
65+
* For example, to use the international variant, you can use:
66+
* `def jscFlavor = io.github.react-native-community:jsc-android-intl:2026004.+`
67+
*
68+
* The international variant includes ICU i18n library and necessary data
69+
* allowing to use e.g. `Date.toLocaleString` and `String.localeCompare` that
70+
* give correct results when using with locales other than en-US. Note that
71+
* this variant is about 6MiB larger per architecture than default.
72+
*/
73+
def jscFlavor = 'io.github.react-native-community:jsc-android:2026004.+'
74+
75+
android {
76+
ndkVersion rootProject.ext.ndkVersion
77+
buildToolsVersion rootProject.ext.buildToolsVersion
78+
compileSdk rootProject.ext.compileSdkVersion
79+
80+
namespace "com.secureauth.quickstart"
81+
defaultConfig {
82+
applicationId "com.secureauth.quickstart"
83+
minSdkVersion rootProject.ext.minSdkVersion
84+
targetSdkVersion rootProject.ext.targetSdkVersion
85+
versionCode 1
86+
versionName "1.0"
87+
manifestPlaceholders = [appAuthRedirectScheme: "com.secureauth.quickstart"]
88+
}
89+
signingConfigs {
90+
debug {
91+
storeFile file('debug.keystore')
92+
storePassword 'android'
93+
keyAlias 'androiddebugkey'
94+
keyPassword 'android'
95+
}
96+
}
97+
buildTypes {
98+
debug {
99+
signingConfig signingConfigs.debug
100+
}
101+
release {
102+
// Caution! In production, you need to generate your own keystore file.
103+
// see https://reactnative.dev/docs/signed-apk-android.
104+
signingConfig signingConfigs.debug
105+
minifyEnabled enableProguardInReleaseBuilds
106+
proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro"
107+
}
108+
}
109+
}
110+
111+
dependencies {
112+
// The version of react-native is set by the React Native Gradle Plugin
113+
implementation("com.facebook.react:react-android")
114+
115+
if (hermesEnabled.toBoolean()) {
116+
implementation("com.facebook.react:hermes-android")
117+
} else {
118+
implementation jscFlavor
119+
}
120+
}

0 commit comments

Comments
 (0)