Keycloack multi-issuer auth with iframe SSO support#1
Open
remusiesan wants to merge 2 commits intomainfrom
Open
Keycloack multi-issuer auth with iframe SSO support#1remusiesan wants to merge 2 commits intomainfrom
remusiesan wants to merge 2 commits intomainfrom
Conversation
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
getSessiontogetServerSession, and iframe requests bypass SSR auth redirects so the client-sideIframeAuthBridgecan 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.
dave.*cookies.AuthWatcherbefore expiry using Keycloak's refresh token.LoginAvatar, signs the user out of both DAVE and Keycloak.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 viapostMessage— no Keycloak redirect occurs inside the iframe.Token validation — the
iframe-tokenCredentialsProvider 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:
getServerSideProps): viaSec-Fetch-Dest: iframeheader, with aRefererfallback againstNEXT_PUBLIC_FRAME_ANCESTORS.window.self !== window.top(try/catch for cross-origin iframes that throw onwindow.topaccess).Comparison
SSO_TOKENvia postMessageIFRAME_REQUEST_TOKEN/sign-inif no sessionkeycloakiframe-token(CredentialsProvider)Environment Variables
KEYCLOAK_ISSUERKEYCLOAK_CLIENT_IDKEYCLOAK_ID— rename required on deploy.NEXT_PUBLIC_FRAME_ANCESTORShttps://toolbox.example.com). Restricts postMessage to this origin. Must be set in production.ES_HOSThost.docker.internal. Defaults toes.Changed Files
backend/documents/src/middlewares/keycloak-auth.jsReplaces loose manual realm-path suffix checking with strict issuer equality against
KEYCLOAK_ISSUER.jwksClientInstancetied toKEYCLOAK_ISSUER— no multi-issuer map.getKeyForToken(token)decodes the JWT payload, assertsiss === KEYCLOAK_ISSUER, then fetches the signing key from the JWKS endpoint.process.env.KEYCLOAK_ID→process.env.KEYCLOAK_CLIENT_ID.backend/documents/src/api/collection.jsEmpty
FacetEntryarray 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.jsconsole.log("doc", document.features.anonymized)left in the deanonymize endpoint.backend/qavectorizer/src/app.py+settings.pyES_HOSTandsettings.elastic_port. Previously hardcoded tohost.docker.internal:9201.collection_idinQueryElasticIndexRequestchanged from requiredstrtoOptional[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.{ type: 'SSO_READY' }to the parent.{ type: 'SSO_TOKEN', token }from the parent, scoped toNEXT_PUBLIC_FRAME_ANCESTORSin production.signIn('iframe-token', { accessToken: token })via the CredentialsProvider.IframeTokenExpired, sends{ type: 'IFRAME_REQUEST_TOKEN' }to request a fresh token.{ type: 'SSO_ERROR', error }back to the parent.signingInref prevents concurrent sign-in attempts if multipleSSO_TOKENmessages arrive in quick succession.frontend/pages/_app.tsxIframeAuthBridge— defined at module scope (outsideMyApp) so React sees a stable component type. Mounting it insideMyAppwould create a new type on every render, causing constant unmount/remount and re-firing all effects. Rendersnulland exists solely to calluseIframeAuth()inside theSessionProvidertree.AuthWatcher— also moved to module scope for the same reason. New behaviour:expclaim and schedules asetTimeoutto callupdate()just before expiry (buffer = min(60s, halfOfRemainingTime)).setIntervalifexpis absent or the token is unparseable./sign-inif any refresh fails.frontend/pages/api/auth/[...nextauth].tsNew
CredentialsProvider(id: 'iframe-token') — validates a raw Keycloak access token from the parent iframe without requiring a Keycloak client secret:sub,iss,expare present and token is not already expired.{iss}/protocol/openid-connect/userinfowith the token as Bearer — validates against live Keycloak.id,email,name,accessToken, andaccessTokenExpires.JWT/session callbacks for iframe provider:
iframe-tokensign-in, storesaccessToken,accessTokenExpires, andprovider: 'iframe'on the JWT.provider === 'iframe'and the token has expired, returns{ ...token, error: 'IframeTokenExpired' }— the client picks this up viauseIframeAuthand requests a new token from the parent.Cookie hardening — all NextAuth cookies renamed
next-auth.*→dave.*andsecureflag set totruein production only:next-auth.session-tokendave.session-tokennext-auth.callback-urldave.callback-urlnext-auth.csrf-tokendave.csrf-tokennext-auth.pkce.code_verifierdave.pkce.code_verifiernext-auth.statedave.statenext-auth.noncedave.nonceFail-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.tsxisInIframe()on mount (state-gated to avoid SSR mismatch).IframeAuthBridgeowns auth, not user interaction.routerremoved fromuseEffectdependency arrays where it caused spurious re-runs.frontend/utils/auth.tsTwo new utility functions:
isInIframe()(client-side) andisRequestFromIframe(req)(server-side).frontend/pages/index.tsx,search/index.tsx,collections/[id].tsx,settings/index.tsx,settings/llm.tsx,settings/annotation-configuration.tsxAll six protected pages receive the same two changes:
getSession→getServerSession— reads the session directly from the JWT cookie instead of making an extra HTTP round-trip to/api/auth/session.isRequestFromIframe(context.req)is checked before the session guard. If the request originates from an iframe, SSR skips the redirect to/sign-inand renders the page —IframeAuthBridgethen handles auth client-side.frontend/server/routers/collection.tsCollectiontype extended withconfig?: { typesToHide: string[]; typesOrder?: string[] }and deprecatedcollectionTypes?: string[].configmade optional — prevents crashes when older collection documents don't have the field.401/403now returnsnullinstead of throwing aTRPCError, allowing the UI to degrade gracefully.TRPCClientError,AnyCnameRecord.frontend/server/routers/search.tscollectionIdfallback changed from'N/A'→undefined—'N/A'was being sent to the indexer as a literal collection ID, causing bad queries on global search.tokenin addition toactiveCollection.id, preventing unauthenticated requests.response.okbefore 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.tserror.data.error→error.data.message→error.messagein priority order.error.status→error.response?.status(the former was alwaysundefinedfor fetch errors).frontend/components/LoginAvatar/LoginAvatar.tsx!inIframe). In iframe mode, session lifecycle is owned by the parent app.frontend/components/FilterChip.tsxkey?: stringtoMatchChipPropsto suppress React key prop warnings whenFilterChipis rendered in a list.frontend/modules/document/DocumentProvider/selectors.tsconsole.logdebug statements left inselectDocumentClustersfrom development.annSet?.name ?? null— safe access onselectCurrentAnnotationSetName; was a crash ifannSetwas undefined.selectFilteredEntityAnnotations.All NextAuth session cookies renamed
next-auth.*→dave.*. Existing sessions will be invalidated on deploy — users will need to log in again.Deploy Checklist
KEYCLOAK_ID→KEYCLOAK_CLIENT_IDin all environmentsNEXT_PUBLIC_FRAME_ANCESTORSto the parent app origin in productionES_HOSTin qavectorizer if not using the default (es)