Zitadel does not always copy urn:zitadel:iam:org:id into the id_token; the
access token always carries it. Verify that token (issuer) before trusting its
tenant.
The portal had no way to obtain a verified principal: /api/0pt/* requires a
Bearer token, but the browser only had the dev-only HMAC cookie.
- GET /api/auth/login: Authorization Code + PKCE against Zitadel.
- GET /api/auth/callback: code exchange, id_token verification (issuer,
audience, nonce) and a signed session cookie with sub + tenant.
- middleware accepts the signed session as a verified principal; Bearer wins.
- dev login POST is refused in production.
- Sign-in link in the portal shell.