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:
16
portal/.env.example
Normal file
16
portal/.env.example
Normal 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
|
||||
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user