feat(auth): Zitadel OIDC-inloggning för portalen #433

Merged
admin merged 2 commits from feat/portal-oidc-login into main 2026-11-02 14:54:55 +00:00
8 changed files with 400 additions and 58 deletions
+4
View File
@@ -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.
+74
View File
@@ -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
View File
@@ -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;
}
+11 -3
View File
@@ -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
View File
@@ -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 }} />
+3 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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 },
);
}