Skip to content

Keycloack multi-issuer auth with iframe SSO support#1

Open
remusiesan wants to merge 2 commits intomainfrom
keycloack-integration
Open

Keycloack multi-issuer auth with iframe SSO support#1
remusiesan wants to merge 2 commits intomainfrom
keycloack-integration

Conversation

@remusiesan
Copy link
Copy Markdown
Collaborator

Keycloak Multi-Issuer Auth & Iframe SSO

Adds iframe SSO support and hardens the Keycloak authentication layer across the full stack. DAVE can now operate in two modes: standalone (direct Keycloak login) and embedded (iframe SSO via postMessage from a parent app). All server-side session checks are migrated from the deprecated getSession to getServerSession, and iframe requests bypass SSR auth redirects so the client-side IframeAuthBridge can handle auth instead.


Modes of Operation

Standalone Mode

DAVE runs as a regular web application. Users authenticate directly via Keycloak using the standard OAuth2/OIDC redirect flow.

  • Session managed by NextAuth with dave.* cookies.
  • Access token stored in the JWT session and proactively refreshed by AuthWatcher before expiry using Keycloak's refresh token.
  • Logout button visible in LoginAvatar, signs the user out of both DAVE and Keycloak.
  • All protected pages redirect unauthenticated users server-side via getServerSideProps.

Iframe (Embedded) Mode

DAVE is embedded inside a parent application (e.g. toolbox-ui) as an <iframe>. The parent already holds a valid Keycloak access token and passes it to DAVE via postMessage — no Keycloak redirect occurs inside the iframe.

Token validation — the iframe-token CredentialsProvider validates the token server-side by calling {iss}/protocol/openid-connect/userinfo. The token is verified against a live Keycloak instance, not just decoded locally — a compromised or expired token is rejected even if the JWT signature looks valid.

Mode detection — iframe context is detected in two places:

  • Server-side (getServerSideProps): via Sec-Fetch-Dest: iframe header, with a Referer fallback against NEXT_PUBLIC_FRAME_ANCESTORS.
  • Client-side: via window.self !== window.top (try/catch for cross-origin iframes that throw on window.top access).

Comparison

Standalone Iframe
Auth trigger User clicks "Sign in" Parent sends SSO_TOKEN via postMessage
Token source Keycloak redirect flow Passed in from parent app
Session refresh Refresh token via Keycloak Parent provides new token on IFRAME_REQUEST_TOKEN
SSR auth guard Redirects to /sign-in if no session Bypassed — client handles auth
Sign-in page Shows Keycloak login button Shows "Authenticating…" placeholder
Logout button Visible Hidden — session owned by parent
NextAuth provider keycloak iframe-token (CredentialsProvider)

Environment Variables

Variable Service Description
KEYCLOAK_ISSUER documents backend Keycloak issuer URL — the only trusted token issuer.
KEYCLOAK_CLIENT_ID documents backend Keycloak client ID. Previously read from KEYCLOAK_IDrename required on deploy.
NEXT_PUBLIC_FRAME_ANCESTORS frontend Origin of the parent app embedding DAVE (e.g. https://toolbox.example.com). Restricts postMessage to this origin. Must be set in production.
ES_HOST qavectorizer Elasticsearch hostname. Previously hardcoded to host.docker.internal. Defaults to es.

Changed Files

backend/documents/src/middlewares/keycloak-auth.js

Replaces loose manual realm-path suffix checking with strict issuer equality against KEYCLOAK_ISSUER.

  • Single jwksClientInstance tied to KEYCLOAK_ISSUER — no multi-issuer map.
  • getKeyForToken(token) decodes the JWT payload, asserts iss === KEYCLOAK_ISSUER, then fetches the signing key from the JWKS endpoint.
  • Env var renamed: process.env.KEYCLOAK_IDprocess.env.KEYCLOAK_CLIENT_ID.

backend/documents/src/api/collection.js

Empty FacetEntry array after a cache rebuild is no longer treated as a 500 error. An empty result is valid for collections with no annotated entities.

backend/documents/src/api/document.js

  • Removed stale console.log("doc", document.features.anonymized) left in the deanonymize endpoint.

backend/qavectorizer/src/app.py + settings.py

  • ES host and port are now configurable via ES_HOST and settings.elastic_port. Previously hardcoded to host.docker.internal:9201.
  • collection_id in QueryElasticIndexRequest changed from required str to Optional[str] = None, allowing global searches not scoped to a single collection.

frontend/hooks/use-iframe-auth.ts (new file)

useIframeAuth() hook — manages the full iframe SSO token lifecycle client-side.

  • On mount, sends { type: 'SSO_READY' } to the parent.
  • Listens for { type: 'SSO_TOKEN', token } from the parent, scoped to NEXT_PUBLIC_FRAME_ANCESTORS in production.
  • Calls signIn('iframe-token', { accessToken: token }) via the CredentialsProvider.
  • When session reports IframeTokenExpired, sends { type: 'IFRAME_REQUEST_TOKEN' } to request a fresh token.
  • On sign-in failure, posts { type: 'SSO_ERROR', error } back to the parent.
  • A signingIn ref prevents concurrent sign-in attempts if multiple SSO_TOKEN messages arrive in quick succession.

frontend/pages/_app.tsx

  • IframeAuthBridge — defined at module scope (outside MyApp) so React sees a stable component type. Mounting it inside MyApp would create a new type on every render, causing constant unmount/remount and re-firing all effects. Renders null and exists solely to call useIframeAuth() inside the SessionProvider tree.
  • AuthWatcher — also moved to module scope for the same reason. New behaviour:
    • Decodes the JWT exp claim and schedules a setTimeout to call update() just before expiry (buffer = min(60s, halfOfRemainingTime)).
    • Falls back to a 2-minute setInterval if exp is absent or the token is unparseable.
    • Clears timers on token change or unmount to avoid stale refreshes.
    • Signs the user out and redirects to /sign-in if any refresh fails.

frontend/pages/api/auth/[...nextauth].ts

New CredentialsProvider (id: 'iframe-token') — validates a raw Keycloak access token from the parent iframe without requiring a Keycloak client secret:

  1. Decodes the JWT payload — checks sub, iss, exp are present and token is not already expired.
  2. Calls {iss}/protocol/openid-connect/userinfo with the token as Bearer — validates against live Keycloak.
  3. Returns a user object with id, email, name, accessToken, and accessTokenExpires.

JWT/session callbacks for iframe provider:

  • On initial iframe-token sign-in, stores accessToken, accessTokenExpires, and provider: 'iframe' on the JWT.
  • On subsequent JWT refreshes, if provider === 'iframe' and the token has expired, returns { ...token, error: 'IframeTokenExpired' } — the client picks this up via useIframeAuth and requests a new token from the parent.

Cookie hardening — all NextAuth cookies renamed next-auth.*dave.* and secure flag set to true in production only:

Cookie Old name New name
Session token next-auth.session-token dave.session-token
Callback URL next-auth.callback-url dave.callback-url
CSRF token next-auth.csrf-token dave.csrf-token
PKCE verifier next-auth.pkce.code_verifier dave.pkce.code_verifier
State next-auth.state dave.state
Nonce next-auth.nonce dave.nonce

Fail-fast validation — throws on server startup if any required env var (KEYCLOAK_CLIENT_ID, KEYCLOAK_SECRET, KEYCLOAK_ISSUER, NEXTAUTH_SECRET) is missing when auth is enabled.

frontend/pages/sign-in.tsx

  • Detects iframe context via isInIframe() on mount (state-gated to avoid SSR mismatch).
  • Renders an "Authenticating…" placeholder in iframe mode — IframeAuthBridge owns auth, not user interaction.
  • router removed from useEffect dependency arrays where it caused spurious re-runs.

frontend/utils/auth.ts

Two new utility functions: isInIframe() (client-side) and isRequestFromIframe(req) (server-side).

frontend/pages/index.tsx, search/index.tsx, collections/[id].tsx, settings/index.tsx, settings/llm.tsx, settings/annotation-configuration.tsx

All six protected pages receive the same two changes:

  1. getSessiongetServerSession — reads the session directly from the JWT cookie instead of making an extra HTTP round-trip to /api/auth/session.
  2. Iframe bypassisRequestFromIframe(context.req) is checked before the session guard. If the request originates from an iframe, SSR skips the redirect to /sign-in and renders the page — IframeAuthBridge then handles auth client-side.

frontend/server/routers/collection.ts

  • Collection type extended with config?: { typesToHide: string[]; typesOrder?: string[] } and deprecated collectionTypes?: string[].
  • config made optional — prevents crashes when older collection documents don't have the field.
  • Facets endpoint: 401/403 now returns null instead of throwing a TRPCError, allowing the UI to degrade gracefully.
  • Removed unused imports: TRPCClientError, AnyCnameRecord.

frontend/server/routers/search.ts

  • collectionId fallback changed from 'N/A'undefined'N/A' was being sent to the indexer as a literal collection ID, causing bad queries on global search.
  • Faceted query enabled condition tightened: now requires token in addition to activeCollection.id, preventing unauthenticated requests.
  • Indexer fetch response checked with response.ok before JSON parsing.

frontend/server/routers/document.ts

  • 'not found' string match in error handling is now case-insensitive (.toLowerCase()), catching 'Not Found' from some API responses.

frontend/server/routers/user.ts

  • Error detail extraction reads error.data.errorerror.data.messageerror.message in priority order.
  • Auth check fixed: error.statuserror.response?.status (the former was always undefined for fetch errors).

frontend/components/LoginAvatar/LoginAvatar.tsx

  • The Logout dropdown item is hidden when DAVE is running inside an iframe (!inIframe). In iframe mode, session lifecycle is owned by the parent app.

frontend/components/FilterChip.tsx

  • Added key?: string to MatchChipProps to suppress React key prop warnings when FilterChip is rendered in a list.

frontend/modules/document/DocumentProvider/selectors.ts

  • Removed ~20 console.log debug statements left in selectDocumentClusters from development.
  • annSet?.name ?? null — safe access on selectCurrentAnnotationSetName; was a crash if annSet was undefined.
  • Duplicate filter condition removed from selectFilteredEntityAnnotations.

⚠️ Cookie Rename Note

All NextAuth session cookies renamed next-auth.*dave.*. Existing sessions will be invalidated on deploy — users will need to log in again.


Deploy Checklist

  • Rename env var KEYCLOAK_IDKEYCLOAK_CLIENT_ID in all environments
  • Set NEXT_PUBLIC_FRAME_ANCESTORS to the parent app origin in production
  • Set ES_HOST in qavectorizer if not using the default (es)
  • Expect a forced logout for all users on deploy (cookie rename)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant