Production
Portal SSO
Configuring Google or Keycloak login for the portal — redirect URIs, provider discovery, and the stateless session model.
The portal has no password login. Users sign in through an OIDC provider, and a user row is created on first successful callback. Two providers are supported, and both are optional. You can enable either, both, or neither. With neither, the only way to create a user is the seed script — see install.
| Provider | Enabled when | Callback URL |
|---|---|---|
GOOGLE_CLIENT_ID is set | ${SERVER_PUBLIC_URL}/api/auth/google/callback | |
| Keycloak | all three KEYCLOAK_* vars are set | ${SERVER_PUBLIC_URL}/api/auth/keycloak/callback |
It is built from SERVER_PUBLIC_URL and starts with /api/auth/. Older
documentation — including the comment in .env.example — describes
${PORTAL_URL}/auth/google/callback. That value is wrong and will fail with a
redirect_uri_mismatch in the provider console. The same helper builds the URI
for both the authorize redirect and the token exchange, so the two are always
byte-identical. There is nothing to align by hand except what you register with
the provider.
Google#
GOOGLE_CLIENT_ID=...apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=...
SERVER_PUBLIC_URL=https://workbench.example.comRegister https://workbench.example.com/api/auth/google/callback as an authorized
redirect URI on the OAuth client.
The flow uses OIDC discovery against
https://accounts.google.com/.well-known/openid-configuration, requests scopes
openid email profile with access_type=online, and carries both a state and a
nonce. The callback verifies the ID token against Google's JWKS, accepting either
of Google's two published issuer spellings — https://accounts.google.com and the
bare accounts.google.com — with the audience pinned to GOOGLE_CLIENT_ID and 60
seconds of clock tolerance, then checks the nonce.
Accounts whose email is not verified are rejected. The server matches a returning user on
google_sub. Failing that, it links by email address. Failing that, it creates a
new user.
GOOGLE_CLIENT_ID alone is enough to make the provider appear and to build an
authorize URL, but without GOOGLE_CLIENT_SECRET the token exchange throws
"Google OAuth not configured" at the callback. Set both.
These credentials are for portal login only. They are unrelated to the
GOOGLE_GMAIL_CLIENT_ID-style per-plugin credentials used to connect Google
integrations.
Keycloak#
KEYCLOAK_ISSUER_URL=https://sso.example.com/realms/engineering
KEYCLOAK_CLIENT_ID=workbench
KEYCLOAK_CLIENT_SECRET=...
SERVER_PUBLIC_URL=https://workbench.example.comAll three must be set — the provider reports itself configured only when none is
missing. KEYCLOAK_ISSUER_URL must parse as a URL, or boot fails.
In Keycloak, create a confidential client (client authentication on, standard flow
enabled) and add https://workbench.example.com/api/auth/keycloak/callback as a
valid redirect URI. Configuration is read from
${KEYCLOAK_ISSUER_URL}/.well-known/openid-configuration and cached, so the realm
must be reachable from the server at first login.
The flow mirrors Google's: authorization code, ID token verified against the
realm's JWKS with the audience pinned to KEYCLOAK_CLIENT_ID, users linked or
created on keycloak_sub. It is a confidential-client flow with no PKCE.
Two behavioural differences to be aware of:
- Email verification is checked more loosely. Keycloak logins are rejected only
when the token carries
email_verified: false. An absent claim passes. Google rejects an absent claim too. Configure the realm to emit the claim if you rely on it. - Keycloak cannot complete an MCP
/authorizehandshake. The MCP OAuth authorize endpoint hardcodes the Google authorize URL, and the Keycloak callback never resumes an authorize ticket. Keycloak works for portal login. Agents authorizing over MCP OAuth 2.1 need Google configured. Agents can also authenticate with a workbench API key, which is provider-independent.
Provider discovery#
curl https://workbench.example.com/api/auth/providersReturns the list of enabled providers — "google" when GOOGLE_CLIENT_ID is set,
"keycloak" when the three Keycloak variables are set. The portal calls this to
decide which sign-in buttons to render, so an empty list means a login page with
nothing on it. Use it to check your configuration landed before opening a browser.
The session model#
sequenceDiagram
participant B as Browser
participant S as workbench
participant I as IdP
B->>S: GET /api/auth/google
S->>B: 200 JSON {url} (state, nonce)
B->>I: navigate to url, authorize
I->>S: GET /api/auth/google/callback?code&state
S->>S: verify state + nonce, verify ID token, upsert user
S->>B: 302 PORTAL_URL#token=<session JWT>
B->>B: read fragment, store in localStorage
B->>S: GET /api/connections (Authorization: Bearer)Note the first hop: GET /api/auth/google (and /api/auth/keycloak) does not
redirect. It returns { url } as JSON — or 503 with an error when the provider
is unconfigured — and the portal's fetch reads url and navigates the browser
there itself. The only route that answers with a 302 to Google is the MCP OAuth
GET /authorize.
The session is a stateless HS256 JWT signed with SESSION_SECRET. Claims are
sub (the user id), email, iat, exp, plus aud and iss both set to
workbench. Verification enforces the audience, the issuer, and 5 seconds of
clock tolerance. The lifetime is 24 hours.
After either callback the server redirects to PORTAL_URL with the token in the
URL fragment — not a query parameter, so it is never sent to the server or
written to an access log. The portal reads the fragment once, stores the token in
localStorage under awb_token, and sends it as Authorization: Bearer on every
API call. The portal session uses no cookies at all.
POST /api/auth/logout returns success and revokes nothing. There is no session
revocation list, so a leaked session JWT stays valid for its full 24 hours.
Rotating SESSION_SECRET invalidates every session at once — and simultaneously
every MCP OAuth access token, connect token, curl-session token, and jot unlock
cookie, since all of them are keyed on the same secret.
For how the session JWT relates to the other credentials the server accepts, see the security model.