Skip to content

Commit 62f960a

Browse files
ksroda-saclaude
andauthored
feat(samples): React Native mobile login + token refresh (#50)
* 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> * update gitignore * fix env * review fixes * change application id + add tests * tsconfig fix * bump deps --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent ec277f3 commit 62f960a

113 files changed

Lines changed: 24621 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: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,3 +67,16 @@ 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+
**/.cxx/
79+
**/.xcode.env.local
80+
**/*.xcworkspace/
81+
**/local.properties
82+
**/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.rn.login://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: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
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+
try {
40+
const [, payload] = idToken.split(".");
41+
if (!payload) return {};
42+
const b64 = payload.replace(/-/g, "+").replace(/_/g, "/");
43+
const padded = b64.padEnd(b64.length + ((4 - (b64.length % 4)) % 4), "=");
44+
return JSON.parse(atob(padded)) as IdTokenClaims;
45+
} catch {
46+
// Malformed / opaque token — fall through to the default welcome message
47+
return {};
48+
}
49+
}
50+
51+
function formatLocal(iso: string): string {
52+
const d = new Date(iso);
53+
return Number.isNaN(d.getTime()) ? iso : d.toLocaleString();
54+
}
55+
56+
function welcomeMessage(authState: AuthorizeResult): string {
57+
const claims = decodeIdToken(authState.idToken);
58+
const name =
59+
[claims.given_name, claims.family_name].filter(Boolean).join(" ") ||
60+
claims.name ||
61+
claims.email ||
62+
claims.sub ||
63+
"there";
64+
return `Welcome, ${name}!`;
65+
}
66+
67+
export default function App() {
68+
const [authState, setAuthState] = useState<AuthorizeResult | null>(null);
69+
const [expired, setExpired] = useState(false);
70+
const [error, setError] = useState<string | null>(null);
71+
72+
// @snippet:step3:start
73+
// @description Open the system browser, run Auth Code + PKCE, and receive the tokens
74+
async function signIn() {
75+
setError(null);
76+
setExpired(false);
77+
try {
78+
const result = await authorize(authConfig);
79+
setAuthState(result);
80+
} catch (e) {
81+
setError(e instanceof Error ? e.message : String(e));
82+
}
83+
}
84+
// @snippet:step3:end
85+
86+
// @snippet:step4:start
87+
// @description Revoke the access token at the IdP and clear local auth state
88+
async function signOut() {
89+
if (!authState) return;
90+
try {
91+
await revoke(authConfig, {
92+
tokenToRevoke: authState.accessToken,
93+
sendClientId: true,
94+
});
95+
} catch (e) {
96+
// Revocation is best-effort — proceed with local logout regardless.
97+
Alert.alert(
98+
"Sign-out warning",
99+
e instanceof Error ? e.message : String(e),
100+
);
101+
}
102+
setAuthState(null);
103+
setExpired(false);
104+
}
105+
// @snippet:step4:end
106+
107+
// Without offline_access there's no refresh token — once the access token
108+
// expires the only path forward is a fresh sign-in. Schedule a timer that
109+
// surfaces this to the user the moment the token elapses.
110+
useEffect(() => {
111+
if (!authState) return;
112+
const ms =
113+
new Date(authState.accessTokenExpirationDate).getTime() - Date.now();
114+
if (ms <= 0) {
115+
setAuthState(null);
116+
setExpired(true);
117+
return;
118+
}
119+
const timer = setTimeout(() => {
120+
setAuthState(null);
121+
setExpired(true);
122+
}, ms);
123+
return () => clearTimeout(timer);
124+
}, [authState]);
125+
126+
return (
127+
<SafeAreaProvider>
128+
<SafeAreaView style={styles.container}>
129+
<Text style={styles.heading}>SecureAuth React Native PKCE Demo</Text>
130+
{error && (
131+
<View style={styles.errorBox}>
132+
<Text style={styles.errorText}>Error: {error}</Text>
133+
<Button title="Try again" onPress={signIn} />
134+
</View>
135+
)}
136+
{authState ? (
137+
<View>
138+
<Text style={styles.welcome}>{welcomeMessage(authState)}</Text>
139+
<Text>
140+
Access token expires:{" "}
141+
{formatLocal(authState.accessTokenExpirationDate)}
142+
</Text>
143+
<View style={{ height: 24 }} />
144+
<Button title="Sign out" onPress={signOut} />
145+
</View>
146+
) : (
147+
!error && (
148+
<View>
149+
{expired && (
150+
<Text style={styles.expired}>
151+
Session expired. Please sign in again.
152+
</Text>
153+
)}
154+
<Button title="Sign in" onPress={signIn} />
155+
</View>
156+
)
157+
)}
158+
</SafeAreaView>
159+
</SafeAreaProvider>
160+
);
161+
}
162+
163+
const styles = StyleSheet.create({
164+
container: {
165+
flex: 1,
166+
padding: 20,
167+
justifyContent: "center",
168+
alignItems: "center",
169+
},
170+
heading: { fontSize: 20, fontWeight: "600", marginBottom: 24 },
171+
welcome: { fontSize: 18, fontWeight: "600", marginBottom: 8 },
172+
expired: { color: "#900", marginBottom: 12 },
173+
errorBox: { padding: 12, backgroundColor: "#fee", marginBottom: 16 },
174+
errorText: { color: "#900" },
175+
});
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.rn.login://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.rn.login://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: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
const config = {
2+
ISSUER_URL: "https://issuer.example/wsp",
3+
CLIENT_ID: "test-client",
4+
REDIRECT_URI: "com.secureauth.quickstart.rn.login://oauthredirect",
5+
SCOPES: "openid profile",
6+
};
7+
8+
module.exports = {
9+
__esModule: true,
10+
default: config,
11+
...config,
12+
};
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import React from "react";
2+
import {
3+
fireEvent,
4+
render,
5+
screen,
6+
waitFor,
7+
} from "@testing-library/react-native";
8+
import { authorize } from "react-native-app-auth";
9+
import App from "../App";
10+
11+
const ID_TOKEN =
12+
"eyJhbGciOiJSUzI1NiJ9." +
13+
"eyJnaXZlbl9uYW1lIjoiSmFuZSIsImZhbWlseV9uYW1lIjoiRG9lIiwic3ViIjoidTEifQ." +
14+
"sig";
15+
16+
const AUTHORIZE_RESULT = {
17+
accessToken: "at_abc",
18+
accessTokenExpirationDate: new Date(Date.now() + 3600_000).toISOString(),
19+
authorizeAdditionalParameters: {},
20+
tokenAdditionalParameters: {},
21+
idToken: ID_TOKEN,
22+
refreshToken: null,
23+
tokenType: "Bearer",
24+
scopes: ["openid", "profile"],
25+
authorizationCode: "code_abc",
26+
};
27+
28+
describe("login-pkce App", () => {
29+
beforeEach(() => {
30+
(authorize as jest.Mock).mockReset();
31+
});
32+
33+
it("signs in, calls authorize with expected config, and renders welcome", async () => {
34+
(authorize as jest.Mock).mockResolvedValue(AUTHORIZE_RESULT);
35+
36+
render(<App />);
37+
fireEvent.press(screen.getByText("Sign in"));
38+
39+
await waitFor(() => expect(authorize).toHaveBeenCalledTimes(1));
40+
expect(authorize).toHaveBeenCalledWith(
41+
expect.objectContaining({
42+
issuer: "https://issuer.example/wsp",
43+
clientId: "test-client",
44+
redirectUrl: "com.secureauth.quickstart.rn.login://oauthredirect",
45+
scopes: ["openid", "profile"],
46+
}),
47+
);
48+
49+
expect(await screen.findByText("Welcome, Jane Doe!")).toBeTruthy();
50+
expect(screen.getByText(/Access token expires:/)).toBeTruthy();
51+
expect(screen.getByText("Sign out")).toBeTruthy();
52+
});
53+
});

0 commit comments

Comments
 (0)