Skip to content
This repository was archived by the owner on Apr 13, 2026. It is now read-only.

Commit 884fcc7

Browse files
authored
Add Keycloak and Okta SSO configuration and sign-in UI (#71)
### Motivation - Expand SSO support beyond Google so teams can use Keycloak or Okta as OAuth providers. - Surface required env variables and documentation so operators can configure Keycloak/Okta in dev and production environments. ### Description - Register Keycloak and Okta providers in the NextAuth config and treat them alongside Google for OAuth validation (`src/server/auth/config.ts`). - Extend the server env schema and runtime mapping to include `KEYCLOAK_*` and `OKTA_*` variables (`src/env.ts`). - Update the sign-in UI and page to expose provider-specific buttons and enablement flags for Keycloak and Okta (`src/features/shared/auth/sign-in-page.tsx`, `src/app/(public-routes)/auth/signin/page.tsx`). - Add commented example env entries to `.env.example-dev` and `deploy/docker/.env.example-prod`, and clarify supported SSO providers in `docs/installation.md`. ### Testing - Ran `npm run check` (ESLint + TypeScript checks) which completed successfully. - Ran `npm run test` which failed due to Prisma being unable to connect to a local database (`localhost:5432`), so full test-suite validations requiring the DB were not executed. ------ [Codex Task](https://chatgpt.com/codex/tasks/task_e_696b2ca854e08323b6e0bdcf3523c9dd)
1 parent 80c3260 commit 884fcc7

7 files changed

Lines changed: 100 additions & 17 deletions

File tree

.env.example-dev

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,15 @@ AUTH_SECRET="REPLACE_WITH_A_SECURE_RANDOM_VALUE"
1717
AUTH_URL=http://localhost:3000
1818
AUTH_TRUST_HOST=true
1919

20-
# Optional: Google OAuth (if configured via environment)
21-
# Registers Google when provided; access is controlled by pre-provisioned users.
20+
# SSO Configuration
2221
#GOOGLE_CLIENT_ID=
2322
#GOOGLE_CLIENT_SECRET=
23+
#KEYCLOAK_CLIENT_ID=
24+
#KEYCLOAK_CLIENT_SECRET=
25+
#KEYCLOAK_ISSUER=
26+
#OKTA_CLIENT_ID=
27+
#OKTA_CLIENT_SECRET=
28+
#OKTA_ISSUER=
2429

2530
# Optional: enable demo login (default: disabled)
2631
# Set to "true" to expose a demo admin login button on the sign-in screen.

deploy/docker/.env.example-prod

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,12 @@ ENABLE_DEMO_MODE=false
1515
# SSO Configuration
1616
#GOOGLE_CLIENT_ID=
1717
#GOOGLE_CLIENT_SECRET=
18+
#KEYCLOAK_CLIENT_ID=
19+
#KEYCLOAK_CLIENT_SECRET=
20+
#KEYCLOAK_ISSUER=
21+
#OKTA_CLIENT_ID=
22+
#OKTA_CLIENT_SECRET=
23+
#OKTA_ISSUER=
1824

1925
# Optional: logging level (default: debug in dev, info in prod)
2026
#LOG_LEVEL=info

docs/installation.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,9 @@ Minimum values to edit:
2626

2727
### Choose authentication mode
2828

29-
RTAP supports SSO or a demo login button.
29+
RTAP supports SSO or a demo login button. Supported SSO providers today are Google, Keycloak, and Okta. If you need another provider, open an issue and we can add it.
3030

31-
- **SSO (recommended):** configure your provider's details (like Google client ID/secret) using the variable names provided in the .env file.
31+
- **SSO (recommended):** configure your provider's details (like client ID/secret + issuer when required) using the variable names provided in the .env file.
3232
- **Demo mode:** set `ENABLE_DEMO_MODE=true`. This exposes a “Sign in as Demo Admin” button and **anyone with access to the sign-in page can log in without an account**. Use only for isolated testing or demos.
3333

3434
For Google SSO, configure the following in the Google Cloud console:

src/app/(public-routes)/auth/signin/page.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,14 @@ export default async function SignInPage(props: { searchParams?: Promise<{ callb
1212
const { callbackUrl = "/", error } = (await props.searchParams) ?? {};
1313
const demoEnabled = env.ENABLE_DEMO_MODE === "true";
1414
const googleEnabled = Boolean(env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET);
15+
const keycloakEnabled = Boolean(env.KEYCLOAK_CLIENT_ID && env.KEYCLOAK_CLIENT_SECRET && env.KEYCLOAK_ISSUER);
16+
const oktaEnabled = Boolean(env.OKTA_CLIENT_ID && env.OKTA_CLIENT_SECRET && env.OKTA_ISSUER);
1517

1618
return (
1719
<SignInPageClient
1820
googleEnabled={googleEnabled}
21+
keycloakEnabled={keycloakEnabled}
22+
oktaEnabled={oktaEnabled}
1923
demoEnabled={demoEnabled}
2024
callbackUrl={callbackUrl}
2125
initialError={error}

src/env.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,14 @@ export const env = createEnv({
2525
// Optional: Google OAuth client credentials (registers provider when present)
2626
GOOGLE_CLIENT_ID: z.string().optional(),
2727
GOOGLE_CLIENT_SECRET: z.string().optional(),
28+
// Optional: Keycloak OAuth client credentials (registers provider when present)
29+
KEYCLOAK_CLIENT_ID: z.string().optional(),
30+
KEYCLOAK_CLIENT_SECRET: z.string().optional(),
31+
KEYCLOAK_ISSUER: z.string().optional(),
32+
// Optional: Okta OAuth client credentials (registers provider when present)
33+
OKTA_CLIENT_ID: z.string().optional(),
34+
OKTA_CLIENT_SECRET: z.string().optional(),
35+
OKTA_ISSUER: z.string().optional(),
2836
},
2937

3038
/**
@@ -49,6 +57,12 @@ export const env = createEnv({
4957
ENABLE_DEMO_MODE: process.env.ENABLE_DEMO_MODE,
5058
GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID,
5159
GOOGLE_CLIENT_SECRET: process.env.GOOGLE_CLIENT_SECRET,
60+
KEYCLOAK_CLIENT_ID: process.env.KEYCLOAK_CLIENT_ID,
61+
KEYCLOAK_CLIENT_SECRET: process.env.KEYCLOAK_CLIENT_SECRET,
62+
KEYCLOAK_ISSUER: process.env.KEYCLOAK_ISSUER,
63+
OKTA_CLIENT_ID: process.env.OKTA_CLIENT_ID,
64+
OKTA_CLIENT_SECRET: process.env.OKTA_CLIENT_SECRET,
65+
OKTA_ISSUER: process.env.OKTA_ISSUER,
5266
},
5367
/**
5468
* Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially

src/features/shared/auth/sign-in-page.tsx

Lines changed: 42 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,31 @@ import { Button, Card, CardContent, CardHeader, CardTitle } from "@components/ui
77

88
interface Props {
99
googleEnabled: boolean;
10+
keycloakEnabled: boolean;
11+
oktaEnabled: boolean;
1012
demoEnabled: boolean;
1113
callbackUrl: string;
1214
initialError?: string;
1315
}
1416

15-
export default function SignInPageClient({ googleEnabled, demoEnabled, callbackUrl, initialError }: Props) {
17+
type OAuthProviderId = "google" | "keycloak" | "okta";
18+
19+
const oauthOptions: Array<{ id: OAuthProviderId; label: string }> = [
20+
{ id: "google", label: "Continue with Google" },
21+
{ id: "keycloak", label: "Continue with Keycloak" },
22+
{ id: "okta", label: "Continue with Okta" },
23+
];
24+
25+
export default function SignInPageClient({
26+
googleEnabled,
27+
keycloakEnabled,
28+
oktaEnabled,
29+
demoEnabled,
30+
callbackUrl,
31+
initialError,
32+
}: Props) {
1633
const router = useRouter();
17-
const [loading, setLoading] = useState<"demo" | "google" | null>(null);
34+
const [loading, setLoading] = useState<"demo" | OAuthProviderId | null>(null);
1835
const [error, setError] = useState<string | null>(initialError ?? null);
1936

2037
const toMessage = (err?: string | null) => {
@@ -45,11 +62,11 @@ export default function SignInPageClient({ googleEnabled, demoEnabled, callbackU
4562
}
4663
};
4764

48-
const handleGoogle = async () => {
49-
setLoading("google");
65+
const handleOAuth = async (provider: OAuthProviderId) => {
66+
setLoading(provider);
5067
setError(null);
5168
try {
52-
const res = await signIn("google", { callbackUrl, redirect: false });
69+
const res = await signIn(provider, { callbackUrl, redirect: false });
5370
if (res?.error) {
5471
setError(toMessage(res.error));
5572
} else if (res?.url) {
@@ -60,7 +77,15 @@ export default function SignInPageClient({ googleEnabled, demoEnabled, callbackU
6077
}
6178
};
6279

63-
const nothingEnabled = !googleEnabled && !demoEnabled;
80+
const oauthEnabled: Record<OAuthProviderId, boolean> = {
81+
google: googleEnabled,
82+
keycloak: keycloakEnabled,
83+
okta: oktaEnabled,
84+
};
85+
86+
const enabledOauthOptions = oauthOptions.filter((option) => oauthEnabled[option.id]);
87+
const nothingEnabled = enabledOauthOptions.length === 0 && !demoEnabled;
88+
const showSeparator = demoEnabled && enabledOauthOptions.length > 0;
6489

6590
return (
6691
<div className="min-h-screen grid place-items-center p-6">
@@ -90,7 +115,7 @@ export default function SignInPageClient({ googleEnabled, demoEnabled, callbackU
90115
</Button>
91116
)}
92117

93-
{googleEnabled && demoEnabled && (
118+
{showSeparator && (
94119
<div className="relative text-center">
95120
<div className="h-px bg-[var(--color-border)]" />
96121
<span className="inline-block px-2 text-xs text-[var(--color-text-muted)] bg-[var(--color-surface)] -mt-2 relative">
@@ -99,11 +124,17 @@ export default function SignInPageClient({ googleEnabled, demoEnabled, callbackU
99124
</div>
100125
)}
101126

102-
{googleEnabled && (
103-
<Button variant="glass" className="w-full" onClick={handleGoogle} disabled={loading !== null}>
104-
{loading === "google" ? "Redirecting…" : "Continue with Google"}
127+
{enabledOauthOptions.map((option) => (
128+
<Button
129+
key={option.id}
130+
variant="glass"
131+
className="w-full"
132+
onClick={() => void handleOAuth(option.id)}
133+
disabled={loading !== null}
134+
>
135+
{loading === option.id ? "Redirecting…" : option.label}
105136
</Button>
106-
)}
137+
))}
107138

108139
{nothingEnabled && (
109140
<div className="text-sm text-[var(--color-text-secondary)]">

src/server/auth/config.ts

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import { type DefaultSession, type NextAuthConfig } from "next-auth";
22
import type { Adapter } from "next-auth/adapters";
33
import type { JWT as NextAuthJWT } from "next-auth/jwt";
44
import GoogleProvider from "next-auth/providers/google";
5+
import KeycloakProvider from "next-auth/providers/keycloak";
6+
import OktaProvider from "next-auth/providers/okta";
57
import CredentialsProvider from "next-auth/providers/credentials";
68
import { PrismaAdapter } from "@auth/prisma-adapter";
79
import { type UserRole } from "@prisma/client";
@@ -40,6 +42,7 @@ declare module "@auth/core/adapters" {
4042
type AugmentedJWT = NextAuthJWT & { role?: UserRole };
4143

4244
const demoModeEnabled = env.ENABLE_DEMO_MODE === "true";
45+
const oauthProviders = new Set(["google", "keycloak", "okta"]);
4346

4447
const isRecord = (value: unknown): value is Record<string, unknown> =>
4548
typeof value === "object" && value !== null;
@@ -189,7 +192,7 @@ export const authConfig = {
189192
}),
190193
]
191194
: []),
192-
// Conditionally register Google provider when env credentials are available.
195+
// Conditionally register providers when env credentials are available.
193196
// Actual enablement is enforced via DB in the signIn callback/UI.
194197
...(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET
195198
? [
@@ -202,6 +205,26 @@ export const authConfig = {
202205
}),
203206
]
204207
: []),
208+
...(process.env.KEYCLOAK_CLIENT_ID && process.env.KEYCLOAK_CLIENT_SECRET && process.env.KEYCLOAK_ISSUER
209+
? [
210+
KeycloakProvider({
211+
clientId: process.env.KEYCLOAK_CLIENT_ID,
212+
clientSecret: process.env.KEYCLOAK_CLIENT_SECRET,
213+
issuer: process.env.KEYCLOAK_ISSUER,
214+
allowDangerousEmailAccountLinking: true,
215+
}),
216+
]
217+
: []),
218+
...(process.env.OKTA_CLIENT_ID && process.env.OKTA_CLIENT_SECRET && process.env.OKTA_ISSUER
219+
? [
220+
OktaProvider({
221+
clientId: process.env.OKTA_CLIENT_ID,
222+
clientSecret: process.env.OKTA_CLIENT_SECRET,
223+
issuer: process.env.OKTA_ISSUER,
224+
allowDangerousEmailAccountLinking: true,
225+
}),
226+
]
227+
: []),
205228
],
206229
session: {
207230
strategy: "jwt",
@@ -227,7 +250,7 @@ export const authConfig = {
227250
return Boolean(resolved);
228251
}
229252

230-
if (provider === "google") {
253+
if (oauthProviders.has(provider)) {
231254
const emailAddr = (user as { email?: string | null } | undefined)?.email?.toLowerCase();
232255
if (!emailAddr) return false;
233256
try {

0 commit comments

Comments
 (0)