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
|
### Configuration
|
||||||
|
|
||||||
Copy `.env.example` to `.env.local` and configure:
|
Copy [`.env.example`](.env.example) to `.env.local` and configure:
|
||||||
|
|
||||||
```env
|
```env
|
||||||
KEYCLOAK_URL=https://keycloak.sankofa.nexus
|
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_GRAFANA_URL=https://grafana.sankofa.nexus
|
||||||
NEXT_PUBLIC_LOKI_URL=https://loki.monitoring.svc.cluster.local:3100
|
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
|
NEXTAUTH_SECRET=your-nextauth-secret
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ function AuthErrorContent() {
|
|||||||
Go Home
|
Go Home
|
||||||
</Link>
|
</Link>
|
||||||
<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"
|
className="px-6 py-3 bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition-colors inline-block"
|
||||||
>
|
>
|
||||||
Try Again
|
Try Again
|
||||||
|
|||||||
@@ -10,9 +10,9 @@ export default function Home() {
|
|||||||
|
|
||||||
if (status === 'loading') {
|
if (status === 'loading') {
|
||||||
return (
|
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="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>
|
<p className="text-gray-400">Loading...</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -21,19 +21,25 @@ export default function Home() {
|
|||||||
|
|
||||||
if (status === 'unauthenticated') {
|
if (status === 'unauthenticated') {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center bg-gray-900">
|
<div className="flex min-h-screen flex-col items-center justify-center bg-gray-950 px-4 py-12">
|
||||||
<div className="text-center max-w-md mx-auto p-8">
|
<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">
|
||||||
<h1 className="text-2xl font-bold text-white mb-4">Welcome to Portal</h1>
|
<p className="mb-1 text-center text-sm font-medium uppercase tracking-wide text-orange-400">
|
||||||
<p className="text-gray-400 mb-6">Please sign in to continue</p>
|
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
|
<button
|
||||||
onClick={() => signIn()}
|
type="button"
|
||||||
className="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
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
|
Sign In
|
||||||
</button>
|
</button>
|
||||||
<p className="text-sm text-gray-500 mt-4">
|
{process.env.NODE_ENV === 'development' && (
|
||||||
Development mode: Use any email/password
|
<p className="mt-6 text-center text-xs text-gray-500">
|
||||||
</p>
|
Development: use any email/password with your dev IdP configuration.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,6 +2,24 @@ import { NextAuthOptions } from 'next-auth';
|
|||||||
import CredentialsProvider from 'next-auth/providers/credentials';
|
import CredentialsProvider from 'next-auth/providers/credentials';
|
||||||
import KeycloakProvider from 'next-auth/providers/keycloak';
|
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
|
// Check if Keycloak is configured
|
||||||
const isKeycloakConfigured =
|
const isKeycloakConfigured =
|
||||||
process.env.KEYCLOAK_URL &&
|
process.env.KEYCLOAK_URL &&
|
||||||
@@ -48,10 +66,18 @@ export const authOptions: NextAuthOptions = {
|
|||||||
providers,
|
providers,
|
||||||
callbacks: {
|
callbacks: {
|
||||||
async redirect({ url, baseUrl }) {
|
async redirect({ url, baseUrl }) {
|
||||||
// Prevent redirect loops - only allow redirects within the same origin
|
const canonical = canonicalAuthBaseUrl(baseUrl);
|
||||||
if (url.startsWith('/')) return `${baseUrl}${url}`;
|
if (url.startsWith('/')) return `${canonical}${url}`;
|
||||||
if (new URL(url).origin === baseUrl) return url;
|
try {
|
||||||
return baseUrl;
|
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 }) {
|
async jwt({ token, account, profile, user }) {
|
||||||
if (account) {
|
if (account) {
|
||||||
@@ -91,8 +117,8 @@ export const authOptions: NextAuthOptions = {
|
|||||||
return session;
|
return session;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
// Do not set pages.signIn to /api/auth/signin — that is the API handler and causes ERR_TOO_MANY_REDIRECTS.
|
||||||
pages: {
|
pages: {
|
||||||
signIn: '/api/auth/signin',
|
|
||||||
error: '/api/auth/error',
|
error: '/api/auth/error',
|
||||||
},
|
},
|
||||||
session: {
|
session: {
|
||||||
|
|||||||
Reference in New Issue
Block a user