feat(auth): Zitadel OIDC-inloggning för portalen #433
@@ -68,6 +68,10 @@ INTERNAL_TOKEN=change-me-internal-token
|
||||
#
|
||||
# Fleet Zitadel issuer (OIDC). Default when unset: https://id-customers.siax.io
|
||||
ZITADEL_ISSUER=https://id-customers.siax.io
|
||||
# Browser login (Authorization Code + PKCE). Create a Web application in the
|
||||
# Zitadel project with redirect URI https://0pt.app/api/auth/callback.
|
||||
ZITADEL_OIDC_CLIENT_ID=
|
||||
ZITADEL_OIDC_CLIENT_SECRET=
|
||||
# Enforcement is FAIL-CLOSED. Leave this UNSET (or set it to "true") and a
|
||||
# valid, verified Bearer token is REQUIRED on /api/0pt/* -- 401 on
|
||||
# missing/invalid, 403 when X-Tenant-Id disagrees with the token tenant.
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { signSession, SESSION_COOKIE } from '@/lib/auth';
|
||||
import {
|
||||
OIDC_SESSION_TTL_SECONDS,
|
||||
OIDC_STATE_COOKIE,
|
||||
appBaseUrl,
|
||||
exchangeCode,
|
||||
readOidcConfig,
|
||||
verifyIdToken,
|
||||
} from '@/lib/oidc';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
function safeNext(value: unknown): string {
|
||||
if (typeof value !== 'string' || !value.startsWith('/') || value.startsWith('//')) {
|
||||
return '/portal/dokument';
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/** GET /api/auth/callback — finish OIDC, verify the id_token, set the session. */
|
||||
export async function GET(request: NextRequest) {
|
||||
const config = readOidcConfig();
|
||||
const fail = (reason: string) =>
|
||||
NextResponse.redirect(`${appBaseUrl()}/portal/dokument?auth_error=${encodeURIComponent(reason)}`);
|
||||
if (!config) return fail('auth_not_configured');
|
||||
|
||||
const state = request.nextUrl.searchParams.get('state');
|
||||
const code = request.nextUrl.searchParams.get('code');
|
||||
const error = request.nextUrl.searchParams.get('error');
|
||||
if (error) return fail('provider_error');
|
||||
if (!state || !code) return fail('missing_code');
|
||||
|
||||
const raw = request.cookies.get(OIDC_STATE_COOKIE)?.value;
|
||||
if (!raw) return fail('missing_state');
|
||||
let stored: { state?: unknown; nonce?: unknown; verifier?: unknown; next?: unknown; ts?: unknown };
|
||||
try {
|
||||
stored = JSON.parse(raw);
|
||||
} catch {
|
||||
return fail('bad_state');
|
||||
}
|
||||
if (
|
||||
stored.state !== state ||
|
||||
typeof stored.nonce !== 'string' ||
|
||||
typeof stored.verifier !== 'string' ||
|
||||
typeof stored.ts !== 'number' ||
|
||||
Date.now() - stored.ts > 10 * 60 * 1000
|
||||
) {
|
||||
return fail('bad_state');
|
||||
}
|
||||
|
||||
try {
|
||||
const { idToken, accessToken } = await exchangeCode(config, code, stored.verifier);
|
||||
const verified = await verifyIdToken(config, idToken, stored.nonce, accessToken);
|
||||
const session = await signSession({
|
||||
sub: verified.sub,
|
||||
tenantId: verified.tenantId,
|
||||
email: verified.email ?? '',
|
||||
exp: Date.now() + OIDC_SESSION_TTL_SECONDS * 1000,
|
||||
});
|
||||
const response = NextResponse.redirect(`${appBaseUrl()}${safeNext(stored.next)}`);
|
||||
response.cookies.set(SESSION_COOKIE, session, {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
secure: appBaseUrl().startsWith('https://'),
|
||||
path: '/',
|
||||
maxAge: OIDC_SESSION_TTL_SECONDS,
|
||||
});
|
||||
response.cookies.set(OIDC_STATE_COOKIE, '', { path: '/', maxAge: 0 });
|
||||
return response;
|
||||
} catch {
|
||||
return fail('verification_failed');
|
||||
}
|
||||
}
|
||||
+79
-13
@@ -1,29 +1,95 @@
|
||||
import { timingSafeEqual } from "node:crypto";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { signSession, SESSION_COOKIE } from "@/lib/auth";
|
||||
import { timingSafeEqual } from 'node:crypto';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { signSession, SESSION_COOKIE } from '@/lib/auth';
|
||||
import {
|
||||
OIDC_STATE_COOKIE,
|
||||
appBaseUrl,
|
||||
authorizeUrl,
|
||||
createOidcState,
|
||||
createPkce,
|
||||
readOidcConfig,
|
||||
} from '@/lib/oidc';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
function safeNext(value: string | null): string {
|
||||
if (!value) return '/portal/dokument';
|
||||
if (!value.startsWith('/') || value.startsWith('//')) return '/portal/dokument';
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/auth/login — start the Zitadel OIDC Authorization Code + PKCE flow.
|
||||
* Redirects back to /api/auth/callback, which sets the verified session cookie.
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
const config = readOidcConfig();
|
||||
if (!config) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: {
|
||||
code: 'auth_not_configured',
|
||||
message: 'Zitadel OIDC is not configured (ZITADEL_OIDC_CLIENT_ID/SECRET)',
|
||||
},
|
||||
},
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
const next = safeNext(request.nextUrl.searchParams.get('next'));
|
||||
const { state, nonce } = createOidcState();
|
||||
const { verifier, challenge } = createPkce();
|
||||
const response = NextResponse.redirect(authorizeUrl(config, state, nonce, challenge));
|
||||
response.cookies.set(OIDC_STATE_COOKIE, JSON.stringify({ state, nonce, verifier, next, ts: Date.now() }), {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
secure: appBaseUrl().startsWith('https://'),
|
||||
path: '/',
|
||||
maxAge: 600,
|
||||
});
|
||||
return response;
|
||||
}
|
||||
|
||||
// POST /api/auth/login — guarded dev login (scaffold). Requires AUTH_SECRET +
|
||||
// a matching DEV_LOGIN_KEY. Production replaces this with Auth.js + OAuth.
|
||||
// a matching DEV_LOGIN_KEY. Never available in production; real login is GET.
|
||||
export async function POST(req: NextRequest) {
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
return NextResponse.json(
|
||||
{ error: { code: 'forbidden', message: 'Dev-login is disabled in production' } },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
const { email, key } = await req.json().catch(() => ({ email: undefined, key: undefined }));
|
||||
if (!process.env.AUTH_SECRET) {
|
||||
return NextResponse.json({ error: { code: "auth_not_configured", message: "AUTH_SECRET saknas" } }, { status: 501 });
|
||||
return NextResponse.json(
|
||||
{ error: { code: 'auth_not_configured', message: 'AUTH_SECRET saknas' } },
|
||||
{ status: 501 },
|
||||
);
|
||||
}
|
||||
const devKey = process.env.DEV_LOGIN_KEY;
|
||||
// Constant-time compare: a plain !== leaks the number of matching leading
|
||||
// bytes via response timing, letting an attacker recover DEV_LOGIN_KEY
|
||||
// byte-by-byte instead of it being an all-or-nothing guess.
|
||||
const keyBuf = Buffer.from(typeof key === "string" ? key : "");
|
||||
const devKeyBuf = Buffer.from(devKey ?? "");
|
||||
const keysMatch = Boolean(devKey) && keyBuf.length === devKeyBuf.length && timingSafeEqual(keyBuf, devKeyBuf);
|
||||
// byte-by-byte instead of an all-or-nothing guess.
|
||||
const keyBuf = Buffer.from(typeof key === 'string' ? key : '');
|
||||
const devKeyBuf = Buffer.from(devKey ?? '');
|
||||
const keysMatch =
|
||||
Boolean(devKey) && keyBuf.length === devKeyBuf.length && timingSafeEqual(keyBuf, devKeyBuf);
|
||||
if (!keysMatch) {
|
||||
return NextResponse.json({ error: { code: "forbidden", message: "Dev-login kräver giltig nyckel" } }, { status: 403 });
|
||||
return NextResponse.json(
|
||||
{ error: { code: 'forbidden', message: 'Dev-login kräver giltig nyckel' } },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
if (!email) {
|
||||
return NextResponse.json({ error: { code: "bad_request", message: "email krävs" } }, { status: 400 });
|
||||
return NextResponse.json({ error: { code: 'bad_request', message: 'email krävs' } }, { status: 400 });
|
||||
}
|
||||
const token = await signSession({ email, iat: Date.now() });
|
||||
const token = await signSession({ email, exp: Date.now() + 7 * 24 * 60 * 60 * 1000 });
|
||||
const res = NextResponse.json({ ok: true, email });
|
||||
res.cookies.set(SESSION_COOKIE, token, { httpOnly: true, sameSite: "lax", secure: true, path: "/", maxAge: 60 * 60 * 24 * 7 });
|
||||
res.cookies.set(SESSION_COOKIE, token, {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
secure: true,
|
||||
path: '/',
|
||||
maxAge: 60 * 60 * 24 * 7,
|
||||
});
|
||||
return res;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { verifySession, SESSION_COOKIE } from "@/lib/auth";
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { verifySession, SESSION_COOKIE } from '@/lib/auth';
|
||||
|
||||
// GET /api/auth/me — current session (or null).
|
||||
export async function GET(req: NextRequest) {
|
||||
const token = req.cookies.get(SESSION_COOKIE)?.value;
|
||||
const session = await verifySession(token);
|
||||
return NextResponse.json({ user: session ? { email: session.email } : null });
|
||||
return NextResponse.json({
|
||||
user: session
|
||||
? {
|
||||
email: session.email ?? null,
|
||||
sub: typeof session.sub === 'string' ? session.sub : null,
|
||||
tenantId: typeof session.tenantId === 'string' ? session.tenantId : null,
|
||||
}
|
||||
: null,
|
||||
});
|
||||
}
|
||||
|
||||
+52
-38
@@ -1,27 +1,27 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useT } from "@/components/locale-provider";
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useT } from '@/components/locale-provider';
|
||||
|
||||
export type NavItem = { label: { en: string; sv: string }; href: string };
|
||||
|
||||
const CUSTOMER_NAV: NavItem[] = [
|
||||
{ label: { en: "Overview", sv: "Översikt" }, href: "/portal" },
|
||||
{ label: { en: "Subscriptions", sv: "Prenumerationer" }, href: "/portal/subscriptions" },
|
||||
{ label: { en: "Documents", sv: "Dokument" }, href: "/portal/dokument" },
|
||||
{ label: { en: "Credits", sv: "Krediter" }, href: "/portal/krediter" },
|
||||
{ label: { en: "Integrations", sv: "Integrationer" }, href: "/portal/integrationer" },
|
||||
{ label: { en: "Settings", sv: "Inställningar" }, href: "/portal/settings" },
|
||||
{ label: { en: 'Overview', sv: 'Översikt' }, href: '/portal' },
|
||||
{ label: { en: 'Subscriptions', sv: 'Prenumerationer' }, href: '/portal/subscriptions' },
|
||||
{ label: { en: 'Documents', sv: 'Dokument' }, href: '/portal/dokument' },
|
||||
{ label: { en: 'Credits', sv: 'Krediter' }, href: '/portal/krediter' },
|
||||
{ label: { en: 'Integrations', sv: 'Integrationer' }, href: '/portal/integrationer' },
|
||||
{ label: { en: 'Settings', sv: 'Inställningar' }, href: '/portal/settings' },
|
||||
];
|
||||
|
||||
const ADMIN_NAV: NavItem[] = [
|
||||
{ label: { en: "Overview", sv: "Översikt" }, href: "/admin" },
|
||||
{ label: { en: "Sources", sv: "Källor" }, href: "/admin/sources" },
|
||||
{ label: { en: "Jobs", sv: "Jobb" }, href: "/admin/jobs" },
|
||||
{ label: { en: "Tools", sv: "Verktyg" }, href: "/admin/verktyg" },
|
||||
{ label: { en: "Customers", sv: "Kunder" }, href: "/admin/customers" },
|
||||
{ label: { en: 'Overview', sv: 'Översikt' }, href: '/admin' },
|
||||
{ label: { en: 'Sources', sv: 'Källor' }, href: '/admin/sources' },
|
||||
{ label: { en: 'Jobs', sv: 'Jobb' }, href: '/admin/jobs' },
|
||||
{ label: { en: 'Tools', sv: 'Verktyg' }, href: '/admin/verktyg' },
|
||||
{ label: { en: 'Customers', sv: 'Kunder' }, href: '/admin/customers' },
|
||||
];
|
||||
|
||||
export function PortalShell({
|
||||
@@ -29,24 +29,24 @@ export function PortalShell({
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
variant: "customer" | "admin";
|
||||
variant: 'customer' | 'admin';
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const t = useT();
|
||||
const pathname = usePathname();
|
||||
const nav = variant === "admin" ? ADMIN_NAV : CUSTOMER_NAV;
|
||||
const nav = variant === 'admin' ? ADMIN_NAV : CUSTOMER_NAV;
|
||||
const [email, setEmail] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/auth/me")
|
||||
fetch('/api/auth/me')
|
||||
.then((r) => r.json())
|
||||
.then((d) => setEmail(d?.user?.email ?? null))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
async function logout() {
|
||||
await fetch("/api/auth/logout", { method: "POST" }).catch(() => {});
|
||||
await fetch('/api/auth/logout', { method: 'POST' }).catch(() => {});
|
||||
setEmail(null);
|
||||
}
|
||||
|
||||
@@ -56,23 +56,29 @@ export function PortalShell({
|
||||
<div className="sticky top-0 z-40 flex h-16 items-center justify-between border-b border-border bg-background px-6">
|
||||
<Link href="/" className="flex items-center gap-2">
|
||||
<span className="text-lg font-semibold tracking-[-0.04em]">0pt</span>
|
||||
<span className="inline-block h-1 w-1" style={{ backgroundColor: "var(--accent-red)" }} />
|
||||
<span className="inline-block h-1 w-1" style={{ backgroundColor: 'var(--accent-red)' }} />
|
||||
<span className="font-mono text-[10px] uppercase tracking-[0.18em] text-muted-foreground">
|
||||
{variant === "admin" ? "admin" : "portal"}
|
||||
{variant === 'admin' ? 'admin' : 'portal'}
|
||||
</span>
|
||||
</Link>
|
||||
<div className="flex items-center gap-4">
|
||||
{email ? (
|
||||
<>
|
||||
<span className="font-mono text-[11px] text-muted-foreground">{email}</span>
|
||||
<button onClick={logout} className="font-mono text-[11px] uppercase tracking-[0.1em] text-muted-foreground hover:text-foreground">
|
||||
{t({ en: "Log out", sv: "Logga ut" })}
|
||||
<button
|
||||
onClick={logout}
|
||||
className="font-mono text-[11px] uppercase tracking-[0.1em] text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{t({ en: 'Log out', sv: 'Logga ut' })}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<Link href="/portal/settings" className="font-mono text-[11px] uppercase tracking-[0.1em] text-muted-foreground hover:text-foreground">
|
||||
{t({ en: "Log in", sv: "Logga in" })}
|
||||
</Link>
|
||||
<a
|
||||
href="/api/auth/login?next=/portal/dokument"
|
||||
className="font-mono text-[11px] uppercase tracking-[0.1em] text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{t({ en: 'Log in', sv: 'Logga in' })}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -87,24 +93,30 @@ export function PortalShell({
|
||||
<Link
|
||||
key={n.href}
|
||||
href={n.href}
|
||||
aria-current={active ? "page" : undefined}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
className="border-l-2 px-4 py-2.5 font-mono text-[11px] uppercase tracking-[0.1em] transition-colors"
|
||||
style={{
|
||||
borderColor: active ? "var(--accent-red)" : "transparent",
|
||||
color: active ? "var(--foreground)" : "var(--muted-foreground)",
|
||||
borderColor: active ? 'var(--accent-red)' : 'transparent',
|
||||
color: active ? 'var(--foreground)' : 'var(--muted-foreground)',
|
||||
}}
|
||||
>
|
||||
{t(n.label)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
{variant === "customer" ? (
|
||||
<Link href="/bestall" className="mt-2 bg-foreground px-4 py-2.5 text-center font-mono text-[11px] uppercase tracking-[0.1em] text-background hover:bg-[var(--accent-red)]">
|
||||
{t({ en: "New order", sv: "Ny beställning" })}
|
||||
{variant === 'customer' ? (
|
||||
<Link
|
||||
href="/bestall"
|
||||
className="mt-2 bg-foreground px-4 py-2.5 text-center font-mono text-[11px] uppercase tracking-[0.1em] text-background hover:bg-[var(--accent-red)]"
|
||||
>
|
||||
{t({ en: 'New order', sv: 'Ny beställning' })}
|
||||
</Link>
|
||||
) : (
|
||||
<Link href="/portal" className="mt-2 border border-border px-4 py-2.5 text-center font-mono text-[11px] uppercase tracking-[0.1em] text-muted-foreground hover:text-foreground">
|
||||
{t({ en: "← Customer view", sv: "← Kundvy" })}
|
||||
<Link
|
||||
href="/portal"
|
||||
className="mt-2 border border-border px-4 py-2.5 text-center font-mono text-[11px] uppercase tracking-[0.1em] text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{t({ en: '← Customer view', sv: '← Kundvy' })}
|
||||
</Link>
|
||||
)}
|
||||
</nav>
|
||||
@@ -122,9 +134,11 @@ export function PortalShell({
|
||||
|
||||
export function StatusPill({ status }: { status: string }) {
|
||||
const color =
|
||||
status === "delivered" || status === "accepted" ? "var(--prov-3)"
|
||||
: status === "in_production" || status === "review" ? "var(--accent-red)"
|
||||
: "var(--muted-foreground)";
|
||||
status === 'delivered' || status === 'accepted'
|
||||
? 'var(--prov-3)'
|
||||
: status === 'in_production' || status === 'review'
|
||||
? 'var(--accent-red)'
|
||||
: 'var(--muted-foreground)';
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 font-mono text-[10.5px] uppercase tracking-[0.08em]">
|
||||
<span className="inline-block h-2 w-2" style={{ backgroundColor: color }} />
|
||||
|
||||
@@ -15,7 +15,9 @@ The customer-facing slice that turns an uploaded file into evidence-bound data.
|
||||
| `POST /api/webhooks/stripe` | re-reads the session and payment intent, then grants credits idempotently | non-2xx until processed |
|
||||
|
||||
UI: `/portal/dokument` (upload, structure, review, download) and `/portal/krediter`
|
||||
(balance, packs, checkout).
|
||||
(balance, packs, checkout). The portal signs in through Zitadel OIDC
|
||||
(`/api/auth/login` → `/api/auth/callback`); the resulting signed session cookie
|
||||
is accepted by middleware as a verified principal (Bearer still wins).
|
||||
|
||||
## Invariants
|
||||
|
||||
|
||||
+151
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* Zitadel OIDC Authorization Code + PKCE for the 0pt portal.
|
||||
*
|
||||
* The portal previously had no way to obtain a verified principal: the
|
||||
* `/api/0pt/*` routes require a Bearer token, but the browser only had the
|
||||
* dev-only HMAC session cookie. This module implements the standard web login
|
||||
* so a real user can reach the document workspace.
|
||||
*
|
||||
* Secrets (client id/secret, AUTH_SECRET) are server-only env values.
|
||||
*/
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
import { createRemoteJWKSet, jwtVerify, type JWTPayload } from 'jose';
|
||||
import { deriveTenantId, zitadelIssuer } from '@/src/auth/zitadel';
|
||||
|
||||
export const OIDC_STATE_COOKIE = '0pt_oidc';
|
||||
export const OIDC_SESSION_TTL_SECONDS = 60 * 60 * 24 * 7;
|
||||
|
||||
export interface OidcConfig {
|
||||
issuer: string;
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
redirectUri: string;
|
||||
}
|
||||
|
||||
export function appBaseUrl(): string {
|
||||
const configured = process.env.NEXT_PUBLIC_APP_URL;
|
||||
if (configured && /^https?:\/\//.test(configured)) return configured.replace(/\/+$/, '');
|
||||
return 'http://localhost:3000';
|
||||
}
|
||||
|
||||
export function readOidcConfig(): OidcConfig | undefined {
|
||||
const issuer = (process.env.ZITADEL_ISSUER || zitadelIssuer()).replace(/\/+$/, '');
|
||||
const clientId = process.env.ZITADEL_OIDC_CLIENT_ID;
|
||||
const clientSecret = process.env.ZITADEL_OIDC_CLIENT_SECRET;
|
||||
if (!clientId || !clientSecret || !/^https:\/\//.test(issuer)) return undefined;
|
||||
return {
|
||||
issuer,
|
||||
clientId,
|
||||
clientSecret,
|
||||
redirectUri: `${appBaseUrl()}/api/auth/callback`,
|
||||
};
|
||||
}
|
||||
|
||||
function base64url(buffer: Buffer): string {
|
||||
return buffer.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
}
|
||||
|
||||
export function createPkce(): { verifier: string; challenge: string } {
|
||||
const verifier = base64url(randomBytes(48));
|
||||
const challenge = base64url(createHash('sha256').update(verifier).digest());
|
||||
return { verifier, challenge };
|
||||
}
|
||||
|
||||
export function createOidcState(): { state: string; nonce: string } {
|
||||
return { state: base64url(randomBytes(24)), nonce: base64url(randomBytes(24)) };
|
||||
}
|
||||
|
||||
export function authorizeUrl(config: OidcConfig, state: string, nonce: string, challenge: string): string {
|
||||
const params = new URLSearchParams({
|
||||
client_id: config.clientId,
|
||||
redirect_uri: config.redirectUri,
|
||||
response_type: 'code',
|
||||
scope: 'openid profile email',
|
||||
state,
|
||||
nonce,
|
||||
code_challenge: challenge,
|
||||
code_challenge_method: 'S256',
|
||||
});
|
||||
return `${config.issuer}/oauth/v2/authorize?${params.toString()}`;
|
||||
}
|
||||
|
||||
interface TokenResponse {
|
||||
id_token?: unknown;
|
||||
access_token?: unknown;
|
||||
token_type?: unknown;
|
||||
}
|
||||
|
||||
export async function exchangeCode(
|
||||
config: OidcConfig,
|
||||
code: string,
|
||||
verifier: string,
|
||||
): Promise<{ idToken: string; accessToken?: string }> {
|
||||
const body = new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
redirect_uri: config.redirectUri,
|
||||
client_id: config.clientId,
|
||||
client_secret: config.clientSecret,
|
||||
code_verifier: verifier,
|
||||
});
|
||||
const response = await fetch(`${config.issuer}/oauth/v2/token`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
||||
body,
|
||||
cache: 'no-store',
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
if (!response.ok) throw new Error('token_exchange_failed');
|
||||
const data = (await response.json()) as TokenResponse;
|
||||
if (typeof data.id_token !== 'string' || !data.id_token) {
|
||||
throw new Error('token_missing_id_token');
|
||||
}
|
||||
return {
|
||||
idToken: data.id_token,
|
||||
...(typeof data.access_token === 'string' ? { accessToken: data.access_token } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
let jwks: ReturnType<typeof createRemoteJWKSet> | undefined;
|
||||
let jwksIssuer: string | undefined;
|
||||
|
||||
function keySet(issuer: string) {
|
||||
if (!jwks || jwksIssuer !== issuer) {
|
||||
jwks = createRemoteJWKSet(new URL(`${issuer}/oauth/v2/keys`));
|
||||
jwksIssuer = issuer;
|
||||
}
|
||||
return jwks;
|
||||
}
|
||||
|
||||
export interface VerifiedIdToken {
|
||||
sub: string;
|
||||
tenantId: string;
|
||||
email?: string;
|
||||
claims: JWTPayload;
|
||||
}
|
||||
|
||||
export async function verifyIdToken(
|
||||
config: OidcConfig,
|
||||
idToken: string,
|
||||
expectedNonce: string,
|
||||
accessToken?: string,
|
||||
): Promise<VerifiedIdToken> {
|
||||
const { payload } = await jwtVerify(idToken, keySet(config.issuer), {
|
||||
issuer: config.issuer,
|
||||
audience: config.clientId,
|
||||
});
|
||||
if (typeof payload.sub !== 'string' || !payload.sub) {
|
||||
throw new Error('id_token_missing_sub');
|
||||
}
|
||||
if (expectedNonce && payload.nonce !== expectedNonce) {
|
||||
throw new Error('id_token_nonce_mismatch');
|
||||
}
|
||||
const tenantId = deriveTenantId(payload);
|
||||
if (!tenantId) throw new Error('id_token_missing_tenant');
|
||||
return {
|
||||
sub: payload.sub,
|
||||
tenantId,
|
||||
...(typeof payload.email === 'string' ? { email: payload.email } : {}),
|
||||
claims: payload,
|
||||
};
|
||||
}
|
||||
+26
-3
@@ -8,6 +8,7 @@
|
||||
*/
|
||||
import { NextResponse, type NextRequest } from 'next/server';
|
||||
import { assertAuthConfigSafe, resolveAuthContext } from '@/src/auth/context';
|
||||
import { SESSION_COOKIE, verifySession } from '@/lib/auth';
|
||||
import { verifyInternalServiceToken } from '@/src/auth/internal-token';
|
||||
|
||||
assertAuthConfigSafe();
|
||||
@@ -45,15 +46,37 @@ export async function middleware(req: NextRequest): Promise<NextResponse> {
|
||||
const hasAuth = !!req.headers.get('authorization');
|
||||
console.log(
|
||||
`[0pt-auth] middleware path=${path} auth_header=${hasAuth} ` +
|
||||
`auth_enforce=${process.env.AUTH_ENFORCE ?? 'unset'} ` +
|
||||
`node_env=${process.env.NODE_ENV ?? 'unset'} request#${_authDiagCount}`,
|
||||
`auth_enforce=${process.env.AUTH_ENFORCE ?? 'unset'} ` +
|
||||
`node_env=${process.env.NODE_ENV ?? 'unset'} request#${_authDiagCount}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Browser sessions (Zitadel OIDC -> signed cookie) are a first-class
|
||||
// principal for the portal. A Bearer token always wins when present.
|
||||
if (!req.headers.get('authorization')) {
|
||||
const session = await verifySession(req.cookies.get(SESSION_COOKIE)?.value);
|
||||
if (
|
||||
session &&
|
||||
typeof session.sub === 'string' &&
|
||||
typeof session.tenantId === 'string' &&
|
||||
typeof session.exp === 'number' &&
|
||||
session.exp > Date.now()
|
||||
) {
|
||||
const forwarded = new Headers(req.headers);
|
||||
forwarded.set('x-0pt-verified-sub', session.sub);
|
||||
forwarded.set('x-0pt-verified-tenant', session.tenantId);
|
||||
return NextResponse.next({ request: { headers: forwarded } });
|
||||
}
|
||||
}
|
||||
|
||||
const decision = await resolveAuthContext(req.headers);
|
||||
if (!decision.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: decision.code, message: decision.message, ...(decision.details ? { details: decision.details } : {}) },
|
||||
{
|
||||
error: decision.code,
|
||||
message: decision.message,
|
||||
...(decision.details ? { details: decision.details } : {}),
|
||||
},
|
||||
{ status: decision.status },
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user