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
10 changes: 10 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,16 @@ updates:
commit-message:
prefix: "deps"

- package-ecosystem: "npm"
directory: "/samples/react/token-refresh"
schedule:
interval: "weekly"
open-pull-requests-limit: 5
labels:
- "dependencies"
commit-message:
prefix: "deps"

- package-ecosystem: "npm"
directory: "/scripts"
schedule:
Expand Down
6 changes: 3 additions & 3 deletions .github/workflows/test-js.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ jobs:
outputs:
matrix: ${{ steps.find.outputs.matrix }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- id: find
run: |
DIRS=$(find samples -name "package.json" -not -path "*/node_modules/*" -exec dirname {} \; 2>/dev/null | sort | jq -R -s -c 'split("\n") | map(select(. != ""))')
Expand All @@ -35,8 +35,8 @@ jobs:
matrix:
project: ${{ fromJson(needs.find-projects.outputs.matrix) }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: "22"
- name: Enable Corepack
Expand Down
9 changes: 8 additions & 1 deletion samples/react/login-pkce/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,14 @@ function AuthButtons() {
}

if (auth.error) {
return <div>Error: {auth.error.message}</div>;
const hint = new URLSearchParams(window.location.search).get("error_hint");
return (
<div style={{ color: "red" }}>
<p>Error: {auth.error.message}</p>
{hint && <p>{hint}</p>}
<button onClick={() => auth.signinRedirect()}>Try again</button>
</div>
);
}

if (auth.isAuthenticated) {
Expand Down
7 changes: 1 addition & 6 deletions samples/react/login-pkce/src/index.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,5 @@
import React from "react";
import ReactDOM from "react-dom/client";

import App from "./App";

ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);
ReactDOM.createRoot(document.getElementById("root")!).render(<App />);
19 changes: 19 additions & 0 deletions samples/react/manifest.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,22 @@ scenarios:
value: "{{SCOPES}}"
- label: "Grant"
value: "Authorization Code + PKCE"

spa_token_refresh:
app_type: single_page
display_name: "Token Refresh"
grant: "Refresh Token"
description: "Use refresh tokens to get new access tokens without re-prompting the user. Requires offline_access scope."
callout: "Requires refresh_token grant type enabled in workspace OAuth settings and in the application's Grant Types. Also requires the offline_access scope."
run_command: "yarn install && yarn dev"
required_scopes:
- offline_access
config_rows:
- label: "Client ID"
value: "{{CLIENT_ID}}"
- label: "Issuer URL"
value: "{{ISSUER_URL}}"
- label: "Redirect URI"
value: "{{REDIRECT_URI}}"
- label: "Scopes"
value: "{{SCOPES}}"
4 changes: 4 additions & 0 deletions samples/react/token-refresh/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
ISSUER_URL=https://your-tenant.us.secureauth.com/your-workspace
CLIENT_ID=your-client-id
REDIRECT_URI=http://localhost:3000/callback
SCOPES=openid profile email offline_access
940 changes: 940 additions & 0 deletions samples/react/token-refresh/.yarn/releases/yarn-4.13.0.cjs

Large diffs are not rendered by default.

7 changes: 7 additions & 0 deletions samples/react/token-refresh/.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
23 changes: 23 additions & 0 deletions samples/react/token-refresh/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# React SPA — Token Refresh

Minimal React app demonstrating token refresh using `react-oidc-context` with the `offline_access` scope. Tokens are refreshed automatically before they expire, and can also be refreshed manually via a button.

## Prerequisites

- `offline_access` scope enabled on the client
- `refresh_token` grant type enabled in workspace OAuth settings and in the application's Grant Types

## Setup

1. Copy `.env.example` to `.env` and fill in your SecureAuth values
2. `yarn install`
3. `yarn dev`
4. Open http://localhost:3000

## What this demonstrates

- OIDC configuration with `offline_access` scope for refresh tokens
- Automatic token refresh via refresh tokens (no iframe needed)
- Manual token refresh via `signinSilent()` button
- Displaying token expiry information
- Error handling with OAuth error hints
12 changes: 12 additions & 0 deletions samples/react/token-refresh/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SecureAuth React Token Refresh</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/index.tsx"></script>
</body>
</html>
33 changes: 33 additions & 0 deletions samples/react/token-refresh/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
{
"name": "@ciam-quickstart/react-token-refresh",
"private": true,
"packageManager": "yarn@4.13.0",
"version": "0.0.1",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"test": "vitest run",
"format": "prettier --write .",
"format:check": "prettier --check ."
},
"dependencies": {
"oidc-client-ts": "3.5.0",
"react": "19.2.5",
"react-dom": "19.2.5",
"react-oidc-context": "3.3.1"
},
"devDependencies": {
"@testing-library/dom": "10.4.1",
"@testing-library/jest-dom": "6.9.1",
"@testing-library/react": "16.3.2",
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3",
"@vitejs/plugin-react": "6.0.1",
"happy-dom": "20.8.9",
"prettier": "3.8.2",
"typescript": "6.0.2",
"vite": "8.0.8",
"vitest": "4.1.4"
}
}
67 changes: 67 additions & 0 deletions samples/react/token-refresh/src/App.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";

const mockUseAuth = vi.fn();
const mockSigninSilent = vi.fn();

vi.mock("react-oidc-context", () => ({
AuthProvider: ({ children }: { children: React.ReactNode }) => (
<>{children}</>
),
useAuth: () => mockUseAuth(),
}));

import App from "./App";

describe("App (Token Refresh)", () => {
afterEach(() => cleanup());
it("renders sign in button when not authenticated", () => {
mockUseAuth.mockReturnValue({
isAuthenticated: false,
isLoading: false,
});
render(<App />);
expect(screen.getByText("Sign in")).toBeInTheDocument();
});

Comment thread
ksroda-sa marked this conversation as resolved.
it("shows token info and refresh button when authenticated", () => {
mockUseAuth.mockReturnValue({
isAuthenticated: true,
isLoading: false,
signinSilent: mockSigninSilent,
user: {
profile: { given_name: "Jane" },
expires_at: Math.floor(Date.now() / 1000) + 3600,
},
});
render(<App />);
expect(screen.getByText("Welcome, Jane")).toBeInTheDocument();
expect(screen.getByText("Refresh token now")).toBeInTheDocument();
});
Comment thread
ksroda-sa marked this conversation as resolved.

it("renders error message when authentication fails", () => {
mockUseAuth.mockReturnValue({
isAuthenticated: false,
isLoading: false,
error: new Error("Unable to refresh token"),
});
render(<App />);
expect(screen.getByText(/Unable to refresh token/)).toBeInTheDocument();
expect(screen.getByText("Try again")).toBeInTheDocument();
});

it("calls signinSilent when refresh button is clicked", () => {
mockUseAuth.mockReturnValue({
isAuthenticated: true,
isLoading: false,
signinSilent: mockSigninSilent,
user: {
profile: { given_name: "Jane" },
expires_at: Math.floor(Date.now() / 1000) + 3600,
},
});
render(<App />);
fireEvent.click(screen.getByText("Refresh token now"));
expect(mockSigninSilent).toHaveBeenCalled();
});
});
72 changes: 72 additions & 0 deletions samples/react/token-refresh/src/App.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
// @snippet:step1:start
// @description Import the OIDC library
import { AuthProvider, useAuth } from "react-oidc-context";
// @snippet:step1:end

// @snippet:step2:start
// @description Request offline_access scope to receive a refresh token
const oidcConfig = {
authority: import.meta.env.ISSUER_URL,
client_id: import.meta.env.CLIENT_ID,
redirect_uri: import.meta.env.REDIRECT_URI,
scope: import.meta.env.SCOPES,
};
// @snippet:step2:end

// @snippet:step3:start
// @description Display token status and trigger manual refresh
function TokenStatus() {
const auth = useAuth();

if (auth.isLoading) {
return <p>Loading...</p>;
}

if (auth.error) {
const hint = new URLSearchParams(window.location.search).get("error_hint");
return (
<div style={{ color: "red" }}>
<p>Error: {auth.error.message}</p>
{hint && <p>{hint}</p>}
<button onClick={() => auth.signinRedirect()}>Try again</button>
</div>
);
}

if (!auth.isAuthenticated) {
return <button onClick={() => auth.signinRedirect()}>Sign in</button>;
}
Comment thread
ksroda-sa marked this conversation as resolved.

const expiresAt = auth.user?.expires_at
? new Date(auth.user.expires_at * 1000).toLocaleTimeString()
: "unknown";

const handleRefresh = async () => {
try {
await auth.signinSilent();
} catch (error) {
console.error("Token refresh failed:", error);
}
};

return (
<div>
<p>Welcome, {auth.user?.profile.given_name}</p>
<p>Token expires at: {expiresAt}</p>
<button onClick={handleRefresh}>Refresh token now</button>
</div>
);
}
// @snippet:step3:end

// @snippet:step4:start
// @description Wrap your app with AuthProvider
export default function App() {
return (
<AuthProvider {...oidcConfig}>
<h1>SecureAuth Token Refresh Demo</h1>
<TokenStatus />
</AuthProvider>
);
}
// @snippet:step4:end
5 changes: 5 additions & 0 deletions samples/react/token-refresh/src/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import ReactDOM from "react-dom/client";

import App from "./App";

ReactDOM.createRoot(document.getElementById("root")!).render(<App />);
12 changes: 12 additions & 0 deletions samples/react/token-refresh/src/vite-env.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
/// <reference types="vite/client" />

interface ImportMetaEnv {
readonly ISSUER_URL: string;
readonly CLIENT_ID: string;
readonly REDIRECT_URI: string;
readonly SCOPES: string;
}

interface ImportMeta {
readonly env: ImportMetaEnv;
}
18 changes: 18 additions & 0 deletions samples/react/token-refresh/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2020",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"types": ["@testing-library/jest-dom"]
},
"include": ["src", "vitest.setup.ts"]
}
28 changes: 28 additions & 0 deletions samples/react/token-refresh/vite.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import react from "@vitejs/plugin-react";
import { defineConfig, loadEnv } from "vite";

const exposedEnvVars = ["ISSUER_URL", "CLIENT_ID", "REDIRECT_URI", "SCOPES"];

export default defineConfig(({ mode, command }) => {
const env = loadEnv(mode, process.cwd(), "");
if (command === "serve") {
const missing = exposedEnvVars.filter((key) => !env[key]);
if (missing.length > 0) {
throw new Error(
`Missing required env vars: ${missing.join(", ")}. Copy .env.example to .env and fill in the values.`,
);
}
}
return {
plugins: [react()],
define: Object.fromEntries(
exposedEnvVars.map((key) => [
`import.meta.env.${key}`,
JSON.stringify(env[key] ?? ""),
]),
),
server: {
port: 3000,
},
};
});
10 changes: 10 additions & 0 deletions samples/react/token-refresh/vitest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import react from "@vitejs/plugin-react";
import { defineConfig } from "vitest/config";

export default defineConfig({
plugins: [react()],
test: {
environment: "happy-dom",
setupFiles: ["./vitest.setup.ts"],
},
});
1 change: 1 addition & 0 deletions samples/react/token-refresh/vitest.setup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
import "@testing-library/jest-dom/vitest";
Loading