diff --git a/.env.example b/.env.example index ef01df2..c94080d 100644 --- a/.env.example +++ b/.env.example @@ -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. diff --git a/app/api/auth/callback/route.ts b/app/api/auth/callback/route.ts new file mode 100644 index 0000000..c784ca2 --- /dev/null +++ b/app/api/auth/callback/route.ts @@ -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'); + } +} diff --git a/app/api/auth/login/route.ts b/app/api/auth/login/route.ts index 8e1929e..4b0be14 100644 --- a/app/api/auth/login/route.ts +++ b/app/api/auth/login/route.ts @@ -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; } diff --git a/app/api/auth/me/route.ts b/app/api/auth/me/route.ts index dcb0a99..a546ac6 100644 --- a/app/api/auth/me/route.ts +++ b/app/api/auth/me/route.ts @@ -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, + }); } diff --git a/components/portal-shell.tsx b/components/portal-shell.tsx index 4f0de4a..ab3d0b7 100644 --- a/components/portal-shell.tsx +++ b/components/portal-shell.tsx @@ -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(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({
0pt - + - {variant === "admin" ? "admin" : "portal"} + {variant === 'admin' ? 'admin' : 'portal'}
{email ? ( <> {email} - ) : ( - - {t({ en: "Log in", sv: "Logga in" })} - + + {t({ en: 'Log in', sv: 'Logga in' })} + )}
@@ -87,24 +93,30 @@ export function PortalShell({ {t(n.label)} ); })} - {variant === "customer" ? ( - - {t({ en: "New order", sv: "Ny beställning" })} + {variant === 'customer' ? ( + + {t({ en: 'New order', sv: 'Ny beställning' })} ) : ( - - {t({ en: "← Customer view", sv: "← Kundvy" })} + + {t({ en: '← Customer view', sv: '← Kundvy' })} )} @@ -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 ( diff --git a/docs/guides/document-workbench.md b/docs/guides/document-workbench.md index bc41d98..ec6918e 100644 --- a/docs/guides/document-workbench.md +++ b/docs/guides/document-workbench.md @@ -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 diff --git a/lib/oidc.ts b/lib/oidc.ts new file mode 100644 index 0000000..ff8338d --- /dev/null +++ b/lib/oidc.ts @@ -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 | 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 { + 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, + }; +} diff --git a/middleware.ts b/middleware.ts index d439dda..1933a57 100644 --- a/middleware.ts +++ b/middleware.ts @@ -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 { 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 }, ); }