Skip to content

feat(manage): opt-in OIDC admin auth + Organization CRUD endpoints#371

Closed
patrickleet wants to merge 2 commits into
Openpanel-dev:mainfrom
hops-ops:pr/admin-jwt-auth
Closed

feat(manage): opt-in OIDC admin auth + Organization CRUD endpoints#371
patrickleet wants to merge 2 commits into
Openpanel-dev:mainfrom
hops-ops:pr/admin-jwt-auth

Conversation

@patrickleet
Copy link
Copy Markdown

@patrickleet patrickleet commented May 18, 2026

Adds two related capabilities to the existing `/manage` REST surface:

  1. An opt-in OIDC Bearer-token auth path. When configured with an OIDC issuer, the api pod accepts `Authorization: Bearer ` headers as an alternative to the existing `openpanel-client-id` / `openpanel-client-secret` Client-pair auth. The token is validated against the issuer's JWKS (discovered from `/.well-known/openid-configuration`) and must carry a configured audience + role claim.

  2. `/manage/organizations` REST endpoints for GET/POST/PATCH/DELETE. Mirrors the existing `/manage/projects` shape. Useful for both Client-pair callers (managing their own Org) and OIDC callers (provisioning Organizations programmatically).

Why

The existing `/manage` API requires a root-typed Client, which is only mintable through the dashboard UI after onboarding. That makes it impossible to bootstrap an OpenPanel install from a fully programmatic flow (Terraform, Crossplane, GitOps pipelines, etc.).

If a self-hoster has already configured an OIDC IdP for dashboard sign-in (Zitadel / Keycloak / Authentik / Okta), the same IdP can issue ServiceUser tokens that `/manage` validates — no second auth primitive inside OpenPanel, no Personal Access Token table to maintain, the IdP's existing token rotation / revocation / audit handles the operational story.

For installs without an OIDC IdP, nothing changes: the env vars are unset and the JWT auth path is disabled.

Configuration

Off by default. Enabled by setting these env vars on the api pod:

```
ADMIN_OIDC_ISSUER # e.g. https://auth.example.com
ADMIN_OIDC_AUDIENCE # JWT aud claim required (e.g. openpanel-admin)
ADMIN_OIDC_REQUIRED_ROLE # default: openpanel:admin
ADMIN_OIDC_ORG_CLAIM # claim that holds the bound Org ID
# default: urn:zitadel:iam:user:resourceowner:id
```

The role check looks at three common claim shapes so different IdPs work without per-IdP code:

  • `roles[]` (Auth0, Keycloak with role mapper, generic)
  • `scope` space-separated (Authelia, dex)
  • `urn:zitadel:iam:org:project:roles` nested map (Zitadel default)

Implementation

`apps/api/src/utils/auth.ts` adds:

  • `validateAdminJwtRequest` — verifies the JWT against the issuer's JWKS, checks audience + role + org claim
  • `validateAdminRequest` — wrapper that picks JWT or Client-pair auth based on the `Authorization` header shape
  • A synthesized `IServiceClientWithProject` for JWT-authenticated requests so existing controllers (`request.client!.organizationId`) work unchanged

`apps/api/src/routes/manage.router.ts` switches the preHandler from `validateManageRequest` to `validateAdminRequest` (backwards-compatible) and registers the new `/manage/organizations` routes.

`apps/api/src/controllers/manage.controller.ts` adds `listOrganizations`, `getOrganization`, `createOrganization`, `updateOrganization`, `deleteOrganization` matching the project controller pattern.

Backwards compatibility

Purely additive:

  • `ADMIN_OIDC_ISSUER` unset → no behavioral change for any caller
  • Existing /manage routes continue to accept Client-pair auth even when OIDC is configured
  • No DB schema changes
  • New dependency: `jose` in `apps/api` (already in the broader Node ecosystem)

How to test

Local docker-compose setup with any OIDC IdP:

  1. Spin up an IdP (e.g. `zitadel/zitadel` or `quay.io/keycloak/keycloak` in docker)
  2. Create a ServiceUser / client_credentials grant with audience `openpanel-admin` and a role grant `openpanel:admin`
  3. Export to api container:
    ```
    ADMIN_OIDC_ISSUER=http://
    ADMIN_OIDC_AUDIENCE=openpanel-admin
    ```
  4. `pnpm dev`
  5. `curl -H "Authorization: Bearer " http://localhost:3333/api/manage/projects\` — should return 200 instead of the usual 401 "Manage: Client ID seems to be malformed"
  6. `curl -X POST -H "Authorization: Bearer " -H "content-type: application/json" -d '{"name":"test-org"}' http://localhost:3333/api/manage/organizations\` — creates a new Org

I verified end-to-end against a self-hosted Zitadel install: ServiceUser provisioned via Zitadel's Crossplane provider, JWT obtained via client_credentials grant, `POST /manage/organizations` + subsequent `POST /manage/projects` + `POST /manage/clients` all succeed and appear in the OpenPanel UI under the freshly-created Org.

Companion to #370

This builds on top of #370 (the user-facing OIDC sign-in provider) but is independent — the two PRs touch different files and can land in either order.

Not included

  • Member / Invitation REST endpoints. Adding them is mechanical (mirror the org/project controllers, gate on the auth wrapper) but I wanted to keep this PR focused. Happy to add in a follow-up if reviewers want.
  • A "discovery" mode that derives `ADMIN_OIDC_ISSUER` from the existing OIDC sign-in `OIDC_AUTHORIZATION_ENDPOINT` env. Could be a nice DX win for installs that point both at the same IdP, but explicit configuration felt safer for v1.

Summary by CodeRabbit

  • Dependencies

    • Added jose library for secure JWT token verification and processing
  • New Features

    • Organization management: API now supports creating, listing, retrieving, updating, and deleting organizations with automatic ID generation and timezone configuration
    • Admin OIDC authentication: JWT Bearer token-based authentication via OIDC with issuer discovery, enabling secure organization-scoped administrative access

Review Change Stack

Adds a JWT-bearer admin auth path for /manage routes, gated behind
ADMIN_OIDC_ISSUER configuration. When unset, /manage continues to
accept the existing openpanel-client-id / openpanel-client-secret
Client-pair auth unchanged.

When configured, /manage accepts `Authorization: Bearer <jwt>` from
any token signed by the configured OIDC issuer (Zitadel, Keycloak,
Authentik, etc.) that:
  - validates against the issuer's JWKS (discovered via
    /.well-known/openid-configuration)
  - matches the configured audience (ADMIN_OIDC_AUDIENCE)
  - carries the required role claim (ADMIN_OIDC_REQUIRED_ROLE,
    defaults to 'openpanel:admin'), tolerantly looking at
    `roles[]`, `scope`, and Zitadel's nested
    `urn:zitadel:iam:org:project:roles` shape
  - carries an organization claim (ADMIN_OIDC_ORG_CLAIM, defaults
    to the Zitadel resourceowner-id claim)

The JWT-validated request synthesizes a Client-shaped record with
`type: root`, `secret: null`, `id: jwt:<sub>`, and the
organizationId from the claim — so existing controllers in
manage.controller.ts work without branching on auth source.

Implementation:
- jose@^6 added to apps/api for JWKS-based JWT verification
- validateAdminJwtRequest + validateAdminRequest wrapper in
  apps/api/src/utils/auth.ts
- manage.router.ts switches its preHandler from
  validateManageRequest to validateAdminRequest

Backwards-compatible. Off by default. No DB schema changes. No new
auth tier inside OpenPanel — identity / orgs / roles stay in the
configured IdP.

Refs the openpanel-admin-jwt-auth spec.
Adds GET / POST / PATCH / DELETE /manage/organizations[/:id] routes
mirroring the existing /manage/projects shape. Use cases:

- Platform-admin OIDC callers (the new JWT auth path) provisioning
  new Organizations as part of tenant onboarding workflows
- Root-Client callers reading/updating/deleting their own
  Organization

v1 deliberately scopes list/get to the caller's bound organization.
Cross-org listing for a true instance-admin claim would require a
richer claim model than v1 ships; the endpoints just trust the auth
context's organizationId.

Member and Invite admin endpoints are deferred — the
JWT-auth-bootstrap-then-OIDC-sign-in flow makes them less load-bearing
than they'd be for a session-cookie admin UX. Adding them later is
additive.

Refs the openpanel-admin-jwt-auth spec.
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 18, 2026

📝 Walkthrough

Walkthrough

This PR adds optional OIDC JWT Bearer token authentication for admin /manage routes with automatic fallback to existing client credentials, introduces a full organization CRUD feature with request-scoped handlers and validation, and registers five new REST endpoints for organization management.

Changes

Admin Organization Management

Layer / File(s) Summary
JWT Auth Infrastructure & Dependencies
apps/api/package.json, apps/api/src/utils/auth.ts
Adds jose dependency and implements OIDC JWT verification with JWKS discovery, per-process caching, issuer/audience validation, required role enforcement, and a smart wrapper validateAdminRequest that routes to JWT verification when Bearer tokens are present and OIDC is configured, otherwise falls back to the existing client-secret auth.
Organization CRUD Contracts & Handlers
apps/api/src/controllers/manage.controller.ts
Defines zCreateOrganization and zUpdateOrganization Zod schemas; implements five exported handlers (listOrganizations, getOrganization, createOrganization, updateOrganization, deleteOrganization) that scope reads to the caller's organization, validate existence on mutations (404 otherwise), generate organization IDs from names, set default onboarding status, and persist changes via the database layer.
Router Integration & Authorization Switch
apps/api/src/routes/manage.router.ts
Updates imports to include organization schemas, changes the /manage preHandler from validateManageRequest to validateAdminRequest to enable JWT auth, and registers five new endpoints (GET /organizations, GET /organizations/:id, POST /organizations, PATCH /organizations/:id, DELETE /organizations/:id) with appropriate schemas and descriptions.

Sequence Diagram(s)

sequenceDiagram
  participant Request as HTTP Request
  participant validateAdminRequest as validateAdminRequest()
  participant validateAdminJwtRequest as validateAdminJwtRequest()
  participant validateManageRequest as validateManageRequest()
  participant JWKS as JWKS Discovery
  participant TokenVerifier as jwt.verify()
  participant Result as Scoped Client

  Request->>validateAdminRequest: headers
  alt Bearer token + OIDC enabled
    validateAdminRequest->>validateAdminJwtRequest: Bearer token
    validateAdminJwtRequest->>JWKS: fetch/cache keys
    validateAdminJwtRequest->>TokenVerifier: verify issuer, audience, role, org claim
    TokenVerifier->>Result: synthesized root client (org scoped)
  else Fallback
    validateAdminRequest->>validateManageRequest: openpanel credentials
    validateManageRequest->>Result: existing client
  end
Loading
classDiagram
  class zCreateOrganization {
    name: string (required)
    timezone: string | null (optional)
  }
  class zUpdateOrganization {
    name: string | null (optional)
    timezone: string | null (optional)
  }
  class OrganizationHandlers {
    listOrganizations() Organization[]
    getOrganization(id) Organization
    createOrganization(body) Organization
    updateOrganization(id, body) Organization
    deleteOrganization(id) void
  }
  zCreateOrganization <|-- OrganizationHandlers: input
  zUpdateOrganization <|-- OrganizationHandlers: input
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐰 A JWT hops into place, with JWKS and grace,
Organizations bloom in a scoped embrace,
Routes and schemas dance in perfect measure,
Admin auth flows—a cryptographic treasure! 🔐

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 31.25% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely summarizes the main changes: adding opt-in OIDC admin authentication and Organization CRUD endpoints to the /manage REST API.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

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

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

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

ESLint skipped: no ESLint configuration detected in root package.json. To enable, add eslint to devDependencies.


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
Contributor

@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

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/api/src/controllers/manage.controller.ts`:
- Around line 277-295: The handlers are incorrectly allowing cross-org access by
overwriting or ignoring request.params.id; update getOrganization to enforce
that request.params.id must equal request.client!.organizationId (do not replace
params.id with the client org id) and return a 403/404 when they differ, and
apply the same strict equality guard to updateOrganization and
deleteOrganization (ensure both functions check request.params.id ===
request.client!.organizationId before performing DB reads/updates/deletes and
bail out if not equal). Use the unique symbols getOrganization,
updateOrganization, deleteOrganization, request.params.id, and
request.client!.organizationId to locate and add the guard logic so callers
cannot act on other organizations.

In `@apps/api/src/utils/auth.ts`:
- Around line 319-327: When ADMIN_OIDC_ISSUER is set, require
ADMIN_OIDC_AUDIENCE to be present so jwtVerify will enforce aud; in
loadAdminOidcConfig check process.env.ADMIN_OIDC_AUDIENCE and if it's missing
either throw a clear error or return undefined and log an error instead of
returning a config with undefined audience; update the symmetric loader
mentioned in the comment (the other OIDC config loader around lines 439-441,
e.g., loadUserOidcConfig or the equivalent function) the same way so any enabled
issuer always mandates a non-empty audience.
- Around line 350-353: The discovery URL is being constructed with new
URL('/.well-known/openid-configuration', config.issuer) which drops any path
segment on config.issuer; update the logic that creates discoveryUrl so it
appends '/.well-known/openid-configuration' to the issuer value itself
(preserving any path component on config.issuer) — e.g., normalize config.issuer
to ensure it ends (or does not double) with a slash and then join the issuer +
'/.well-known/openid-configuration' to produce discoveryUrl; modify the code
that sets discoveryUrl (the variable named discoveryUrl that uses config.issuer)
to use this concatenation approach so path-based issuers like
https://example.com/tenant-1 resolve to
https://example.com/tenant-1/.well-known/openid-configuration.
🪄 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: 1aa889fd-fbad-47f7-99bd-c0fe8ba9a6eb

📥 Commits

Reviewing files that changed from the base of the PR and between cd3bef8 and e228ffb.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (4)
  • apps/api/package.json
  • apps/api/src/controllers/manage.controller.ts
  • apps/api/src/routes/manage.router.ts
  • apps/api/src/utils/auth.ts

Comment on lines +277 to +295
export async function getOrganization(
request: FastifyRequest<{ Params: { id: string } }>,
reply: FastifyReply
) {
const org = await db.organization.findFirst({
where: {
id: request.params.id,
// Same-org scoping. JWT-auth callers can only `get` the org
// their claim is bound to; cross-org reads require additional
// claim plumbing we haven't designed yet.
...(request.client!.organizationId
? { id: request.client!.organizationId }
: {}),
},
});
if (!org) {
throw new HttpError('Organization not found', { status: 404 });
}
reply.send({ data: org });
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Gate organization reads and mutations on request.params.id === request.client!.organizationId.

getOrganization currently overwrites request.params.id with request.client!.organizationId, so /organizations/other-id can return the caller's own org instead of a 404. updateOrganization and deleteOrganization skip that check entirely, which lets any authenticated manage caller update or delete another organization by ID.

🛡️ Suggested guard
 export async function getOrganization(
   request: FastifyRequest<{ Params: { id: string } }>,
   reply: FastifyReply
 ) {
+  if (request.params.id !== request.client!.organizationId) {
+    throw new HttpError('Organization not found', { status: 404 });
+  }
   const org = await db.organization.findFirst({
-    where: {
-      id: request.params.id,
-      ...(request.client!.organizationId
-        ? { id: request.client!.organizationId }
-        : {}),
-    },
+    where: { id: request.params.id },
   });
 export async function updateOrganization(...) {
+  if (request.params.id !== request.client!.organizationId) {
+    throw new HttpError('Organization not found', { status: 404 });
+  }
   const existing = await db.organization.findFirst({
     where: { id: request.params.id },
   });
 export async function deleteOrganization(...) {
+  if (request.params.id !== request.client!.organizationId) {
+    throw new HttpError('Organization not found', { status: 404 });
+  }
   const existing = await db.organization.findFirst({
     where: { id: request.params.id },
   });

Also applies to: 320-360

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/src/controllers/manage.controller.ts` around lines 277 - 295, The
handlers are incorrectly allowing cross-org access by overwriting or ignoring
request.params.id; update getOrganization to enforce that request.params.id must
equal request.client!.organizationId (do not replace params.id with the client
org id) and return a 403/404 when they differ, and apply the same strict
equality guard to updateOrganization and deleteOrganization (ensure both
functions check request.params.id === request.client!.organizationId before
performing DB reads/updates/deletes and bail out if not equal). Use the unique
symbols getOrganization, updateOrganization, deleteOrganization,
request.params.id, and request.client!.organizationId to locate and add the
guard logic so callers cannot act on other organizations.

Comment on lines +319 to +327
function loadAdminOidcConfig(): AdminOidcConfig | undefined {
const issuer = process.env.ADMIN_OIDC_ISSUER;
if (!issuer) {
return undefined;
}
return {
issuer: issuer.replace(/\/$/, ''),
audience: process.env.ADMIN_OIDC_AUDIENCE,
requiredRole: process.env.ADMIN_OIDC_REQUIRED_ROLE ?? DEFAULT_REQUIRED_ROLE,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Require ADMIN_OIDC_AUDIENCE whenever JWT admin auth is enabled.

ADMIN_OIDC_ISSUER alone currently enables bearer auth, but audience stays optional so jwtVerify skips aud enforcement entirely. That accepts any token from the issuer with the required role, even if it was minted for a different client/application.

🔐 Suggested guard
 function loadAdminOidcConfig(): AdminOidcConfig | undefined {
   const issuer = process.env.ADMIN_OIDC_ISSUER;
   if (!issuer) {
     return undefined;
   }
+  const audience = process.env.ADMIN_OIDC_AUDIENCE;
+  if (!audience) {
+    throw new Error(
+      'Admin OIDC: ADMIN_OIDC_AUDIENCE must be set when ADMIN_OIDC_ISSUER is configured',
+    );
+  }
   return {
     issuer: issuer.replace(/\/$/, ''),
-    audience: process.env.ADMIN_OIDC_AUDIENCE,
+    audience,
     requiredRole: process.env.ADMIN_OIDC_REQUIRED_ROLE ?? DEFAULT_REQUIRED_ROLE,

Also applies to: 439-441

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/src/utils/auth.ts` around lines 319 - 327, When ADMIN_OIDC_ISSUER is
set, require ADMIN_OIDC_AUDIENCE to be present so jwtVerify will enforce aud; in
loadAdminOidcConfig check process.env.ADMIN_OIDC_AUDIENCE and if it's missing
either throw a clear error or return undefined and log an error instead of
returning a config with undefined audience; update the symmetric loader
mentioned in the comment (the other OIDC config loader around lines 439-441,
e.g., loadUserOidcConfig or the equivalent function) the same way so any enabled
issuer always mandates a non-empty audience.

Comment on lines +350 to +353
const discoveryUrl = new URL(
'/.well-known/openid-configuration',
config.issuer,
);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

cat -n apps/api/src/utils/auth.ts | sed -n '340,360p'

Repository: Openpanel-dev/openpanel

Length of output: 781


🌐 Web query:

URL constructor JavaScript path handling base parameter behavior

💡 Result:

The JavaScript URL constructor resolves a relative URL against a base URL using standard URL resolution rules, which are not a simple string concatenation [1][2]. When you provide a base URL, the constructor treats the path component of that base as a directory structure [1][3]. Key behaviors include: 1. Path Resolution: The resolution process considers the current directory of the base URL, which includes all path segments up to the last forward slash (/) [3][2]. Any path segments after the last slash in the base URL are discarded [3][2]. 2. Relative Path Handling: - If the relative URL starts with a forward slash (/), it is treated as root-relative and resolves relative to the base origin (replacing the entire path of the base) [1][2]. - If the relative URL does not start with a slash, it is appended to the base URL's directory path [1][2]. - Relative references like../ or./ are resolved relative to the base URL's directory [1][2]. To ensure a path is appended correctly to a base URL, the base URL should end with a trailing slash, and the relative path should not start with a slash [4]. Example: // Base path "v1" is treated as a file, so it is stripped new URL("/items", "https://example.com/v1"); // Result: https://example.com/items // Adding a trailing slash preserves the "v1" directory new URL("items", "https://example.com/v1/"); // Result: https://example.com/v1/items If the first argument is an absolute URL, the base argument is ignored [3][2]. The constructor will throw a TypeError if the resulting URL is invalid [2][5].

Citations:


🌐 Web query:

OIDC Connect Discovery path-based issuer specification

💡 Result:

In OpenID Connect (OIDC) Discovery 1.0, the Issuer Identifier is a case-sensitive URL using the https scheme that may include scheme, host, port, and path components, but must not include query or fragment components [1][2][3]. When an OpenID Provider (OP) supports discovery, it must make its configuration document available at a specific location derived from the Issuer Identifier [1]. The specification defines a precise concatenation rule to handle cases where the Issuer Identifier contains a path component [1][4]: 1. If the Issuer Identifier contains a path component, any terminating forward slash (/) must be removed [1][5]. 2. The string /.well-known/openid-configuration is then appended to the resulting URL [1][4]. For example, if the Issuer Identifier is https://example.com/tenant-1, the Relying Party (RP) must request the configuration document from https://example.com/tenant-1/.well-known/openid-configuration [6][4]. This path-based approach is particularly important for multi-tenant environments, where a single host may support multiple distinct issuers, each identified by a unique path [3]. The Issuer Identifier returned by discovery must exactly match the iss claim in any ID Tokens issued by that provider, and it must also match the base URL used to retrieve the configuration document [4][3]. Failure to adhere to these exact matching rules can lead to security vulnerabilities, such as one provider impersonating another [4].

Citations:


Build the discovery URL from the issuer value instead of the origin root.

Using new URL('/.well-known/openid-configuration', config.issuer) treats the first argument as a root-relative path and discards any path component from the issuer. The OIDC Discovery specification requires path-based issuers (e.g., https://example.com/tenant-1) to have the well-known endpoint appended to the issuer value itself, resulting in https://example.com/tenant-1/.well-known/openid-configuration. The current code would incorrectly resolve to https://example.com/.well-known/openid-configuration for such providers.

🧭 Suggested fix
-  const discoveryUrl = new URL(
-    '/.well-known/openid-configuration',
-    config.issuer,
-  );
+  const discoveryUrl = new URL(
+    `${config.issuer.replace(/\/$/, '')}/.well-known/openid-configuration`,
+  );
📝 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
const discoveryUrl = new URL(
'/.well-known/openid-configuration',
config.issuer,
);
const discoveryUrl = new URL(
`${config.issuer.replace(/\/$/, '')}/.well-known/openid-configuration`,
);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/src/utils/auth.ts` around lines 350 - 353, The discovery URL is
being constructed with new URL('/.well-known/openid-configuration',
config.issuer) which drops any path segment on config.issuer; update the logic
that creates discoveryUrl so it appends '/.well-known/openid-configuration' to
the issuer value itself (preserving any path component on config.issuer) — e.g.,
normalize config.issuer to ensure it ends (or does not double) with a slash and
then join the issuer + '/.well-known/openid-configuration' to produce
discoveryUrl; modify the code that sets discoveryUrl (the variable named
discoveryUrl that uses config.issuer) to use this concatenation approach so
path-based issuers like https://example.com/tenant-1 resolve to
https://example.com/tenant-1/.well-known/openid-configuration.

@patrickleet
Copy link
Copy Markdown
Author

Closing this from our side for now — we've decided to take a different approach for our platform (chart-side bootstrap of a root Client at install time, mirroring how we already handle Zitadel's iam-admin) which sidesteps the need for any auth-model change in OpenPanel.

The branch lives on at https://github.com/hops-ops/openpanel-app/tree/pr/admin-jwt-auth in case anyone else wants to pick it up or use it as a reference for adding opt-in OIDC admin auth.

Not asking for upstream review while we're still uncertain about the design — thanks for taking the time to even glance at it, and apologies for the noise. #370 (the user-facing OIDC sign-in provider) remains open separately — that one we are confident in and would still love your review on whenever you have a moment.

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