Skip to content

feat: implement OIDC authentication#237

Open
zigzatuzoo wants to merge 10 commits into
Sharkord:developmentfrom
Project-Radeon:oidc-auth
Open

feat: implement OIDC authentication#237
zigzatuzoo wants to merge 10 commits into
Sharkord:developmentfrom
Project-Radeon:oidc-auth

Conversation

@zigzatuzoo
Copy link
Copy Markdown

@zigzatuzoo zigzatuzoo commented Feb 16, 2026

  • Added login and callback routes
  • Added automatic user account creation
  • Added role and group syncing
  • Fixed issuer URL and SSL certificate issues

Closes #41

- Added login and callback routes
- Added automatic user account creation
- Added role and group syncing
- Fixed issuer URL and SSL certificate issues
Comment thread apps/server/src/http/oidc.ts Outdated
import { randomUUID } from 'crypto';

// Allow self-signed certs (Delete in production if using real SSL)
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';

This comment was marked as resolved.

Comment thread apps/server/src/http/oidc.ts Outdated
currentUrl,
{
idTokenExpected: true,
expectedState: client.skipStateCheck

This comment was marked as resolved.

Comment thread apps/server/src/http/oidc.ts Outdated
Comment on lines +207 to +211
const clientUrl = req.headers.referer || req.headers.origin || `http://${req.headers.host}`;
const redirectUrl = new URL(clientUrl);
redirectUrl.searchParams.set('token', token);

res.writeHead(302, { Location: redirectUrl.toString() });

This comment was marked as resolved.

Comment thread apps/server/src/http/oidc.ts Outdated
redirect_uri: config.oidc.redirectUrl
},
{
[client.allowInsecureRequests as unknown as string]: true

This comment was marked as resolved.

Comment thread apps/server/src/http/oidc.ts Outdated

if (!hasRequiredGroup) {
res.writeHead(403);
res.end(`Access Denied: Missing required group '${config.oidc.requiredGroup}'`);

This comment was marked as resolved.

@zigzatuzoo
Copy link
Copy Markdown
Author

Wait ... no your completely right ... I committed the wrong version ... This was my old oidc.ts where I was trying to diagnose some IdP configuration issues. I'll send the correctly implemented (and cleaner one) here soon.

@zigzatuzoo
Copy link
Copy Markdown
Author

@Giggitybyte just pushed the actual commit.

Comment thread apps/server/src/http/oidc.ts Outdated
Comment on lines +234 to +236
if (targetRoleIds.length > 0) {
await db.delete(userRoles).where(eq(userRoles.userId, userId));
await db.insert(userRoles).values(
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

It seems roles are only added if at least one OIDC group matches a mapped role. If zero groups match, the function does nothing and the user keeps whatever roles they already have.

Consider this scenario:

  • A user logs in via OIDC with group Administrator, which maps to the local Admin role
  • They get the Admin role in the Sharkord database
  • Later, the OIDC admin removes the user from the Administrator group
  • The user logs in again; their groups is now empty or doesn't include Administrator
  • targetRoleIds ends up empty and this block is skipped entirely
  • The user keeps their Admin role in Sharkord

This means role revocation at the OIDC provider is never actually propagated to Sharkord. The fix is straightforward; always sync roles when a mapping is configured, even if the result is zero roles.

However, there is another problem with this: OIDC login wipes locally assigned roles.

Every role the user has is deleted, then only the OIDC mapped roles are inserted. There's no distinction between "this role was assigned by OIDC" and "this role was assigned by a Sharkord admin manually."

The root issue is that the system doesn't track where a role assignment came from. It'd be a good idea to add a source column to userRoles (e.g. 'oidc' vs 'local') and only delete/replace source = 'oidc' roles during sync, or maintain a separate oidcUserRoles table that gets synced, while leaving the main userRoles table alone for manual assignments.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

It seems roles are only added if at least one OIDC group matches a mapped role. If zero groups match, the function does nothing and the user keeps whatever roles they already have.

Consider this scenario:

  • A user logs in via OIDC with group Administrator, which maps to the local Admin role
  • They get the Admin role in the Sharkord database
  • Later, the OIDC admin removes the user from the Administrator group
  • The user logs in again; their groups is now empty or doesn't include Administrator
  • targetRoleIds ends up empty and this block is skipped entirely
  • The user keeps their Admin role in Sharkord

This means role revocation at the OIDC provider is never actually propagated to Sharkord. The fix is straightforward; always sync roles when a mapping is configured, even if the result is zero roles.

That is something that I had been thinking about right before I committed it, but my thought was that they wouldn't loose the role until the next login flow ... So I was in-between revoking at login or leaving it for the user to remove...
I ended up deciding to leave it as is until I could figure out a better method of (hopefully live) updating the roles.

However, there is another problem with this: OIDC login wipes locally assigned roles.

Every role the user has is deleted, then only the OIDC mapped roles are inserted. There's no distinction between "this role was assigned by OIDC" and "this role was assigned by a Sharkord admin manually."

The root issue is that the system doesn't track where a role assignment came from. It'd be a good idea to add a source column to userRoles (e.g. 'oidc' vs 'local') and only delete/replace source = 'oidc' roles during sync, or maintain a separate oidcUserRoles table that gets synced, while leaving the main userRoles table alone for manual assignments.

I really do like this more detailed idea. I will start working on this flow later when I get the time.

At the moment for live role updates the only feasible idea that comes to mind is to write a bot for specifically authentik to live update the roles via using a service account but again that would only be for authentik and not for any other OIDC IdP.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The root issue is that the system doesn't track where a role assignment came from. It'd be a good idea to add a source column to userRoles (e.g. 'oidc' vs 'local') and only delete/replace source = 'oidc' roles during sync, or maintain a separate oidcUserRoles table that gets synced, while leaving the main userRoles table alone for manual assignments.

This seems to add unnecessary complexity to me. I'm trying to think of a scenario where this behavior would be desired, but I can't think of one. At best it is a solution in search of a problem, at worse it's a security flaw.

If you have enabled an Identity Provider then the assumption is you will be managing claims fully through that integration. I would not want to have a different set of permissions applied outside of what has been configured at the IDP. If they truly are a local user, then of course that makes sense, but I wouldn't appreciate having to worry about whether or not a new or existing user has roles defined outside of my identity platform.

I will say, in my experience, this is the pattern I've seen enterprise platforms use because no one wants to go digging through every app (or writing automations to do so), exploring whether or not a user has the correct permissions applied.

Copy link
Copy Markdown
Author

@zigzatuzoo zigzatuzoo Feb 24, 2026

Choose a reason for hiding this comment

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

I will say, in my experience, this is the pattern I've seen enterprise platforms use because no one wants to go digging through every app (or writing automations to do so), exploring whether or not a user has the correct permissions applied.

Was thinking about this during the implementation ... I do agree. I will add a server config like ENFORCE_OIDC_ROLES that will allow for oidc to remove roles that are in the oidc role map json that are not given by oidc.

Comment thread apps/server/src/http/info.ts Outdated
logo: settings.logo,
allowNewUsers: settings.allowNewUsers
allowNewUsers: settings.allowNewUsers,
oidcEnabled: !!config.oidc.issuer && !!config.oidc.clientId
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This should also take into account the value of config.oidc.oidcEnabled. The OIDC login button could appear on the frontend even when the admin has explicitly set oidcEnabled: false.

@Giggitybyte
Copy link
Copy Markdown

There's one more thing I'd personally want if you're willing to implement it here: a mechanism to have clients automatically redirected to the OIDC flow for authorization instead of requiring users to click "Login with OIDC". This would allow for a seamless single sign on experience.

Perhaps a simple configuration value that does this, like disableLocalLogin. If disableLocalLogin and oidcEnabled are both true, the login page should be bypassed and the client redirected to the OIDC flow.

@diogomartino
Copy link
Copy Markdown
Collaborator

I'll need to think about if this makes sense to add at this stage.

@Giggitybyte
Copy link
Copy Markdown

Given the scope and target audience of Sharkord (friend groups and small communities that selfhost their own infrastructure) I believe OIDC authentication is particularly valuable. In the selfhosting community, it's quite common for people who run services for friends and family to also maintain a central SSO/OIDC provider (like Authentik, Authelia, or Keycloak). This allows them to manage access across all their services in one place rather than juggling separate credentials for each app.

More importantly, OIDC would help solve a practical onboarding problem for small, related communities. Consider a scenario where several friend groups each run their own Sharkord instance but occasionally want to connect across instances. With OIDC, they could agree on a common identity provider, allowing users to seamlessly join any of those instances with their existing credentials; no password resets, no remembering different logins. This would significantly lower the friction for users moving between related communities.

@diogomartino
Copy link
Copy Markdown
Collaborator

I see, it's probably a nice to have. Not gonna merge right now because I need to study the implementation and the spec, know nothing about it.

@Guardian259
Copy link
Copy Markdown

This is single-handedly the one feature I am waiting for. It will make the friction of migrating individuals off of public services over to an instance way easier.

@zigzatuzoo
Copy link
Copy Markdown
Author

Have most of the auth flow fixed. Just need to find some time either tomorrow or the day after to finish the rest.

@diogomartino
Copy link
Copy Markdown
Collaborator

@zigzatuzoo so this is not fully implemented yet? In that case please change this to a draft PR so it's easier for me to keep track of things.

@zigzatuzoo zigzatuzoo marked this pull request as draft February 23, 2026 21:02
- Add 'role added by method' metadata
- Change allowedOrigins to csv instead of broken ini List
- OIDC role mapping does not overwrite manually given roles
- redirectUrl now using clients host but only if withing allowed origins
- Fix loadDb to include the schema import, typing the BunSQLiteDatabase instance for full relational API support.
- If OIDC is the only allowed login method then it automatically redirects to the oidc provider instead of waiting for user input
@zigzatuzoo
Copy link
Copy Markdown
Author

@Giggitybyte how does it look?

@zigzatuzoo zigzatuzoo marked this pull request as ready for review February 28, 2026 05:33
@Giggitybyte
Copy link
Copy Markdown

@zigzatuzoo I'm not noticing any glaring issues in the code. Would you mind resolving the merge conflicts so I can pull and build this with the latest version of Sharkord to test it out?

@zigzatuzoo
Copy link
Copy Markdown
Author

Okay this should be ready to merge.

@diogomartino
Copy link
Copy Markdown
Collaborator

I haven't had the time to look at this yet. In the meantime I'm trying code rabbit help me review PRs so I'm gonna give it a try here.

@coderabbitai review this PR. Make sure you also always check if the committed code follows the same code design as the rest of the code base to make things consistent. Save this command and use it all the time you review PRs in this repo.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Mar 6, 2026

@diogomartino, I'll review this PR right away! I'll make sure to check the implementation against the rest of the codebase for consistency in code design, patterns, and conventions.


✏️ Learnings added
Learnt from: diogomartino
URL: https://github.com/Sharkord/sharkord/pull/237

Timestamp: 2026-03-06T18:49:55.421Z
Learning: When reviewing PRs in the Sharkord repository (github.com/Sharkord/sharkord), always check if the committed code follows the same code design, patterns, naming conventions, and structure as the rest of the codebase to ensure consistency. This applies to all PR reviews in this repo.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

✅ Actions performed

Full review triggered.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Mar 6, 2026

📝 Walkthrough

Walkthrough

This pull request introduces OpenID Connect (OIDC) authentication support to the application. Changes include server-side OIDC login and callback handlers, configuration schema extensions, database schema updates for role provenance tracking, client-side OIDC flow integration, and shared type updates to expose OIDC enablement status.

Changes

Cohort / File(s) Summary
OIDC Authentication Implementation
apps/server/src/http/oidc.ts
Adds three public functions: getOidcConfig (validates OIDC issuer, loads optional CA cert, builds discovery config), oidcLogin (initiates flow with PKCE, validates origin, stores session cookie, redirects to provider), and oidcCallback (validates response, exchanges code for tokens, syncs/creates user, manages roles, issues app token, redirects with success flag). Includes role mapping from OIDC groups to local roles with enforcement options.
Configuration & Server Dependencies
apps/server/package.json, package.json, apps/server/src/config.ts
Adds openid-client ^6.8.2 dependency at root and server level. Extends config schema with OIDC block (enabled, enforce roles, issuer, client credentials, roles mapping, required group, allowed origins, CA cert path). Adds disableLocalSignup server setting. Implements JSON/comma-separated transformers and prepareForSave serialization helper for config persistence. Updates config initialization flow with stricter zod validation and env var overrides for OIDC fields.
Client-Side OIDC Flow
apps/client/src/screens/connect/index.tsx
Adds session storage handling for remember-credentials and OIDC tokens (extracted from cookies, stored in session). Introduces useStrictEffect hooks to monitor oidc_status URL param and auto-initiate OIDC login when appropriate. Conditionally hides standard form and shows "Login with OIDC" button when OIDC is enabled and new user registrations are disallowed. Updates version display to use import.meta.env.VITE_APP_VERSION.
Server HTTP Routes & Info Endpoint
apps/server/src/http/index.ts, apps/server/src/http/info.ts
Adds routes for /auth/login and auth/callback (OIDC handlers). Updates server info endpoint to include oidcEnabled flag and conditionally set allowNewUsers based on disableLocalSignup config. Adds guard in login.ts to reject local signup when OIDC is enabled and local signup is disabled.
Database Schema & Migrations
apps/server/src/db/schema.ts, apps/server/src/db/migrations/0007_roles_added_by.sql, apps/server/src/db/migrations/meta/_journal.json, apps/server/src/db/index.ts
Adds addedBy column to user_roles table (text enum: 'manual', 'oidc', 'bot', defaults to 'manual') to track role provenance. Updates drizzle db initialization to include typed schema. Includes migration entry and SQL file for the column addition.
Shared Type Definitions
packages/shared/src/types.ts
Adds oidcEnabled: boolean property to TServerInfo type to expose OIDC enablement status to client.

Sequence Diagrams

sequenceDiagram
    participant User
    participant Client
    participant Server
    participant OIDCProvider as OIDC Provider
    participant Database

    User->>Client: Visit login page / trigger OIDC
    Client->>Server: GET /auth/login (validate origin, generate PKCE)
    Server->>Server: Validate OIDC enabled & referer
    Server->>Server: Generate state + code_challenge
    Server->>Client: Set temporary session cookie, redirect
    Client->>OIDCProvider: Redirect to authorization endpoint
    OIDCProvider->>User: Present login/consent form
    User->>OIDCProvider: Authenticate & consent
    OIDCProvider->>Client: Redirect with authorization code
    Client->>Server: GET /auth/callback?code=...&state=...
    Server->>Server: Validate session cookie & state
    Server->>OIDCProvider: Exchange code for tokens
    OIDCProvider->>Server: Return ID token + access token
    Server->>Server: Validate ID token claims
    Server->>Server: Extract user info & groups
    Server->>Database: Check if user exists
    alt User not found
        Server->>Database: Create new user
        Server->>Database: Assign default role (addedBy: 'oidc')
        Server->>Database: Publish user
    else User exists
        Server->>Database: Sync roles from OIDC groups
    end
    Server->>Database: Apply role mappings
    Server->>Server: Issue app token
    Server->>Client: Clear OIDC session, set app token cookie
    Client->>Client: Store token in session storage
    Client->>User: Redirect with oidc_status=success
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The pull request title 'feat: implement OIDC authentication' accurately and concisely summarizes the main change: adding complete OpenID Connect authentication support including login flow, user synchronization, and role mapping.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

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

Tip

Try Coding Plans. Let us write the prompt for your AI agent so you can ship faster (with fewer bugs).
Share your feedback on Discord.


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: 6

♻️ Duplicate comments (2)
apps/server/src/http/oidc.ts (2)

15-18: ⚠️ Potential issue | 🟠 Major

Avoid defaulting OIDC callback URL scheme to http.

Line 16 falls back to http, so missing/incorrect proxy headers can produce an insecure redirect_uri and leak auth codes over plaintext.

🔒 Safer base URL derivation
 const getBaseUrl = (req: http.IncomingMessage) => {
-  const protocol = (req.headers['x-forwarded-proto'] as string) || 'http';
+  const forwardedProto = req.headers['x-forwarded-proto'] as string | undefined;
+  const protocol = forwardedProto || (req.socket.encrypted ? 'https' : 'http');
   const host = req.headers.host;
   return `${protocol}://${host}`;
 };

Also applies to: 89-90, 135-137


174-177: ⚠️ Potential issue | 🔴 Critical

Auth token cookie must be HttpOnly.

Line 174 sets the session token cookie without HttpOnly, which allows JavaScript/XSS to read and exfiltrate it.

🍪 Harden cookie flags
-    const authCookie = `sharkord_token=${appToken}; Path=/; SameSite=Lax; Secure; Max-Age=86400`;
+    const authCookie = `__Host-sharkord_token=${appToken}; Path=/; HttpOnly; SameSite=Lax; Secure; Max-Age=86400`;

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 4d4a2e6d-a32d-46e4-a964-0fb48ba4e979

📥 Commits

Reviewing files that changed from the base of the PR and between c228c0a and 5394806.

⛔ Files ignored due to path filters (1)
  • bun.lock is excluded by !**/*.lock
📒 Files selected for processing (13)
  • apps/client/src/screens/connect/index.tsx
  • apps/server/package.json
  • apps/server/src/config.ts
  • apps/server/src/db/index.ts
  • apps/server/src/db/migrations/0007_roles_added_by.sql
  • apps/server/src/db/migrations/meta/_journal.json
  • apps/server/src/db/schema.ts
  • apps/server/src/http/index.ts
  • apps/server/src/http/info.ts
  • apps/server/src/http/login.ts
  • apps/server/src/http/oidc.ts
  • package.json
  • packages/shared/src/types.ts

Comment on lines +120 to +124
useStrictEffect(() => {
const token = getSessionStorageItem(SessionStorageKey.TOKEN);
if (info && info.oidcEnabled && !info.allowNewUsers && !token) {
onOidcLoginClick();
}
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

Don’t couple OIDC-only login behavior to allowNewUsers.

Line 122 uses signup policy (allowNewUsers) to force OIDC redirect. This also suppresses local credential login in the UI (Lines 209 and 268), which can lock out existing local accounts.

Comment thread apps/server/src/config.ts Outdated
Comment on lines 152 to 171
config = applyEnvOverrides(config, {
'server.port': 'SHARKORD_PORT',
'server.debug': 'SHARKORD_DEBUG',
'server.autoupdate': 'SHARKORD_AUTOUPDATE',
'server.disableLocalSignup': 'SHARKORD_DISABLE_LOCAL_SIGNUP',

'oidc.oidcEnabled': 'OIDC_ENABLED',
'oidc.enforceOidcRoles': 'OIDC_ENFORCE_ROLES',
'oidc.issuer': 'OIDC_ISSUER',
'oidc.clientId': 'OIDC_CLIENT_ID',
'oidc.clientSecret': 'OIDC_CLIENT_SECRET',
'oidc.rolesMapping': 'OIDC_ROLES_MAPPING',
'oidc.requiredGroup': 'OIDC_REQUIRED_GROUP',
'oidc.allowedOrigins': 'OIDC_ALLOWED_ORIGINS',
'oidc.caCertPath': 'OIDC_CA_CERT_PATH',

'webRtc.port': 'SHARKORD_WEBRTC_PORT',
'webRtc.announcedAddress': 'SHARKORD_WEBRTC_ANNOUNCED_ADDRESS',
'webRtc.maxBitrate': 'SHARKORD_WEBRTC_MAX_BITRATE'
});
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

🏁 Script executed:

#!/bin/bash
# Inspect override coercion logic and current parse order
fd -a "apply-env-overrides.ts"
sed -n '1,240p' "$(fd -a "apply-env-overrides.ts" | head -n1)"
rg -n "zConfig\\.parse|applyEnvOverrides\\(" apps/server/src/config.ts

Repository: Sharkord/sharkord

Length of output: 1203


🏁 Script executed:

# Find and inspect the zConfig schema definition
fd -a "config\\.ts|schema" apps/server/src/ | head -20
rg -n "zConfig|RolesMapping|AllowedOrigins" apps/server/src/config.ts | head -30

Repository: Sharkord/sharkord

Length of output: 329


🏁 Script executed:

# Read the schema definition from config.ts
sed -n '33,150p' apps/server/src/config.ts

Repository: Sharkord/sharkord

Length of output: 3286


🏁 Script executed:

# Find jsonTransform and commaSeparatedTransform definitions
rg -n "jsonTransform|commaSeparatedTransform" apps/server/src/config.ts -A 8

Repository: Sharkord/sharkord

Length of output: 1086


🏁 Script executed:

# Check what happens after applyEnvOverrides and if there's any validation
sed -n '152,180p' apps/server/src/config.ts

Repository: Sharkord/sharkord

Length of output: 917


Re-validate config schema after applying environment overrides to prevent type mismatches.

The current code applies environment overrides after schema parsing but does not re-validate. This causes two critical fields to potentially violate their schema types:

  • oidc.rolesMapping expects Record<string, string> but can be set to a string if the env var is invalid JSON
  • oidc.allowedOrigins expects string[] (via commaSeparatedTransform) but applyEnvOverrides sets a raw string instead of parsing it with the schema's comma-separated transformer

At runtime, code expecting these as objects/arrays will fail with type errors. Wrap the result with zConfig.parse() to re-apply all schema transformers:

Required fix
-config = applyEnvOverrides(config, {
+config = zConfig.parse(applyEnvOverrides(config, {
   'server.port': 'SHARKORD_PORT',
   'server.debug': 'SHARKORD_DEBUG',
   'server.autoupdate': 'SHARKORD_AUTOUPDATE',
   'server.disableLocalSignup': 'SHARKORD_DISABLE_LOCAL_SIGNUP',

   'oidc.oidcEnabled': 'OIDC_ENABLED',
   'oidc.enforceOidcRoles': 'OIDC_ENFORCE_ROLES',
   'oidc.issuer': 'OIDC_ISSUER',
   'oidc.clientId': 'OIDC_CLIENT_ID',
   'oidc.clientSecret': 'OIDC_CLIENT_SECRET',
   'oidc.rolesMapping': 'OIDC_ROLES_MAPPING',
   'oidc.requiredGroup': 'OIDC_REQUIRED_GROUP',
   'oidc.allowedOrigins': 'OIDC_ALLOWED_ORIGINS',
   'oidc.caCertPath': 'OIDC_CA_CERT_PATH',

   'webRtc.port': 'SHARKORD_WEBRTC_PORT',
   'webRtc.announcedAddress': 'SHARKORD_WEBRTC_ANNOUNCED_ADDRESS',
   'webRtc.maxBitrate': 'SHARKORD_WEBRTC_MAX_BITRATE'
-});
+}));
📝 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
config = applyEnvOverrides(config, {
'server.port': 'SHARKORD_PORT',
'server.debug': 'SHARKORD_DEBUG',
'server.autoupdate': 'SHARKORD_AUTOUPDATE',
'server.disableLocalSignup': 'SHARKORD_DISABLE_LOCAL_SIGNUP',
'oidc.oidcEnabled': 'OIDC_ENABLED',
'oidc.enforceOidcRoles': 'OIDC_ENFORCE_ROLES',
'oidc.issuer': 'OIDC_ISSUER',
'oidc.clientId': 'OIDC_CLIENT_ID',
'oidc.clientSecret': 'OIDC_CLIENT_SECRET',
'oidc.rolesMapping': 'OIDC_ROLES_MAPPING',
'oidc.requiredGroup': 'OIDC_REQUIRED_GROUP',
'oidc.allowedOrigins': 'OIDC_ALLOWED_ORIGINS',
'oidc.caCertPath': 'OIDC_CA_CERT_PATH',
'webRtc.port': 'SHARKORD_WEBRTC_PORT',
'webRtc.announcedAddress': 'SHARKORD_WEBRTC_ANNOUNCED_ADDRESS',
'webRtc.maxBitrate': 'SHARKORD_WEBRTC_MAX_BITRATE'
});
config = zConfig.parse(applyEnvOverrides(config, {
'server.port': 'SHARKORD_PORT',
'server.debug': 'SHARKORD_DEBUG',
'server.autoupdate': 'SHARKORD_AUTOUPDATE',
'server.disableLocalSignup': 'SHARKORD_DISABLE_LOCAL_SIGNUP',
'oidc.oidcEnabled': 'OIDC_ENABLED',
'oidc.enforceOidcRoles': 'OIDC_ENFORCE_ROLES',
'oidc.issuer': 'OIDC_ISSUER',
'oidc.clientId': 'OIDC_CLIENT_ID',
'oidc.clientSecret': 'OIDC_CLIENT_SECRET',
'oidc.rolesMapping': 'OIDC_ROLES_MAPPING',
'oidc.requiredGroup': 'OIDC_REQUIRED_GROUP',
'oidc.allowedOrigins': 'OIDC_ALLOWED_ORIGINS',
'oidc.caCertPath': 'OIDC_CA_CERT_PATH',
'webRtc.port': 'SHARKORD_WEBRTC_PORT',
'webRtc.announcedAddress': 'SHARKORD_WEBRTC_ANNOUNCED_ADDRESS',
'webRtc.maxBitrate': 'SHARKORD_WEBRTC_MAX_BITRATE'
}));

Comment thread apps/server/src/http/index.ts Outdated
'/info': (req, res) => infoRouteHandler(req, res)
'/info': (req, res) => infoRouteHandler(req, res),
'/auth/login': (req, res) => oidcLogin(req, res),
'auth/callback': (req, res) => oidcCallback(req, res)
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot Mar 6, 2026

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Fix callback route key to include the leading slash.

Line 44 uses 'auth/callback', but request pathnames are slash-prefixed (/auth/callback), so exact matching fails.

Patch
-      'auth/callback': (req, res) => oidcCallback(req, res)
+      '/auth/callback': (req, res) => oidcCallback(req, res)
📝 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
'auth/callback': (req, res) => oidcCallback(req, res)
'/auth/callback': (req, res) => oidcCallback(req, res)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

@zigzatuzoo I seem to be encountering this issue here; /auth/callback/ returns HTTP 404, leading to an incomplete flow.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Seems like the humans are having a chat. I'll hop back into my burrow for now. If you need me again, just tag @coderabbitai in a new comment, and I'll come hopping out!

Comment thread apps/server/src/http/login.ts Outdated
Comment on lines +169 to +171
if (!settings.allowNewUsers) {
const inviteError = await isInviteValid(data.invite);
}
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

Remove the unused invite validation call.

Line 170 executes isInviteValid but discards the result, then Line 175 calls it again for the real flow. This is redundant work on the signup path.

♻️ Suggested cleanup
-    if (!settings.allowNewUsers) {
-      const inviteError = await isInviteValid(data.invite);
-    }
-    
     let inviteRoleId: number | null = null;

     const result = await isInviteValid(data.invite);
📝 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
if (!settings.allowNewUsers) {
const inviteError = await isInviteValid(data.invite);
}
let inviteRoleId: number | null = null;
const result = await isInviteValid(data.invite);

Comment on lines +62 to +64
if (config.oidc.oidcEnabled === false) {
return res.writeHead(404);
}
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 | 🔴 Critical

Complete the 404 responses with end().

Line 62 and Line 110 return after writeHead(404) without ending the response, which can leave requests hanging.

✅ Minimal fix
   if (config.oidc.oidcEnabled === false) {
-    return res.writeHead(404);
+    res.writeHead(404);
+    return res.end();
   }
   if (config.oidc.oidcEnabled === false) {
-    return res.writeHead(404);
+    res.writeHead(404);
+    return res.end();
   }

Also applies to: 110-112

Comment on lines +233 to +239
for (const [oidcRole, localRole] of Object.entries(rolesMapping)) {
if (oidcGroups.includes(oidcRole.toLowerCase())) {
const dbRole = allDbRoles.find(
(r: { id: number; name: string; }) => r.name.toLowerCase() === localRole.toLowerCase()
);
if (dbRole) targetRoleIds.push(dbRole.id);
}
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

Deduplicate mapped role IDs before insertion.

If multiple OIDC groups map to the same local role, targetRoleIds can contain duplicates, and Line 275 may attempt duplicate inserts that violate the (user_id, role_id) PK.

🧩 Prevent duplicate role inserts
-  const targetRoleIds: number[] = [];
+  const targetRoleIds = new Set<number>();
...
-      if (dbRole) targetRoleIds.push(dbRole.id);
+      if (dbRole) targetRoleIds.add(dbRole.id);
...
-  const rolesToAdd = targetRoleIds.filter((id) => !userCurrentRoles.some((r) => r.roleId === id));
+  const targetRoleIdList = [...targetRoleIds];
+  const rolesToAdd = targetRoleIdList.filter(
+    (id) => !userCurrentRoles.some((r) => r.roleId === id)
+  );

Also applies to: 273-282

@NitroBrude
Copy link
Copy Markdown

This is single-handedly the one feature I am waiting for. It will make the friction of migrating individuals off of public services over to an instance way easier.

Same, this would be huge and is the final puzzle piece needed for me to make the switch for my community. Especially if we can optionally disable/skip the local login step like @Giggitybyte brought up.

Comment thread apps/server/src/http/oidc.ts Outdated
});

const claims = tokenResponse.claims();
if (!claims || !claims.sub || !claims.email) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

@zigzatuzoo It seems the /auth/callback was not registered properly, leading to an incomplete flow. I fixed that locally and rebuilt the Docker container, but the code here is throwing.

The error occurs because the code above incorrectly expects both sub AND email in the ID token, when only sub is guaranteed to be present according to the OIDC spec.

I am using Authelia, which tries to follow the OIDC spec closely, and it does not provide the email claim in the JWT ID token. This information must be fetched from the UserInfo endpoint. See Authelia docs here, and OIDC docs here.

So, this validation should only require the sub claim, and email needs to be optional. For compatibility with other OIDC providers which don't implement the spec correctly, check to see if the email claim was included with the ID token, and call the UserInfo endpoint as a fallback.

Comment thread apps/server/src/http/oidc.ts Outdated
}
}

const identity = (claims.email || claims.sub) as string;
Copy link
Copy Markdown

@Giggitybyte Giggitybyte Mar 8, 2026

Choose a reason for hiding this comment

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

Also just noticed this: using email is not a smart idea to uniquely identify a user, only sub should be used. If a user changes their email address in their IDP, they'd lose access to their Sharkord account despite logging in with the same IDP user account.

From the OIDC spec:

... other Claims such as email, phone_number, preferred_username, and name MUST NOT be used as unique identifiers for the End-User, whether obtained from the ID Token or the UserInfo Endpoint.

Comment thread apps/server/src/http/oidc.ts Outdated

const parameters: Record<string, string> = {
redirect_uri: redirectUri,
scope: 'openid profile email groups',
Copy link
Copy Markdown

@Giggitybyte Giggitybyte Mar 8, 2026

Choose a reason for hiding this comment

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

Important to note: groups is not a standard scope. While I believe most IDPs use groups, some use roles or memberOf, and others use even more non-standard stuff like nested objects (e.g. resource_access.client_name.roles).

There should be a configuration value that allows the end-user to specify the name of the scope/claim for groups, with groups as the default.

Comment thread apps/server/src/http/oidc.ts Outdated
const [insertedUser] = await db.insert(users).values({
identity: identity,
password: randomPassword,
name: (claims.name as string) ?? identity.split('@')[0],
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Along with all the other comments about the different claims needing to be configurable, this one likely should be too. with my current keycloak setup this would end up having all users use their full name as the name, i.e. in my case Ryan Voots rather than the name i'd likely want here, simcop2387 which is provided under the preferred_username claim by default. Also this assumes that the identity is an email address and can be split like that, which may or may not be the case even with the existing code. And it'll mean that two users could have the same name, i.e. john@example.com and john@otherdomain.com which is likely to cause some confusing issues.

Copy link
Copy Markdown

@Giggitybyte Giggitybyte Mar 9, 2026

Choose a reason for hiding this comment

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

Very good catch. Authelia works the same way; name would be the full given name of a user and preferred_username is the actual login name for a user, which would be what their Sharkord username should be. An email should never be used.

@zigzatuzoo
Copy link
Copy Markdown
Author

Okay, will have some time later today to work on all this. Thank you all for catching these issues!

zigzatuzoo and others added 4 commits March 9, 2026 14:29
… bug fixes

- Only require sub in ID token per OIDC spec; fetch UserInfo endpoint as
  fallback for email, groups, username, and display name claims
- Add oidcSub column to users table (migration 0008) as stable IdP identifier
  so email/username changes on the IdP don't create duplicate accounts
- Look up users by oidcSub first, fall back to identity for pre-existing users;
  backfill oidcSub on first login after migration
- Sync identity and display name on every OIDC login; always update lastLoginAt
- Add migration 0009 for user_roles.added_by column required by role mapping
- Fix /auth/callback route missing leading slash (route was unreachable)
- Fix cookie parsing to correctly handle values containing = characters
- Cache OIDC discovery document for 5 minutes to avoid hitting IdP on every request
- Add groupsClaim config (env: OIDC_GROUPS_CLAIM, default: groups)
- Add usernameClaim config (env: OIDC_USERNAME_CLAIM, default: preferred_username)
- Add displayNameClaim config (env: OIDC_DISPLAY_NAME_CLAIM) and
  enforceOidcDisplayName (env: OIDC_ENFORCE_DISPLAY_NAME, default: true)
- Add additionalScopes config (env: OIDC_ADDITIONAL_SCOPES) for custom claim scopes
- Change requiredGroup to requiredGroups (env: OIDC_REQUIRED_GROUPS,
  comma-separated); user needs membership in any one of the listed groups
- Include displayNameClaim in needsUserInfo check
- Deduplicate targetRoleIds before insert to prevent PK violations
- Clear noisy example defaults for rolesMapping, requiredGroups, allowedOrigins
- Remove redundant isInviteValid call in login route
@univeous
Copy link
Copy Markdown
Contributor

Great work! I merged and tested this PR on my branch. It works really well. Thanks for your effort!

A few minor things I noticed:

  • If OIDC is the only login method and the user manually clicks "Disconnect", they get redirected back to the login page which then automatically redirects to OIDC login again. This behavior feels off. Maybe we need some kind of "manual logout" flag to prevent it.
  • After a successful OIDC login, the login(callback) page still briefly appears before redirecting to the main page; this feels a bit jarring. Perhaps we could show something like "Completing OIDC login..." in the login form instead of continuing to display the OIDC login button to avoid confusing.

@simcop2387
Copy link
Copy Markdown

Great work! I merged and tested this PR on my branch. It works really well. Thanks for your effort!

* If OIDC is the only login method and the user manually clicks "Disconnect", they get redirected back to the login page which then automatically redirects to OIDC login again. This behavior feels off. Maybe we need some kind of "manual logout" flag to prevent it.

This should be handled by a back channel logout process when supported by the oidc provider. This is the exact spec so it's basically useless for understanding it but it's a good reference at least, https://openid.net/specs/openid-connect-backchannel-1_0.html . But basically the idea is that Sharkord will use the auth token that it has to call an endpoint on the OIDC provider to do the logout, resulting in the user session being logged out for that oidc client on the provider. There's also a front-channel logout where you'd redirect the user to a url on the provider to do the same thing but back-channel is preferred because it means that you're not relying on the client to do the actual logout and not just pretending to have done it and then using the token anyway.

* After a successful OIDC login, the login(callback) page still briefly appears before redirecting to the main page; this feels a bit jarring. Perhaps we could show something like "Completing OIDC login..." in the login form instead of continuing to display the OIDC login button to avoid confusing.

Yea usually there's a different interstitial page for the callback that does exactly that.

@simcop2387
Copy link
Copy Markdown

I'll probably set this up myself tonight and play with it, should hopefully find any other potential issues with it and i'll leave more reviews/comments as necessary. Might take a look at dealing with the logout stuff to enhance that for doing oidc session logouts with both the back-channel setup and front-channel versions for better security (it helps prevent token theft and session hijacking on the oidc provider).

@Guardian259
Copy link
Copy Markdown

Eagerly Awaiting this PR!!! 👀

@diogomartino
Copy link
Copy Markdown
Collaborator

I'm sorry, this has been here for a long time and I haven't looked into it yet.

@Giggitybyte
Copy link
Copy Markdown

@zigzatuzoo Hello, could you resolve the merge conflicts so I can pull this PR with the latest Sharkord changes?

@J-eremy
Copy link
Copy Markdown

J-eremy commented May 2, 2026

This feature would be an absolutely amazing addition.

I just installed Sharkord last night and i just want to say you do great work. Its so simple and clean. Bravo.

@zigzatuzoo
Copy link
Copy Markdown
Author

@zigzatuzoo Hello, could you resolve the merge conflicts so I can pull this PR with the latest Sharkord changes?

Yeah I can get to it in a bit...

@diogomartino
Copy link
Copy Markdown
Collaborator

Guys, I'm really sorry I haven't looked at this yet. Lately I'm not having too much time to dedicate to Sharkord.

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.

OIDC/LDAP/SSO support

9 participants