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
9 changes: 7 additions & 2 deletions .env.example-dev
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,15 @@ AUTH_SECRET="REPLACE_WITH_A_SECURE_RANDOM_VALUE"
AUTH_URL=http://localhost:3000
AUTH_TRUST_HOST=true

# Optional: Google OAuth (if configured via environment)
# Registers Google when provided; access is controlled by pre-provisioned users.
# SSO Configuration
#GOOGLE_CLIENT_ID=
#GOOGLE_CLIENT_SECRET=
#KEYCLOAK_CLIENT_ID=
#KEYCLOAK_CLIENT_SECRET=
#KEYCLOAK_ISSUER=
#OKTA_CLIENT_ID=
#OKTA_CLIENT_SECRET=
#OKTA_ISSUER=

# Optional: enable demo login (default: disabled)
# Set to "true" to expose a demo admin login button on the sign-in screen.
Expand Down
6 changes: 6 additions & 0 deletions deploy/docker/.env.example-prod
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@ ENABLE_DEMO_MODE=false
# SSO Configuration
#GOOGLE_CLIENT_ID=
#GOOGLE_CLIENT_SECRET=
#KEYCLOAK_CLIENT_ID=
#KEYCLOAK_CLIENT_SECRET=
#KEYCLOAK_ISSUER=
#OKTA_CLIENT_ID=
#OKTA_CLIENT_SECRET=
#OKTA_ISSUER=

# Optional: logging level (default: debug in dev, info in prod)
#LOG_LEVEL=info
Expand Down
4 changes: 2 additions & 2 deletions docs/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,9 @@ Minimum values to edit:

### Choose authentication mode

RTAP supports SSO or a demo login button.
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.

- **SSO (recommended):** configure your provider's details (like Google client ID/secret) using the variable names provided in the .env file.
- **SSO (recommended):** configure your provider's details (like client ID/secret + issuer when required) using the variable names provided in the .env file.
- **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.

For Google SSO, configure the following in the Google Cloud console:
Expand Down
4 changes: 4 additions & 0 deletions src/app/(public-routes)/auth/signin/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,14 @@ export default async function SignInPage(props: { searchParams?: Promise<{ callb
const { callbackUrl = "/", error } = (await props.searchParams) ?? {};
const demoEnabled = env.ENABLE_DEMO_MODE === "true";
const googleEnabled = Boolean(env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET);
const keycloakEnabled = Boolean(env.KEYCLOAK_CLIENT_ID && env.KEYCLOAK_CLIENT_SECRET && env.KEYCLOAK_ISSUER);
const oktaEnabled = Boolean(env.OKTA_CLIENT_ID && env.OKTA_CLIENT_SECRET && env.OKTA_ISSUER);

return (
<SignInPageClient
googleEnabled={googleEnabled}
keycloakEnabled={keycloakEnabled}
oktaEnabled={oktaEnabled}
demoEnabled={demoEnabled}
callbackUrl={callbackUrl}
initialError={error}
Expand Down
14 changes: 14 additions & 0 deletions src/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,14 @@ export const env = createEnv({
// Optional: Google OAuth client credentials (registers provider when present)
GOOGLE_CLIENT_ID: z.string().optional(),
GOOGLE_CLIENT_SECRET: z.string().optional(),
// Optional: Keycloak OAuth client credentials (registers provider when present)
KEYCLOAK_CLIENT_ID: z.string().optional(),
KEYCLOAK_CLIENT_SECRET: z.string().optional(),
KEYCLOAK_ISSUER: z.string().optional(),
// Optional: Okta OAuth client credentials (registers provider when present)
OKTA_CLIENT_ID: z.string().optional(),
OKTA_CLIENT_SECRET: z.string().optional(),
OKTA_ISSUER: z.string().optional(),
},

/**
Expand All @@ -49,6 +57,12 @@ export const env = createEnv({
ENABLE_DEMO_MODE: process.env.ENABLE_DEMO_MODE,
GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID,
GOOGLE_CLIENT_SECRET: process.env.GOOGLE_CLIENT_SECRET,
KEYCLOAK_CLIENT_ID: process.env.KEYCLOAK_CLIENT_ID,
KEYCLOAK_CLIENT_SECRET: process.env.KEYCLOAK_CLIENT_SECRET,
KEYCLOAK_ISSUER: process.env.KEYCLOAK_ISSUER,
OKTA_CLIENT_ID: process.env.OKTA_CLIENT_ID,
OKTA_CLIENT_SECRET: process.env.OKTA_CLIENT_SECRET,
OKTA_ISSUER: process.env.OKTA_ISSUER,
},
/**
* Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially
Expand Down
53 changes: 42 additions & 11 deletions src/features/shared/auth/sign-in-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,31 @@ import { Button, Card, CardContent, CardHeader, CardTitle } from "@components/ui

interface Props {
googleEnabled: boolean;
keycloakEnabled: boolean;
oktaEnabled: boolean;
demoEnabled: boolean;
callbackUrl: string;
initialError?: string;
}

export default function SignInPageClient({ googleEnabled, demoEnabled, callbackUrl, initialError }: Props) {
type OAuthProviderId = "google" | "keycloak" | "okta";

const oauthOptions: Array<{ id: OAuthProviderId; label: string }> = [
{ id: "google", label: "Continue with Google" },
{ id: "keycloak", label: "Continue with Keycloak" },
{ id: "okta", label: "Continue with Okta" },
];

export default function SignInPageClient({
googleEnabled,
keycloakEnabled,
oktaEnabled,
demoEnabled,
callbackUrl,
initialError,
}: Props) {
const router = useRouter();
const [loading, setLoading] = useState<"demo" | "google" | null>(null);
const [loading, setLoading] = useState<"demo" | OAuthProviderId | null>(null);
const [error, setError] = useState<string | null>(initialError ?? null);

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

const handleGoogle = async () => {
setLoading("google");
const handleOAuth = async (provider: OAuthProviderId) => {
setLoading(provider);
setError(null);
try {
const res = await signIn("google", { callbackUrl, redirect: false });
const res = await signIn(provider, { callbackUrl, redirect: false });
if (res?.error) {
setError(toMessage(res.error));
} else if (res?.url) {
Expand All @@ -60,7 +77,15 @@ export default function SignInPageClient({ googleEnabled, demoEnabled, callbackU
}
};

const nothingEnabled = !googleEnabled && !demoEnabled;
const oauthEnabled: Record<OAuthProviderId, boolean> = {
google: googleEnabled,
keycloak: keycloakEnabled,
okta: oktaEnabled,
};

const enabledOauthOptions = oauthOptions.filter((option) => oauthEnabled[option.id]);
const nothingEnabled = enabledOauthOptions.length === 0 && !demoEnabled;
const showSeparator = demoEnabled && enabledOauthOptions.length > 0;

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

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

{googleEnabled && (
<Button variant="glass" className="w-full" onClick={handleGoogle} disabled={loading !== null}>
{loading === "google" ? "Redirecting…" : "Continue with Google"}
{enabledOauthOptions.map((option) => (
<Button
key={option.id}
variant="glass"
className="w-full"
onClick={() => void handleOAuth(option.id)}
disabled={loading !== null}
>
{loading === option.id ? "Redirecting…" : option.label}
</Button>
)}
))}

{nothingEnabled && (
<div className="text-sm text-[var(--color-text-secondary)]">
Expand Down
27 changes: 25 additions & 2 deletions src/server/auth/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { type DefaultSession, type NextAuthConfig } from "next-auth";
import type { Adapter } from "next-auth/adapters";
import type { JWT as NextAuthJWT } from "next-auth/jwt";
import GoogleProvider from "next-auth/providers/google";
import KeycloakProvider from "next-auth/providers/keycloak";
import OktaProvider from "next-auth/providers/okta";
import CredentialsProvider from "next-auth/providers/credentials";
import { PrismaAdapter } from "@auth/prisma-adapter";
import { type UserRole } from "@prisma/client";
Expand Down Expand Up @@ -40,6 +42,7 @@ declare module "@auth/core/adapters" {
type AugmentedJWT = NextAuthJWT & { role?: UserRole };

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

const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null;
Expand Down Expand Up @@ -189,7 +192,7 @@ export const authConfig = {
}),
]
: []),
// Conditionally register Google provider when env credentials are available.
// Conditionally register providers when env credentials are available.
// Actual enablement is enforced via DB in the signIn callback/UI.
...(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET
? [
Expand All @@ -202,6 +205,26 @@ export const authConfig = {
}),
]
: []),
...(process.env.KEYCLOAK_CLIENT_ID && process.env.KEYCLOAK_CLIENT_SECRET && process.env.KEYCLOAK_ISSUER
? [
KeycloakProvider({
clientId: process.env.KEYCLOAK_CLIENT_ID,
clientSecret: process.env.KEYCLOAK_CLIENT_SECRET,
issuer: process.env.KEYCLOAK_ISSUER,
allowDangerousEmailAccountLinking: true,
}),
]
: []),
...(process.env.OKTA_CLIENT_ID && process.env.OKTA_CLIENT_SECRET && process.env.OKTA_ISSUER
? [
OktaProvider({
clientId: process.env.OKTA_CLIENT_ID,
clientSecret: process.env.OKTA_CLIENT_SECRET,
issuer: process.env.OKTA_ISSUER,
allowDangerousEmailAccountLinking: true,
}),
]
: []),
],
session: {
strategy: "jwt",
Expand All @@ -227,7 +250,7 @@ export const authConfig = {
return Boolean(resolved);
}

if (provider === "google") {
if (oauthProviders.has(provider)) {
const emailAddr = (user as { email?: string | null } | undefined)?.email?.toLowerCase();
if (!emailAddr) return false;
try {
Expand Down
Loading