Skip to content
Open
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
17 changes: 5 additions & 12 deletions examples/tanstack-router-file-router/src/components/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@ import { Link } from "@tanstack/react-router";
import { useOidc } from "#/oidc";
import { isKeycloak, createKeycloakUtils } from "oidc-spa/keycloak";

import userPictureFallback from "./userPictureFallback.svg";

export function Header() {
return (
<header className="fixed inset-x-0 top-0 border-b border-slate-800 bg-slate-950/80 backdrop-blur">
Expand Down Expand Up @@ -54,17 +52,12 @@ const primaryButtonClasses =
"inline-flex items-center rounded-full bg-white/90 px-4 py-2 text-sm font-semibold text-slate-900 transition-colors hover:bg-white";

function LoggedInAuthButtons() {
const { decodedIdToken, logout, issuerUri, clientId, validRedirectUri } = useOidc({
const { user, logout, issuerUri, clientId, validRedirectUri } = useOidc({
assert: "user logged in"
});

const keycloakUtils = !isKeycloak({ issuerUri }) ? undefined : createKeycloakUtils({ issuerUri });

const profileImageSrc =
decodedIdToken.picture && decodedIdToken.picture.trim().length > 0
? decodedIdToken.picture
: userPictureFallback;

return (
<div className="flex items-center gap-4">
<a
Expand All @@ -76,8 +69,8 @@ function LoggedInAuthButtons() {
className="flex items-center gap-3 text-sm font-medium text-slate-200 hover:text-white"
>
<img
src={profileImageSrc}
alt={`${decodedIdToken.name}'s avatar`}
src={user.avatarImgUrl}
alt={`${user.displayName}'s avatar`}
className="h-10 w-10 shrink-0 rounded-full border border-slate-700 object-cover"
/>
</a>
Expand Down Expand Up @@ -116,13 +109,13 @@ function NotLoggedInAuthButtons() {
}

function AdminOnlyNavLink() {
const { isUserLoggedIn, decodedIdToken } = useOidc();
const { isUserLoggedIn, user } = useOidc();

if (!isUserLoggedIn) {
return null;
}

if (!decodedIdToken.realm_access?.roles.includes("realm-admin")) {
if (!user.isRealmAdmin) {
return null;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

Expand Down
50 changes: 14 additions & 36 deletions examples/tanstack-router-file-router/src/oidc.ts
Original file line number Diff line number Diff line change
@@ -1,65 +1,43 @@
import { oidcSpa } from "oidc-spa/react-spa";
import { z } from "zod";
import { type User, createUser, user_mock } from "./oidc.user";

export const {
bootstrapOidc,
useOidc,
getOidc,
enforceLogin,
// Wrap your whole application within this component in the root route
// Non blocking rendering is possible, see: https://docs.oidc-spa.dev/v/v10/features/non-blocking-rendering#react-spas
OidcInitializationGate
} = oidcSpa
.withExpectedDecodedIdTokenShape({
// Describe the expected shape of the ID Token.
// Think of `decodedIdToken` as your “user” object.
decodedIdTokenSchema: z.object({
sub: z.string(),
name: z.string(),
picture: z.string().optional(),
email: z.string().email().optional(),
preferred_username: z.string().optional(),
realm_access: z.object({ roles: z.array(z.string()) }).optional()
}),
// The mock user returned when the mock implementation is enabled.
decodedIdToken_mock: {
sub: "mock-user",
name: "John Doe",
preferred_username: "john.doe",
realm_access: {
roles: ["realm-admin"]
}
}
})
export const { bootstrapOidc, useOidc, getOidc, enforceLogin, OidcInitializationGate } = oidcSpa
.withUser<User>({ createUser, user_mock })
// See: https://docs.oidc-spa.dev/v/v10/features/auto-login#react-spa
//.withAutoLogin()
.createUtils();

/**
* This can be called immediately or after you've fetched some remote params.
* If you call this more than once the subsequent calls will be ignored.
* Call this immediately, or after you fetch remote configuration.
* If you call it more than once, the later calls are ignored.
*/
bootstrapOidc(
import.meta.env.VITE_OIDC_USE_MOCK === "true"
? {
// Mock mode: no requests to an auth server are made.
// Mock mode: no requests are sent to the auth server.
implementation: "mock",
isUserInitiallyLoggedIn: true
// You can also override mock user data here.
// You can also override `user_mock` here.
}
: {
implementation: "real",
// Configure your OIDC provider in `.env.local`
issuerUri: import.meta.env.VITE_OIDC_ISSUER_URI,
clientId: import.meta.env.VITE_OIDC_CLIENT_ID,
// Enable for detailed initialization and token lifecycle logs.
debugLogs: true
debugLogs: false
}
);

/**
* A convenience wrapper around `fetch()` that automatically
* attaches the access token as an Authorization header when the user is logged in.
*
* Usage:
* const response = await fetchWithAuth("/api/data");
*
* If you need to talk to more than one resource server read this:
* https://docs.oidc-spa.dev/v/v10/talking-to-multiple-apis-with-different-access-tokens
*/
export const fetchWithAuth: typeof fetch = async (input, init) => {
const oidc = await getOidc();
Expand Down
100 changes: 100 additions & 0 deletions examples/tanstack-router-file-router/src/oidc.user.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import type { CreateUser } from "oidc-spa/core";
import { z } from "zod";
import avatarFallbackSvgUrl from "./assets/avatarFallback.svg";

// App-level user shape exposed by `useOidc()`.
// You decide what an user should looks like!
export type User = {
id: string;
username: string;
displayName: string;
email: string | undefined;
avatarImgUrl: string;
isRealmAdmin: boolean;
userInfo: {
sub: string;
[claim: string]: unknown;
};
keycloakUserProfile?: import("oidc-spa/keycloak").KeycloakProfile;
};

// The function that oidc-spa will call to create the user object,
// gathering information from different sources depending of what you need.
export const createUser: CreateUser<User> = async ({
decodedIdToken: decodedIdToken_generic,
accessToken,
fetchUserInfo,
issuerUri
}) => {
/* ================= Possible source: ID token claims. ====================== */

const DecodedIdToken = z.object({
sub: z.string(),
name: z.string(),
picture: z.string().optional(),
email: z.string().email().optional(),
preferred_username: z.string().optional()
});

const decodedIdToken = DecodedIdToken.parse(decodedIdToken_generic);
Comment on lines +31 to +39

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

ls -la
git ls-files examples/tanstack-router-file-router/src/oidc.user.ts
sed -n '1,140p' examples/tanstack-router-file-router/src/oidc.user.ts

Repository: keycloakify/oidc-spa

Length of output: 4938


🏁 Script executed:

#!/bin/bash
set -euo pipefail

nl -ba examples/tanstack-router-file-router/src/oidc.user.ts | sed -n '1,130p'

Repository: keycloakify/oidc-spa

Length of output: 107


🏁 Script executed:

#!/bin/bash
set -euo pipefail

cat -n examples/tanstack-router-file-router/src/oidc.user.ts | sed -n '1,140p'

Repository: keycloakify/oidc-spa

Length of output: 4334


Don’t hard-require name for user construction.

  • examples/tanstack-router-file-router/src/oidc.user.ts lines 31-39: DecodedIdToken requires name: z.string(), so DecodedIdToken.parse(...) throws for valid tokens/providers that omit name.
  • lines 76-80: displayName: decodedIdToken.name also assumes name is always present.
  • lines 70-71: keycloakUtils?.fetchUserProfile(...) is awaited on the auth path; failures here will still fail createUser() even though this enrichment is optional.
Suggested fix
     const DecodedIdToken = z.object({
         sub: z.string(),
-        name: z.string(),
+        name: z.string().optional(),
         picture: z.string().optional(),
         email: z.string().email().optional(),
         preferred_username: z.string().optional()
     });
@@
-        displayName: decodedIdToken.name,
+        displayName:
+            decodedIdToken.name ??
+            decodedIdToken.preferred_username ??
+            decodedIdToken.sub,
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@examples/tanstack-router-file-router/src/oidc.user.ts` around lines 31 - 39,
DecodedIdToken schema currently requires name which causes
DecodedIdToken.parse(decodedIdToken_generic) to throw for providers that omit
it; update the DecodedIdToken zod schema (DecodedIdToken) to make name optional,
then update any user construction in createUser() to compute displayName
defensively (use decodedIdToken.name ?? decodedIdToken.preferred_username ??
decodedIdToken.email ?? a sensible fallback) instead of assuming name exists,
and wrap the optional enrichment call keycloakUtils?.fetchUserProfile(...) in a
try/catch so failures are logged/ignored and do not abort createUser()—ensure
decodedIdToken and displayName usage refers to the updated optional fields.


/* ================== Possible source: access token claims. ================== */
// This is pragmatic, but not textbook OIDC: clients should usually
// treat access tokens as opaque, and some providers do not issue JWTs.

const DecodedAccessToken = z.object({
realm_access: z.object({ roles: z.array(z.string()) }).optional()
});

const { decodeJwt } = await import("oidc-spa/decode-jwt");
const { isKeycloak } = await import("oidc-spa/keycloak");

const decodedAccessToken = !isKeycloak({ issuerUri })
? undefined
: DecodedAccessToken.parse(decodeJwt(accessToken));

/* ================= Possible source: your own API. ========================= */

// const { fetchWithAuth } = await import("./oidc");
// const userFromApi = await fetchWithAuth("/api/user").then(r => r.json());

/* ================= Possible source: the standard OIDC UserInfo endpoint. == */

const userInfo = await fetchUserInfo();

/* ================= Possible source: provider-specific endpoints. ========== */
const { createKeycloakUtils } = await import("oidc-spa/keycloak");

const keycloakUtils = isKeycloak({ issuerUri }) ? createKeycloakUtils({ issuerUri }) : undefined;

const keycloakUserProfile = await keycloakUtils?.fetchUserProfile({ accessToken });

Comment on lines +68 to +71

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate the target file
ls -la examples/tanstack-router-file-router/src/oidc.user.ts

# Show the relevant section around the reported lines
sed -n '1,140p' examples/tanstack-router-file-router/src/oidc.user.ts | nl -ba | sed -n '50,110p'

# Find where createUser() is defined/used in this example
rg -n "createUser\\b" examples/tanstack-router-file-router/src -S || true

# Find the Keycloak utils factory and fetchUserProfile definition
rg -n "isKeycloak\\b|createKeycloakUtils\\b|fetchUserProfile\\b" examples/tanstack-router-file-router/src -S || true

# Search entire repo for createKeycloakUtils/fetchUserProfile to understand behavior
rg -n "function\\s+createKeycloakUtils\\b|createKeycloakUtils\\b" -S . || true
rg -n "fetchUserProfile\\b" -S . || true

Repository: keycloakify/oidc-spa

Length of output: 228


🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="examples/tanstack-router-file-router/src/oidc.user.ts"

echo "== Target file =="
sed -n '1,140p' "$FILE" | awk '{printf "%d\t%s\n", NR,$0}' | sed -n '45,95p'

echo "== Search for createUser() usages in this example =="
rg -n "createUser\\b" examples/tanstack-router-file-router/src -S || true

echo "== Search for keycloak utils / fetchUserProfile in this example =="
rg -n "isKeycloak\\b|createKeycloakUtils\\b|fetchUserProfile\\b" examples/tanstack-router-file-router/src -S || true

echo "== Search repo for createKeycloakUtils / fetchUserProfile implementation =="
rg -n "createKeycloakUtils\\b" -S . || true
rg -n "fetchUserProfile\\b" -S . || true

Repository: keycloakify/oidc-spa

Length of output: 12766


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect createUser implementation in the example
FILE_EX="examples/tanstack-router-file-router/src/oidc.user.ts"
echo "== createUser() around line ~1-110 =="
sed -n '1,120p' "$FILE_EX" | awk '{printf "%5d\t%s\n", NR,$0}'

# Inspect keycloakUtils.ts fetchUserProfile implementation
FILE_KC="src/keycloak/keycloakUtils.ts"
echo "== createKeycloakUtils() / fetchUserProfile around line ~160-270 =="
sed -n '160,270p' "$FILE_KC" | awk '{printf "%5d\t%s\n", NR+160,$0}'

Repository: keycloakify/oidc-spa

Length of output: 7895


Guard Keycloak fetchUserProfile so it can’t break login initialization.

fetchUserProfile is implemented as a direct fetch(...).then(r => r.json()), and the current createUser() path awaits it without any try/catch—so a network/JSON/provider failure will reject createUser() even though keycloakUserProfile is optional.

File: examples/tanstack-router-file-router/src/oidc.user.ts (lines 68-71)

Suggested fix
-    const keycloakUserProfile = await keycloakUtils?.fetchUserProfile({ accessToken });
+    const keycloakUserProfile = await (async () => {
+        if (!keycloakUtils) {
+            return undefined;
+        }
+        try {
+            return await keycloakUtils.fetchUserProfile({ accessToken });
+        } catch {
+            return undefined;
+        }
+    })();
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const keycloakUtils = isKeycloak({ issuerUri }) ? createKeycloakUtils({ issuerUri }) : undefined;
const keycloakUserProfile = await keycloakUtils?.fetchUserProfile({ accessToken });
const keycloakUtils = isKeycloak({ issuerUri }) ? createKeycloakUtils({ issuerUri }) : undefined;
const keycloakUserProfile = await (async () => {
if (!keycloakUtils) {
return undefined;
}
try {
return await keycloakUtils.fetchUserProfile({ accessToken });
} catch {
return undefined;
}
})();
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@examples/tanstack-router-file-router/src/oidc.user.ts` around lines 68 - 71,
The call to keycloakUtils?.fetchUserProfile in the createUser flow can throw
(network/JSON errors) and must be guarded so it doesn't fail login
initialization: wrap the await keycloakUtils?.fetchUserProfile({ accessToken })
call in a try/catch (or otherwise catch the Promise) and on error set
keycloakUserProfile to undefined (or log the error) so createUser continues;
update the code paths that use keycloakUserProfile to handle undefined as
before. Ensure you modify the keycloakUtils/fetchUserProfile invocation in
createUser (the variable keycloakUserProfile) and not other unrelated calls.

/* ================== Merging =============================================== */
// Merge whichever sources you decided to use into the single
// `User` shape consumed by the rest of the app.

const user: User = {
id: decodedIdToken.sub,
username: decodedIdToken.preferred_username ?? decodedIdToken.sub,
displayName: decodedIdToken.name,
avatarImgUrl: decodedIdToken.picture || avatarFallbackSvgUrl,
email: decodedIdToken.email,
isRealmAdmin: decodedAccessToken?.realm_access?.roles.includes("realm-admin") ?? false,
userInfo,
keycloakUserProfile
};

return user;
};

// App-level user returned when the mock implementation is enabled.
export const user_mock: User = {
id: "mock-user",
username: "john.doe",
displayName: "John Doe",
email: undefined,
avatarImgUrl: avatarFallbackSvgUrl,
isRealmAdmin: true,
userInfo: { sub: "1234" },
keycloakUserProfile: undefined
};
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ export const Route = createFileRoute("/admin-only")({
loader: async () => {
const oidc = await getOidc({ assert: "user logged in" });

if (!oidc.getDecodedIdToken().realm_access?.roles.includes("realm-admin")) {
const { user } = await oidc.getUser();

if (!user.isRealmAdmin) {
throw new Error("unauthorized");
}
},
Expand Down Expand Up @@ -37,7 +39,8 @@ function AdminOnly() {
<div className="space-y-1">
<h1 className="text-xl font-semibold text-white">Administration Page</h1>
<p className="text-sm text-slate-300">
Access is granted because your ID token includes the <code>realm-admin</code> role.
Access is granted because your access token includes the <code>realm-admin</code>{" "}
role.
</p>
</div>

Expand Down
15 changes: 7 additions & 8 deletions examples/tanstack-router-file-router/src/routes/protected.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,9 @@ export const Route = createFileRoute("/protected")({

function Protected() {
// Safe to assume user is logged in here.
const { decodedIdToken, goToAuthServer, backFromAuthServer, issuerUri, clientId, validRedirectUri } =
useOidc({ assert: "user logged in" });
const { user, goToAuthServer, backFromAuthServer, issuerUri, clientId, validRedirectUri } = useOidc({
assert: "user logged in"
});

const keycloakUtils = isKeycloak({ issuerUri }) ? createKeycloakUtils({ issuerUri }) : undefined;

Expand All @@ -30,19 +31,17 @@ function Protected() {
<section className="space-y-6">
<div className="space-y-1">
<p className="text-sm uppercase tracking-wide text-slate-400">Protected content</p>
<h1 className="text-2xl font-semibold text-white">Hello {decodedIdToken.name}</h1>
<h1 className="text-2xl font-semibold text-white">Hello {user.displayName}</h1>
<p className="text-base text-slate-300">
These actions come directly from your identity provider via oidc-spa.
</p>
</div>

<div className="rounded-2xl border border-slate-800 bg-slate-900 p-6 shadow-sm shadow-slate-950/40">
<dl className="grid gap-2 text-sm text-slate-400">
<InfoRow label="Subject">{decodedIdToken.sub}</InfoRow>
{decodedIdToken.email && <InfoRow label="Email">{decodedIdToken.email}</InfoRow>}
{decodedIdToken.preferred_username && (
<InfoRow label="Username">{decodedIdToken.preferred_username}</InfoRow>
)}
<InfoRow label="id">{user.id}</InfoRow>
{user.email && <InfoRow label="Email">{user.email}</InfoRow>}
{user.username && <InfoRow label="Username">{user.username}</InfoRow>}
</dl>

{keycloakUtils && (
Expand Down
12 changes: 5 additions & 7 deletions examples/tanstack-start/src/components/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,6 @@ import { ChevronDown, ChevronRight, Home, Menu, Server, X } from "lucide-react";
import { useOidc } from "#/oidc";
import { isKeycloak, createKeycloakUtils } from "oidc-spa/keycloak";

import userPictureFallback from "./userPictureFallback.svg";

export default function Header() {
const [isOpen, setIsOpen] = useState(false);
const [groupedExpanded, setGroupedExpanded] = useState<Record<string, boolean>>({});
Expand Down Expand Up @@ -189,7 +187,7 @@ function AuthButtons(props: { className?: string }) {
}

function LoggedInAuthButton() {
const { decodedIdToken, logout } = useOidc({ assert: "user logged in" });
const { user, logout } = useOidc({ assert: "user logged in" });

return (
<div className="flex items-center gap-4">
Expand All @@ -198,8 +196,8 @@ function LoggedInAuthButton() {
className="flex items-center gap-3 text-white font-semibold hover:text-cyan-300 transition-colors"
>
<img
src={decodedIdToken.picture || userPictureFallback}
alt={`${decodedIdToken.name}'s avatar`}
src={user.avatarImgUrl}
alt={`${user.displayName}'s avatar`}
className="w-10 h-10 rounded-full object-cover border border-cyan-500/60 shadow-lg shrink-0"
/>
</Link>
Expand Down Expand Up @@ -246,13 +244,13 @@ function NotLoggedInAuthButton() {
function AdminOnlyNavLink(props: { onClick: () => void }) {
const { onClick } = props;

const { isUserLoggedIn, decodedIdToken } = useOidc();
const { isUserLoggedIn, user } = useOidc();

if (!isUserLoggedIn) {
return null;
}

if (!decodedIdToken.realm_access?.roles.includes("realm-admin")) {
if (!user.isKeycloakRealmAdmin) {
return null;
}

Expand Down
20 changes: 3 additions & 17 deletions examples/tanstack-start/src/oidc.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { oidcSpa } from "oidc-spa/react-tanstack-start";
import { z } from "zod";
import { type User, createUser, user_mock } from "./oidc.user";

export const {
bootstrapOidc,
Expand All @@ -12,22 +13,7 @@ export const {
oidcFnMiddleware,
oidcRequestMiddleware
} = oidcSpa
.withExpectedDecodedIdTokenShape({
decodedIdTokenSchema: z.object({
name: z.string(),
picture: z.string().optional(),
email: z.email().optional(),
preferred_username: z.string().optional(),
realm_access: z.object({ roles: z.array(z.string()) }).optional()
}),
decodedIdToken_mock: {
name: "John Doe",
preferred_username: "john.doe",
realm_access: {
roles: ["realm-admin"]
}
}
})
.withUser<User>({ createUser, user_mock })
.withAccessTokenValidation({
type: "RFC 9068: JSON Web Token (JWT) Profile for OAuth 2.0 Access Tokens",
expectedAudience: (/*{ paramsOfBootstrap, process }*/) => "account",
Expand All @@ -36,7 +22,7 @@ export const {
realm_access: z.object({ roles: z.array(z.string()) }).optional()
}),
accessTokenClaims_mock: {
sub: "u123",
sub: "mock-user-id",
realm_access: {
roles: ["realm-admin"]
}
Expand Down
Loading
Loading