Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -67,3 +67,16 @@ target/
*.p12
*.pem
*.jks

# Mobile (iOS / Android / RN) build artifacts
.bundle/
*.jsbundle
.metro-health-check*
**/build/
**/Pods/
**/.gradle/
**/.cxx/
**/.xcode.env.local
**/*.xcworkspace/
**/local.properties
**/debug.keystore
13 changes: 9 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,15 @@ Framework-specific sample apps demonstrating SecureAuth integration. Code is ext
## Structure

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

## Adding a new sample
Expand Down
8 changes: 8 additions & 0 deletions placeholder-map.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,14 @@ js:
placeholder: "{{SCOPES}}"
- pattern: "import.meta.env.POST_LOGOUT_URI"
placeholder: "{{POST_LOGOUT_URI}}"
- pattern: "Config.ISSUER_URL!"
placeholder: "{{ISSUER_URL}}"
- pattern: "Config.CLIENT_ID!"
placeholder: "{{CLIENT_ID}}"
- pattern: "Config.REDIRECT_URI!"
placeholder: "{{REDIRECT_URI}}"
- pattern: "Config.SCOPES!"
placeholder: "{{SCOPES}}"

ts:
- pattern: "environment.issuerUrl"
Expand Down
4 changes: 4 additions & 0 deletions samples/react-native/login-pkce/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
CLIENT_ID=your-client-id
ISSUER_URL=https://your-tenant.us.connect.secureauth.com/your-workspace
REDIRECT_URI=com.secureauth.quickstart.rn.login://oauthredirect
SCOPES=openid profile email
940 changes: 940 additions & 0 deletions samples/react-native/login-pkce/.yarn/releases/yarn-4.13.0.cjs

Large diffs are not rendered by default.

7 changes: 7 additions & 0 deletions samples/react-native/login-pkce/.yarnrc.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
defaultSemverRangePrefix: ""

nodeLinker: node-modules

npmRegistryServer: "https://registry.npmjs.org"

yarnPath: .yarn/releases/yarn-4.13.0.cjs
175 changes: 175 additions & 0 deletions samples/react-native/login-pkce/App.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
// @snippet:step1:start
// @description Import the AppAuth wrapper and the env loader
import Config from "react-native-config";
import {
authorize,
revoke,
type AuthConfiguration,
} from "react-native-app-auth";
// @snippet:step1:end

// @snippet:step2:start
// @description Configure the OIDC client with your SecureAuth app settings
const authConfig: AuthConfiguration = {
issuer: Config.ISSUER_URL!,
clientId: Config.CLIENT_ID!,
redirectUrl: Config.REDIRECT_URI!,
scopes: Config.SCOPES!.split(" "),
};
// @snippet:step2:end

import React, { useEffect, useState } from "react";
import { Alert, Button, StyleSheet, Text, View } from "react-native";
import { SafeAreaProvider, SafeAreaView } from "react-native-safe-area-context";
import type { AuthorizeResult } from "react-native-app-auth";

// Hermes provides atob/btoa globally; declare for TypeScript since the RN
// typescript-config preset doesn't include the DOM lib.
declare const atob: (data: string) => string;

type IdTokenClaims = {
given_name?: string;
family_name?: string;
name?: string;
email?: string;
sub?: string;
};

function decodeIdToken(idToken: string): IdTokenClaims {
try {
const [, payload] = idToken.split(".");
if (!payload) return {};
const b64 = payload.replace(/-/g, "+").replace(/_/g, "/");
const padded = b64.padEnd(b64.length + ((4 - (b64.length % 4)) % 4), "=");
return JSON.parse(atob(padded)) as IdTokenClaims;
} catch {
// Malformed / opaque token — fall through to the default welcome message
return {};
}
}

function formatLocal(iso: string): string {
const d = new Date(iso);
return Number.isNaN(d.getTime()) ? iso : d.toLocaleString();
}

function welcomeMessage(authState: AuthorizeResult): string {
const claims = decodeIdToken(authState.idToken);
const name =
[claims.given_name, claims.family_name].filter(Boolean).join(" ") ||
claims.name ||
claims.email ||
claims.sub ||
"there";
return `Welcome, ${name}!`;
}

export default function App() {
const [authState, setAuthState] = useState<AuthorizeResult | null>(null);
const [expired, setExpired] = useState(false);
const [error, setError] = useState<string | null>(null);

// @snippet:step3:start
// @description Open the system browser, run Auth Code + PKCE, and receive the tokens
async function signIn() {
setError(null);
setExpired(false);
try {
const result = await authorize(authConfig);
setAuthState(result);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
}
}
// @snippet:step3:end

// @snippet:step4:start
// @description Revoke the access token at the IdP and clear local auth state
async function signOut() {
if (!authState) return;
try {
await revoke(authConfig, {
tokenToRevoke: authState.accessToken,
sendClientId: true,
});
} catch (e) {
// Revocation is best-effort — proceed with local logout regardless.
Alert.alert(
"Sign-out warning",
e instanceof Error ? e.message : String(e),
);
}
setAuthState(null);
setExpired(false);
}
// @snippet:step4:end

// Without offline_access there's no refresh token — once the access token
// expires the only path forward is a fresh sign-in. Schedule a timer that
// surfaces this to the user the moment the token elapses.
useEffect(() => {
if (!authState) return;
const ms =
new Date(authState.accessTokenExpirationDate).getTime() - Date.now();
if (ms <= 0) {
setAuthState(null);
setExpired(true);
return;
}
const timer = setTimeout(() => {
setAuthState(null);
setExpired(true);
}, ms);
return () => clearTimeout(timer);
}, [authState]);

return (
<SafeAreaProvider>
<SafeAreaView style={styles.container}>
<Text style={styles.heading}>SecureAuth React Native PKCE Demo</Text>
{error && (
<View style={styles.errorBox}>
<Text style={styles.errorText}>Error: {error}</Text>
<Button title="Try again" onPress={signIn} />
</View>
)}
{authState ? (
<View>
<Text style={styles.welcome}>{welcomeMessage(authState)}</Text>
<Text>
Access token expires:{" "}
{formatLocal(authState.accessTokenExpirationDate)}
</Text>
<View style={{ height: 24 }} />
<Button title="Sign out" onPress={signOut} />
</View>
) : (
!error && (
<View>
{expired && (
<Text style={styles.expired}>
Session expired. Please sign in again.
</Text>
)}
<Button title="Sign in" onPress={signIn} />
</View>
)
)}
</SafeAreaView>
</SafeAreaProvider>
);
}

const styles = StyleSheet.create({
container: {
flex: 1,
padding: 20,
justifyContent: "center",
alignItems: "center",
},
heading: { fontSize: 20, fontWeight: "600", marginBottom: 24 },
welcome: { fontSize: 18, fontWeight: "600", marginBottom: 8 },
expired: { color: "#900", marginBottom: 12 },
errorBox: { padding: 12, backgroundColor: "#fee", marginBottom: 16 },
errorText: { color: "#900" },
});
36 changes: 36 additions & 0 deletions samples/react-native/login-pkce/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# React Native — Login with PKCE

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.

## Prerequisites

- Node 22.11+
- Yarn 4 (via Corepack: `corepack enable`)
- For iOS: Xcode 15+, CocoaPods (`sudo gem install cocoapods` or `brew install cocoapods`)
- For Android: JDK 17, Android Studio with SDK 34, an emulator or device

## Setup

1. Copy `.env.example` to `.env` and fill in your SecureAuth values:
- `ISSUER_URL` — your workspace URL
- `CLIENT_ID` — the OAuth client ID
- `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`)
- `SCOPES` — space-separated list (e.g. `openid profile email`)
2. In CIAM admin UI, register the redirect URI exactly: `com.secureauth.quickstart.rn.login://oauthredirect`
3. Install JS deps: `yarn install`
4. Install iOS native deps: `cd ios && pod install && cd ..`
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 ../..`
6. Run on iOS: `yarn ios`
7. Or run on Android: `yarn android`

## What this demonstrates

- OIDC configuration with PKCE on a public mobile client (no client secret)
- System browser–driven login (AppAuth pattern — no embedded WebView)
- Auth Code + PKCE token exchange
- Local logout via `revoke()` + clearing app state

## Notes

- 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.
- `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.
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
const config = {
ISSUER_URL: "https://issuer.example/wsp",
CLIENT_ID: "test-client",
REDIRECT_URI: "com.secureauth.quickstart.rn.login://oauthredirect",
SCOPES: "openid profile",
};

module.exports = {
__esModule: true,
default: config,
...config,
};
53 changes: 53 additions & 0 deletions samples/react-native/login-pkce/__tests__/App.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import React from "react";
import {
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react-native";
import { authorize } from "react-native-app-auth";
import App from "../App";

const ID_TOKEN =
"eyJhbGciOiJSUzI1NiJ9." +
"eyJnaXZlbl9uYW1lIjoiSmFuZSIsImZhbWlseV9uYW1lIjoiRG9lIiwic3ViIjoidTEifQ." +
"sig";

const AUTHORIZE_RESULT = {
accessToken: "at_abc",
accessTokenExpirationDate: new Date(Date.now() + 3600_000).toISOString(),
authorizeAdditionalParameters: {},
tokenAdditionalParameters: {},
idToken: ID_TOKEN,
refreshToken: null,
tokenType: "Bearer",
scopes: ["openid", "profile"],
authorizationCode: "code_abc",
};

describe("login-pkce App", () => {
beforeEach(() => {
(authorize as jest.Mock).mockReset();
});

it("signs in, calls authorize with expected config, and renders welcome", async () => {
(authorize as jest.Mock).mockResolvedValue(AUTHORIZE_RESULT);

render(<App />);
fireEvent.press(screen.getByText("Sign in"));

await waitFor(() => expect(authorize).toHaveBeenCalledTimes(1));
expect(authorize).toHaveBeenCalledWith(
expect.objectContaining({
issuer: "https://issuer.example/wsp",
clientId: "test-client",
redirectUrl: "com.secureauth.quickstart.rn.login://oauthredirect",
scopes: ["openid", "profile"],
}),
);

expect(await screen.findByText("Welcome, Jane Doe!")).toBeTruthy();
expect(screen.getByText(/Access token expires:/)).toBeTruthy();
expect(screen.getByText("Sign out")).toBeTruthy();
});
});
Loading