fix(portal): NextAuth redirect loop and production NEXTAUTH_URL docs

- Remove pages.signIn pointed at API route; normalize redirects for LAN callbacks
- signIn callbackUrl /; auth error page Try Again to /
- Add .env.example; README documents public NEXTAUTH_URL (sankofa.nexus)

Made-with: Cursor
This commit is contained in:
defiQUG
2026-03-26 18:56:56 -07:00
parent 0a7b4f320b
commit 28892a4ce4
5 changed files with 68 additions and 19 deletions

16
portal/.env.example Normal file
View File

@@ -0,0 +1,16 @@
# Copy to .env.local — never commit .env.local.
# Public origin must match the browser URL (NPM host), not the LAN upstream IP.
# Apex: https://sankofa.nexus — or use https://portal.sankofa.nexus if that is your vhost.
NEXTAUTH_URL=https://sankofa.nexus
NEXTAUTH_SECRET=generate-with-openssl-rand-base64-32
KEYCLOAK_URL=https://keycloak.sankofa.nexus
KEYCLOAK_REALM=your-realm
KEYCLOAK_CLIENT_ID=portal-client
KEYCLOAK_CLIENT_SECRET=your-client-secret
NEXT_PUBLIC_CROSSPLANE_API=https://crossplane-api.crossplane-system.svc.cluster.local
NEXT_PUBLIC_ARGOCD_URL=https://argocd.sankofa.nexus
NEXT_PUBLIC_GRAFANA_URL=https://grafana.sankofa.nexus
NEXT_PUBLIC_LOKI_URL=https://loki.monitoring.svc.cluster.local:3100

View File

@@ -42,7 +42,7 @@ npm install
### Configuration
Copy `.env.example` to `.env.local` and configure:
Copy [`.env.example`](.env.example) to `.env.local` and configure:
```env
KEYCLOAK_URL=https://keycloak.sankofa.nexus
@@ -55,7 +55,8 @@ NEXT_PUBLIC_ARGOCD_URL=https://argocd.sankofa.nexus
NEXT_PUBLIC_GRAFANA_URL=https://grafana.sankofa.nexus
NEXT_PUBLIC_LOKI_URL=https://loki.monitoring.svc.cluster.local:3100
NEXTAUTH_URL=https://portal.sankofa.nexus
# Must match the browser URL (NPM vhost), not the LAN upstream — e.g. https://sankofa.nexus
NEXTAUTH_URL=https://sankofa.nexus
NEXTAUTH_SECRET=your-nextauth-secret
```

View File

@@ -35,7 +35,7 @@ function AuthErrorContent() {
Go Home
</Link>
<Link
href="/api/auth/signin"
href="/"
className="px-6 py-3 bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition-colors inline-block"
>
Try Again

View File

@@ -10,9 +10,9 @@ export default function Home() {
if (status === 'loading') {
return (
<div className="flex min-h-screen items-center justify-center bg-gray-900">
<div className="flex min-h-screen items-center justify-center bg-gray-950 px-4">
<div className="text-center">
<div className="mb-4 h-8 w-8 animate-spin rounded-full border-4 border-gray-300 border-t-blue-600 mx-auto"></div>
<div className="mx-auto mb-4 h-8 w-8 animate-spin rounded-full border-4 border-gray-600 border-t-orange-500" />
<p className="text-gray-400">Loading...</p>
</div>
</div>
@@ -21,19 +21,25 @@ export default function Home() {
if (status === 'unauthenticated') {
return (
<div className="flex min-h-screen items-center justify-center bg-gray-900">
<div className="text-center max-w-md mx-auto p-8">
<h1 className="text-2xl font-bold text-white mb-4">Welcome to Portal</h1>
<p className="text-gray-400 mb-6">Please sign in to continue</p>
<div className="flex min-h-screen flex-col items-center justify-center bg-gray-950 px-4 py-12">
<div className="w-full max-w-md rounded-2xl border border-gray-800 bg-gray-900/80 p-8 shadow-xl shadow-black/40 backdrop-blur-sm">
<p className="mb-1 text-center text-sm font-medium uppercase tracking-wide text-orange-400">
Sankofa Phoenix
</p>
<h1 className="mb-2 text-center text-2xl font-bold text-white">Welcome to Portal</h1>
<p className="mb-8 text-center text-gray-400">Sign in to open Nexus Console.</p>
<button
onClick={() => signIn()}
className="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
type="button"
onClick={() => signIn(undefined, { callbackUrl: '/' })}
className="w-full rounded-lg bg-gradient-to-r from-orange-500 to-amber-500 px-6 py-3 font-semibold text-gray-950 shadow-lg transition hover:from-orange-400 hover:to-amber-400 focus:outline-none focus:ring-2 focus:ring-orange-400 focus:ring-offset-2 focus:ring-offset-gray-900"
>
Sign In
</button>
<p className="text-sm text-gray-500 mt-4">
Development mode: Use any email/password
</p>
{process.env.NODE_ENV === 'development' && (
<p className="mt-6 text-center text-xs text-gray-500">
Development: use any email/password with your dev IdP configuration.
</p>
)}
</div>
</div>
);

View File

@@ -2,6 +2,24 @@ import { NextAuthOptions } from 'next-auth';
import CredentialsProvider from 'next-auth/providers/credentials';
import KeycloakProvider from 'next-auth/providers/keycloak';
/** Prefer NEXTAUTH_URL (public origin behind NPM) so redirects match the browser host. */
function canonicalAuthBaseUrl(fallback: string): string {
const raw = process.env.NEXTAUTH_URL?.trim();
if (!raw) return fallback.replace(/\/$/, '');
try {
return new URL(raw).origin;
} catch {
return fallback.replace(/\/$/, '');
}
}
function isPrivateOrLocalHost(hostname: string): boolean {
if (hostname === 'localhost' || hostname === '127.0.0.1') return true;
if (hostname.startsWith('192.168.')) return true;
if (hostname.startsWith('10.')) return true;
return /^172\.(1[6-9]|2\d|3[0-1])\./.test(hostname);
}
// Check if Keycloak is configured
const isKeycloakConfigured =
process.env.KEYCLOAK_URL &&
@@ -48,10 +66,18 @@ export const authOptions: NextAuthOptions = {
providers,
callbacks: {
async redirect({ url, baseUrl }) {
// Prevent redirect loops - only allow redirects within the same origin
if (url.startsWith('/')) return `${baseUrl}${url}`;
if (new URL(url).origin === baseUrl) return url;
return baseUrl;
const canonical = canonicalAuthBaseUrl(baseUrl);
if (url.startsWith('/')) return `${canonical}${url}`;
try {
const target = new URL(url);
if (target.origin === canonical) return url;
if (isPrivateOrLocalHost(target.hostname)) {
return `${canonical}${target.pathname}${target.search}${target.hash}`;
}
return canonical;
} catch {
return canonical;
}
},
async jwt({ token, account, profile, user }) {
if (account) {
@@ -91,8 +117,8 @@ export const authOptions: NextAuthOptions = {
return session;
},
},
// Do not set pages.signIn to /api/auth/signin — that is the API handler and causes ERR_TOO_MANY_REDIRECTS.
pages: {
signIn: '/api/auth/signin',
error: '/api/auth/error',
},
session: {