Authentication
Shell API supports two authentication modes — both produce a Shell JWT.
| Mode | Endpoint | Flow |
|---|---|---|
| Keycloak OAuth2 + PKCE S256 | GET /api/auth/login | Browser → Keycloak → callback → Shell JWT |
| Local login | POST /api/auth/local-login | Username + password → Shell JWT |
PKCE S256 flow
Dual Keycloak URL
This is the most common misconfiguration in K8s
In a Kubernetes cluster, the browser cannot reach internal service names like http://keycloak:8080, and the Shell API server should not route outbound through the public ingress.
| Variable | Value | Used by |
|---|---|---|
APP_ENV_KEYCLOAK_URL | https://auth.dev.venizia.ai | Browser — the PKCE authorize redirect URL |
APP_ENV_KEYCLOAK_INTERNAL_URL | http://keycloak:8080 | Server — token exchange POST, JWKS fetch |
In local dev, set both to http://localhost:8080. APP_ENV_KEYCLOAK_INTERNAL_URL falls back to APP_ENV_KEYCLOAK_URL if not set.
Redis state store
During login, Shell API stores the PKCE parameters in Redis:
Key: oauth:state:{state-uuid}
Value: { "codeVerifier": "...", "providerId": "keycloak" }
TTL: 600 seconds (10 minutes)The state key is consumed (deleted) on callback. If the browser takes longer than 10 minutes to complete login, the state will expire and the user must restart the flow.
Cache serialisation
CacheService stores values as JSON.stringify(value) via the raw Redis client, and parses them back with JSON.parse on read. Both sides must stay consistent.
Shell JWT payload
{
"sub": "user-uuid",
"email": "user@example.com",
"username": "john",
"firstName": "John",
"lastName": "Doe",
"roles": ["admin"],
"organizationId": "org-uuid",
"iss": "APP_ENV_JWT_ISSUER",
"aud": "APP_ENV_JWT_AUDIENCE",
"exp": 1700000000
}Token distribution to micro-frontends
After login, Shell UI distributes both tokens to all remote services:
// Shell UI — BroadcastChannel broadcast
const channel = new BroadcastChannel('veni-auth');
channel.postMessage({
type: 'TOKEN_UPDATE',
shellToken: '<shell-jwt>',
keycloakToken: '<keycloak-access-token>',
});Remote services listen on the same channel and call POST /api/auth/exchange or POST /api/auth/exchange-keycloak with the Keycloak token to obtain a service-scoped JWT.
Local login
POST /api/auth/local-login
Content-Type: application/json
{ "username": "veni", "password": "veni" }Verifies against user_credentials.passwordHash (Bun.password / bcrypt). Returns same Shell JWT structure.
Troubleshooting
PKCE code verifier not specified (Keycloak error) The code_verifier was not sent in the token exchange. Root cause: CacheService.get() returned a raw JSON string instead of a parsed object. Fixed by adding JSON.parse in get() when the value is a string.
Invalid or expired state parameter The OAuth state TTL (600s) expired, or Redis lost the key. User must restart the login flow.
Browser redirected to http://keycloak:8080APP_ENV_KEYCLOAK_URL is set to the internal cluster address. It must be the public URL reachable from the user's browser.
identity_providers table not found DB migrations have not been run. Run bun run db:migrate inside the shell/api directory (or kubectl exec into the pod).