Skip to content

feat: Keycloak SSO via better-auth genericOAuth#135

Closed
escooterclinic wants to merge 2 commits intoreqcore-inc:mainfrom
escooterclinic:esc
Closed

feat: Keycloak SSO via better-auth genericOAuth#135
escooterclinic wants to merge 2 commits intoreqcore-inc:mainfrom
escooterclinic:esc

Conversation

@escooterclinic
Copy link
Copy Markdown

@escooterclinic escooterclinic commented Apr 9, 2026

Summary

  • Add Keycloak SSO support using better-auth's genericOAuth plugin
  • Fully opt-in via 3 environment variables (KC_CLIENT_ID, KC_CLIENT_SECRET, KC_DISCOVERY_URL)
  • Zero impact when env vars are not set — existing auth unchanged
  • SSO button + divider on sign-in page
  • PKCE enabled for security
  • Fix create-org auto-switch when user already has an active organization

Changes

  • server/utils/env.ts — 3 new optional env vars with Zod validation
  • server/utils/auth.ts — conditional genericOAuth plugin registration with profile mapping
  • app/utils/auth-client.tsgenericOAuthClient() plugin added
  • app/pages/auth/sign-in.vue — SSO button + handleKeycloakSignIn() handler
  • app/pages/onboarding/create-org.vue — guard against auto-switch when session already has active org

How it works

When all 3 KC_* env vars are set, the genericOAuth plugin registers a keycloak provider. The sign-in page shows an "Sign in with SSO" button that triggers the OIDC flow. User profile (name, email, avatar) is mapped from standard OIDC claims.

Since this uses genericOAuth (not a Keycloak-specific plugin), it works with any OIDC provider — Authentik, Authelia, Okta, Azure AD — just by changing the discovery URL.

Keycloak setup

  1. Create OpenID Connect client in your realm
  2. Enable client authentication (confidential)
  3. Set redirect URI: https://your-domain.com/api/auth/callback/keycloak
  4. Copy client ID + secret to env vars
  5. Set discovery URL: https://keycloak.example.com/realms/REALM/.well-known/openid-configuration

Test plan

  • Verify sign-in page shows SSO button when KC env vars are set
  • Verify SSO button hidden when KC env vars are not set
  • Test OIDC flow creates new user with correct name/email
  • Test OIDC flow logs in existing user by email match
  • Verify PKCE is used in the authorization request
  • Verify create-org doesn't redirect when user already has active org

Related: #134

Summary by CodeRabbit

  • New Features

    • Added Keycloak-based Single Sign-On (SSO) authentication option for user sign-in.
  • Improvements

    • Enhanced organization auto-selection logic during onboarding to avoid unnecessary switches.
  • Chores

    • Updated development environment configuration and added automated deployment script.

root added 2 commits April 8, 2026 05:59
- Add genericOAuth plugin for Keycloak SSO (server + client)
- Add KC_CLIENT_ID/SECRET/DISCOVERY_URL env vars
- Add SSO button to sign-in page
- Fix MinIO console port conflict with Portainer (9001→9091)
- Fix create-org auto-switch redirecting when user already has active org
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 9, 2026

📝 Walkthrough

Walkthrough

The PR introduces Keycloak SSO authentication using the better-auth genericOAuth plugin, adding server-side OAuth configuration with environment variables and client-side sign-in UI. It also refines the onboarding auto-switch logic to check session state, adjusts MinIO's web console port mapping, and introduces an update script for deployment automation.

Changes

Cohort / File(s) Summary
Keycloak SSO Client Integration
app/pages/auth/sign-in.vue, app/utils/auth-client.ts
Added handleKeycloakSignIn() function triggering OAuth2 flow via authClient.signIn.oauth2 with Keycloak provider. Registered genericOAuthClient() plugin in auth client configuration.
Keycloak SSO Server Configuration
server/utils/auth.ts, server/utils/env.ts
Added conditional Keycloak OAuth plugin with providerId: 'keycloak', client credentials, discovery URL, and OIDC scope configuration. Extended environment schema with KC_CLIENT_ID, KC_CLIENT_SECRET, and KC_DISCOVERY_URL optional fields.
Onboarding Auto-Switch Logic
app/pages/onboarding/create-org.vue
Updated auto-switch watcher to retrieve current session via authClient.useSession() and prevent switching when activeOrganizationId is already set in session state.
Infrastructure & Deployment
docker-compose.yml, update.sh
Changed MinIO web console host port from 9001 to 9091. Added new update.sh bash script for automated repository updates with fast-forward pull, conditional Docker rebuild, and database migration execution.

Sequence Diagram(s)

sequenceDiagram
    participant User
    participant SignIn as Sign-in Page
    participant AuthClient as Auth Client<br/>(genericOAuth)
    participant Server as Auth Server
    participant Keycloak as Keycloak/OIDC<br/>Provider
    
    User->>SignIn: Click "Sign in with SSO"
    SignIn->>SignIn: handleKeycloakSignIn()<br/>(set isLoading=true)
    SignIn->>AuthClient: authClient.signIn.oauth2<br/>(providerId: 'keycloak')
    AuthClient->>Server: OAuth2 authorization request
    Server->>Keycloak: Redirect to Keycloak login
    Keycloak->>User: Display login form
    User->>Keycloak: Enter credentials & authorize
    Keycloak->>Server: Return authorization code
    Server->>Keycloak: Exchange code for tokens
    Keycloak->>Server: Return ID/access tokens
    Server->>Server: Extract user profile<br/>(name, email, picture)
    Server->>SignIn: Redirect with session
    SignIn->>SignIn: set isLoading=false
    SignIn->>User: Navigate to dashboard
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~22 minutes

Possibly related issues

Possibly related PRs

Poem

🐰 A SSO hop, so spry and bright,
Keycloak keys unlock the light!
With OAuth flows and sessions true,
The rabbit greets the world anew.

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main feature: adding Keycloak SSO support via better-auth's genericOAuth plugin, which is the primary focus of this changeset.
Description check ✅ Passed The PR description is comprehensive and complete. It includes a clear summary, detailed change breakdown, implementation details, setup instructions, and a thorough test plan addressing all major functionality areas.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (3)
server/utils/auth.ts (1)

113-117: Consider defensive handling for missing profile claims.

If the OIDC provider doesn't return name, given_name, or family_name (e.g., misconfigured or minimal claims), the name field becomes an empty string. Consider a fallback to the email prefix or a placeholder.

Optional defensive fallback
 mapProfileToUser: (profile) => ({
-  name: profile.name || `${profile.given_name || ''} ${profile.family_name || ''}`.trim(),
+  name: profile.name
+    || `${profile.given_name || ''} ${profile.family_name || ''}`.trim()
+    || profile.email?.split('@')[0]
+    || 'SSO User',
   email: profile.email,
   image: profile.picture,
 }),
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/utils/auth.ts` around lines 113 - 117, The mapProfileToUser mapping
can produce an empty name when profile lacks name/given_name/family_name; update
mapProfileToUser to defensively compute name by first using profile.name, then
joining given_name and family_name if present, and if still empty fallback to
the local-part of profile.email (substring before '@') or a constant placeholder
like "User" so name is never an empty string; ensure you reference
mapProfileToUser and use profile.email only if present to avoid runtime errors.
server/utils/env.ts (1)

91-96: Consider adding all-or-nothing validation for Keycloak config.

Currently, if an operator sets only one or two of the KC_* variables, SSO is silently disabled. Adding a superRefine check to warn when the configuration is partial could improve operator experience during deployment.

Example validation addition
   })
   .superRefine((data, ctx) => {
+    // Warn if Keycloak SSO is partially configured
+    const kcVars = [data.KC_CLIENT_ID, data.KC_CLIENT_SECRET, data.KC_DISCOVERY_URL]
+    const kcSet = kcVars.filter(Boolean).length
+    if (kcSet > 0 && kcSet < 3) {
+      ctx.addIssue({
+        code: z.ZodIssueCode.custom,
+        path: ['KC_CLIENT_ID'],
+        message: 'Keycloak SSO requires all three: KC_CLIENT_ID, KC_CLIENT_SECRET, and KC_DISCOVERY_URL',
+      })
+    }
+
     // BETTER_AUTH_URL can be derived at runtime from RAILWAY_PUBLIC_DOMAIN,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/utils/env.ts` around lines 91 - 96, Add an all-or-nothing validation
to the environment zod schema so Keycloak config is either fully provided or
fully absent: update the schema that currently defines KC_CLIENT_ID,
KC_CLIENT_SECRET, and KC_DISCOVERY_URL (which use
emptyToUndefined.pipe(z.string().min(1)).optional() / .url().optional()) to
include a superRefine on the parent object (e.g., env schema) that checks if any
of these KC_* fields are set then all must be set (otherwise add a clear
ZodIssue with path(s) and message), and if none are set allow it; ensure you
reference KC_CLIENT_ID, KC_CLIENT_SECRET, and KC_DISCOVERY_URL in the check.
app/pages/auth/sign-in.vue (1)

90-98: SSO button is always visible, even when Keycloak is not configured.

The button will be shown to all users regardless of whether the server has Keycloak enabled (via KC_CLIENT_ID, KC_CLIENT_SECRET, KC_DISCOVERY_URL). While clicking it when unavailable triggers an error that is displayed (improving upon silent failure), the UX could be better by hiding the button entirely when SSO is disabled. Expose Keycloak availability via a NUXT_PUBLIC_ variable in nuxt.config.ts and conditionally render the button, or create a server endpoint that returns enabled providers.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/pages/auth/sign-in.vue` around lines 90 - 98, The SSO button is always
shown even when Keycloak is not configured; fix by exposing Keycloak
availability to the client and conditionally rendering the button. Add a boolean
public flag (e.g. NUXT_PUBLIC_KEYCLOAK_ENABLED or
NUXT_PUBLIC_KEYCLOAK_AVAILABLE) in nuxt.config.ts derived from KC_CLIENT_ID/
KC_CLIENT_SECRET/ KC_DISCOVERY_URL (or create a server endpoint that returns
provider availability), then update sign-in.vue to check that flag before
rendering the button and keep the existing click handler handleKeycloakSignIn
unchanged so the button only appears when Keycloak is enabled.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@app/pages/auth/sign-in.vue`:
- Around line 71-83: handleKeycloakSignIn currently hardcodes callbackURL to
localePath('/dashboard') and loses route.query.invitation; update
handleKeycloakSignIn to read the invitation from route.query (e.g., const
invitation = route.query.invitation) and preserve it when calling
authClient.signIn.oauth2 by including the invitation in the callback (or oauth
state) so the app can restore context after SSO. Modify the callbackURL passed
to authClient.signIn.oauth2 (or add a state param) to include the encoded
invitation value and keep the existing error/loading handling in
handleKeycloakSignIn.

In `@docker-compose.yml`:
- Line 26: Update the stale MinIO console URL comment for the port mapping "-
\"127.0.0.1:9091:9001\"" so it reflects the host port 9091 (e.g., change "MinIO
Console → http://localhost:9001" to "MinIO Console → http://localhost:9091") to
avoid operator confusion.

In `@update.sh`:
- Around line 33-38: Replace the brittle sleep+exec approach: stop using the
fixed sleep and instead run docker compose up with the --wait option (e.g., keep
detached if desired by including -d) to wait for service start, and when running
the migration use docker compose exec -T app npm run db:migrate so no TTY is
allocated in non-interactive CI; update the lines that currently call "docker
compose up -d", "sleep 5", and "docker compose exec app npm run db:migrate" to
use the --wait flag and add -T to the exec invocation.

---

Nitpick comments:
In `@app/pages/auth/sign-in.vue`:
- Around line 90-98: The SSO button is always shown even when Keycloak is not
configured; fix by exposing Keycloak availability to the client and
conditionally rendering the button. Add a boolean public flag (e.g.
NUXT_PUBLIC_KEYCLOAK_ENABLED or NUXT_PUBLIC_KEYCLOAK_AVAILABLE) in
nuxt.config.ts derived from KC_CLIENT_ID/ KC_CLIENT_SECRET/ KC_DISCOVERY_URL (or
create a server endpoint that returns provider availability), then update
sign-in.vue to check that flag before rendering the button and keep the existing
click handler handleKeycloakSignIn unchanged so the button only appears when
Keycloak is enabled.

In `@server/utils/auth.ts`:
- Around line 113-117: The mapProfileToUser mapping can produce an empty name
when profile lacks name/given_name/family_name; update mapProfileToUser to
defensively compute name by first using profile.name, then joining given_name
and family_name if present, and if still empty fallback to the local-part of
profile.email (substring before '@') or a constant placeholder like "User" so
name is never an empty string; ensure you reference mapProfileToUser and use
profile.email only if present to avoid runtime errors.

In `@server/utils/env.ts`:
- Around line 91-96: Add an all-or-nothing validation to the environment zod
schema so Keycloak config is either fully provided or fully absent: update the
schema that currently defines KC_CLIENT_ID, KC_CLIENT_SECRET, and
KC_DISCOVERY_URL (which use emptyToUndefined.pipe(z.string().min(1)).optional()
/ .url().optional()) to include a superRefine on the parent object (e.g., env
schema) that checks if any of these KC_* fields are set then all must be set
(otherwise add a clear ZodIssue with path(s) and message), and if none are set
allow it; ensure you reference KC_CLIENT_ID, KC_CLIENT_SECRET, and
KC_DISCOVERY_URL in the check.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 598285c3-d8e7-4585-bda7-156737614d1b

📥 Commits

Reviewing files that changed from the base of the PR and between 596e6c8 and 30e3d14.

📒 Files selected for processing (7)
  • app/pages/auth/sign-in.vue
  • app/pages/onboarding/create-org.vue
  • app/utils/auth-client.ts
  • docker-compose.yml
  • server/utils/auth.ts
  • server/utils/env.ts
  • update.sh

Comment on lines +71 to +83
async function handleKeycloakSignIn() {
isLoading.value = true
error.value = ''
try {
await authClient.signIn.oauth2({
providerId: 'keycloak',
callbackURL: localePath('/dashboard'),
})
} catch (e: unknown) {
error.value = e instanceof Error ? e.message : 'SSO sign-in failed'
isLoading.value = false
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

SSO callback doesn't preserve pending invitation context.

The email sign-in flow checks route.query.invitation and redirects to the invitation acceptance page after authentication. The SSO flow hardcodes callbackURL: localePath('/dashboard'), so users accepting invitations via SSO will lose the invitation context.

Proposed fix to preserve invitation context
 async function handleKeycloakSignIn() {
   isLoading.value = true
   error.value = ''
   try {
+    const pendingInvitation = route.query.invitation as string | undefined
+    const callbackURL = pendingInvitation
+      ? localePath(`/auth/accept-invitation/${pendingInvitation}`)
+      : localePath('/dashboard')
     await authClient.signIn.oauth2({
       providerId: 'keycloak',
-      callbackURL: localePath('/dashboard'),
+      callbackURL,
     })
   } catch (e: unknown) {
     error.value = e instanceof Error ? e.message : 'SSO sign-in failed'
     isLoading.value = false
   }
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
async function handleKeycloakSignIn() {
isLoading.value = true
error.value = ''
try {
await authClient.signIn.oauth2({
providerId: 'keycloak',
callbackURL: localePath('/dashboard'),
})
} catch (e: unknown) {
error.value = e instanceof Error ? e.message : 'SSO sign-in failed'
isLoading.value = false
}
}
async function handleKeycloakSignIn() {
isLoading.value = true
error.value = ''
try {
const pendingInvitation = route.query.invitation as string | undefined
const callbackURL = pendingInvitation
? localePath(`/auth/accept-invitation/${pendingInvitation}`)
: localePath('/dashboard')
await authClient.signIn.oauth2({
providerId: 'keycloak',
callbackURL,
})
} catch (e: unknown) {
error.value = e instanceof Error ? e.message : 'SSO sign-in failed'
isLoading.value = false
}
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/pages/auth/sign-in.vue` around lines 71 - 83, handleKeycloakSignIn
currently hardcodes callbackURL to localePath('/dashboard') and loses
route.query.invitation; update handleKeycloakSignIn to read the invitation from
route.query (e.g., const invitation = route.query.invitation) and preserve it
when calling authClient.signIn.oauth2 by including the invitation in the
callback (or oauth state) so the app can restore context after SSO. Modify the
callbackURL passed to authClient.signIn.oauth2 (or add a state param) to include
the encoded invitation value and keep the existing error/loading handling in
handleKeycloakSignIn.

Comment thread docker-compose.yml
ports:
- "127.0.0.1:9000:9000" # S3 API — localhost only, never expose publicly
- "127.0.0.1:9001:9001" # MinIO Console → http://localhost:9001
- "127.0.0.1:9091:9001" # MinIO Console → http://localhost:9001
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Fix stale MinIO console URL comment.

Line 26 maps host port 9091 to container port 9001, but the comment still points to http://localhost:9001. Please update it to avoid operator confusion.

✏️ Proposed fix
-      - "127.0.0.1:9091:9001" # MinIO Console → http://localhost:9001
+      - "127.0.0.1:9091:9001" # MinIO Console → http://localhost:9091
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
- "127.0.0.1:9091:9001" # MinIO Console → http://localhost:9001
- "127.0.0.1:9091:9001" # MinIO Console → http://localhost:9091
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docker-compose.yml` at line 26, Update the stale MinIO console URL comment
for the port mapping "- \"127.0.0.1:9091:9001\"" so it reflects the host port
9091 (e.g., change "MinIO Console → http://localhost:9001" to "MinIO Console →
http://localhost:9091") to avoid operator confusion.

Comment thread update.sh
Comment on lines +33 to +38
echo "==> Restarting stack..."
docker compose up -d

echo "==> Running migrations..."
sleep 5
docker compose exec app npm run db:migrate
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

Does docker compose execallocate a TTY by default (requiring-Tin non-interactive scripts), and what minimum Docker Compose version supportsdocker compose up --wait?

💡 Result:

  • Yes. docker compose exec allocates a TTY by default, so in non-interactive/scripted usage you typically need -T/--no-tty (or --interactive=false where appropriate) to avoid “not a TTY” issues. [1]
  • Minimum Docker Compose version for docker compose up --wait: v2.7.0 (it’s referenced as an existing behavior in the v2.7.0 release notes). [3]
    • The docker compose up CLI docs also document --wait / --wait-timeout. [2]

Sources: [1] (docs.docker.com) [2] (docs.docker.com) [3] (github.com)

Citations:


Harden migration execution for unattended runs.

Line 37 uses a fixed sleep and Line 38 uses docker compose exec without -T; this is brittle in automation. The exec command allocates a TTY by default, which causes failures in non-interactive contexts. Replace the sleep with proper health checking via docker compose up --wait (requires Docker Compose v2.7.0+) and add -T to prevent TTY allocation.

🔧 Proposed fix
 echo "==> Restarting stack..."
-docker compose up -d
+docker compose up -d --wait

 echo "==> Running migrations..."
-sleep 5
-docker compose exec app npm run db:migrate
+docker compose exec -T app npm run db:migrate
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
echo "==> Restarting stack..."
docker compose up -d
echo "==> Running migrations..."
sleep 5
docker compose exec app npm run db:migrate
echo "==> Restarting stack..."
docker compose up -d --wait
echo "==> Running migrations..."
docker compose exec -T app npm run db:migrate
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@update.sh` around lines 33 - 38, Replace the brittle sleep+exec approach:
stop using the fixed sleep and instead run docker compose up with the --wait
option (e.g., keep detached if desired by including -d) to wait for service
start, and when running the migration use docker compose exec -T app npm run
db:migrate so no TTY is allocated in non-interactive CI; update the lines that
currently call "docker compose up -d", "sleep 5", and "docker compose exec app
npm run db:migrate" to use the --wait flag and add -T to the exec invocation.

@JoachimLK JoachimLK mentioned this pull request Apr 10, 2026
9 tasks
@JoachimLK
Copy link
Copy Markdown
Contributor

Thank you, this is a really clean PR and exactly the right direction. The genericOAuth approach, the PKCE toggle, the conditional plugin registration, and the create-org auto-switch fix are all spot-on.

While this was in open, I'd been building out a broader SSO implementation on feat/sso (now #136) that ended up covering this same ground plus a second tier (per-org enterprise SSO via Better Auth's sso plugin). Since the two overlap almost entirely, I'm going to close this in favour of #136 rather than try to merge both.

A few things from your PR that directly influenced the final implementation:

Really appreciate you taking the time on this. If you find any bugs with the SSO implementation, just let me know : )

@JoachimLK JoachimLK closed this Apr 10, 2026
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.

2 participants