# Native OpenID Connect Support for Redmine — Design Proposal

| Field | Value |
| --- | --- |
| **Date** | 2026-05-24 |
| **Status** | Draft proposal — for discussion on redmine.org issue [#43352](https://www.redmine.org/issues/43352) |
| **Target Redmine version** | 7.0.0 |
| **Related issues** | [#43352](https://www.redmine.org/issues/43352), [#37363](https://www.redmine.org/issues/37363), [#35755](https://www.redmine.org/issues/35755) |

---

## Executive summary

Redmine has no native federated authentication beyond the legacy LDAP `AuthSource`. The community has produced four OpenID Connect (OIDC) plugins of varying maturity (enricohuang, devopskube, nanego, kontron) but each has at least one disqualifying defect — most commonly the ID token signature is not cryptographically verified. The user-facing impact is that customers relying on SSO with Keycloak, Okta, Auth0, Azure AD, or Google Workspace cannot do so without accepting third-party plugins of mixed quality.

This document proposes a native OpenID Connect 1.0 implementation for Redmine 7.0.0. The design borrows the strongest ideas from existing plugins (multi-provider model from kontron, link-table identity from enricohuang, gem-backed JWT verification from nanego) and adds capabilities none of them have today: full SSO logout propagation, OIDC-correct ID token validation, opt-in token introspection, and a universal claim-to-Redmine-role mapping that works across all major identity providers (Keycloak, Microsoft Entra ID, Okta, Auth0, Google Cloud Identity / Workspace, and any generic OIDC IdP).

The implementation lives entirely in Redmine core (no plugin layer), targeting current trunk (Rails 8.1, Ruby 3.2+). It introduces three new tables (`oidc_providers`, `oidc_user_links`, plus two columns on existing membership tables), one new gem dependency (`jwt`), and roughly 2 000 lines of code with comprehensive tests. Discovery (`/.well-known/openid-configuration`) is mandatory; admins enter only an issuer URL and credentials. Provider presets ship for Keycloak, Okta, Auth0, Azure AD, Google, and a fully-customizable generic OIDC profile.

Authentication uses authorization-code flow with mandatory PKCE (S256). Identity is stabilised by a `(issuer, sub)` link record; email matching is only used at first login and only when `email_verified=true`. Role mapping offers three universal strategies driven by a configurable dot-notation claim path: `group` (claim → Redmine `Group` by name), `project_role` (claim → `Member` × `Role` on a project, encoded as `<project>.<role>`), or `none`. The admin flag is sync-controlled via an explicit opt-in `admin_role_values` whitelist. Manual group/membership assignments made by Redmine admins are never overwritten.

The session lifecycle is reactive, not polled. A middleware refreshes tokens lazily on the first request inside a configurable buffer window before access-token expiry. A refresh failure (`invalid_grant`) revokes the local session — the standard mechanism by which IdP-side user revocation flows through to Redmine. Three additional layers cover stronger guarantees on opt-in: Back-Channel Logout for cross-system propagation; periodic introspection for sub-token-TTL revocation latency; and a deferred post-MVP background refresh for idle-user role syncing.

Two-factor authentication is delegated to the IdP when the `amr` or `acr` claim signals MFA was performed; auto-provisioned users without TOTP setup are routed through Redmine's existing TOTP setup wizard when global policy demands 2FA but the IdP did not assert MFA. Localization ships with English and Czech. The patch is structured to be reviewable as a series.

The remainder of this document details the data model, control flow, security invariants, and testing approach. We propose to engage with the assigned maintainer (Marius BĂLTEANU) on issue #43352 before opening any patch, and align scope and approach to upstream preferences first.

---

## Background

### Why this matters

OpenID Connect 1.0 is the de-facto identity protocol for enterprise web applications. Customers consistently cite the lack of native OIDC as a blocker when comparing Redmine against alternatives. The historical OpenID 2.0 support was removed in [#35755](https://www.redmine.org/issues/35755); OIDC has been an open feature request in [#37363](https://www.redmine.org/issues/37363) for four years, and [#43352](https://www.redmine.org/issues/43352) is now assigned to the 7.0.0 milestone.

### Community signal from existing issues

The community sentiment in #37363 and #43352 is unambiguous and consistent over the past four years:

- *"In my opinion a native Integration is an absolute must when it comes to the future of Redmine."* — Christoffer Rumohr, #37363#1
- *"Maybe also add support for different OAuth2 providers, like Github, Gitlab or Google."* — Felix Singer, #37363#2 (note: explicitly mentions plain OAuth2, not only OIDC)
- *"Having native, standard OIDC support in Redmine is an absolute must-have."* — Quentin Aymard, #37363#3
- *"With an OpenID Connect Core 1.0 compliant implementation, it would be possible to use third-party IdPs for login, such as Entra ID, Google Cloud Identity, Okta, etc. This is currently a very desirable functionality, without it many companies will stop using Redmine."* — Mariusz Lichota, #37363#5
- *"Consider using https://github.com/kontron/redmine_oauth — there has been a lot of work done in this project, and we use it for authentication for quite a while now!"* — Marco Descher, #37363#6
- *"I've added this to 7.0.0 for now to discuss it, but I strongly believe we should implement this in some way."* — Marius BĂLTEANU (assignee), #43352#2
- *"The lack of support for modern auth / Single Sign On (SSO) is really a problem for Redmine to move forward. [...] ecosystem fragmentation across OAuth/OIDC plugins"* — Quentin Aymard, #43352#6

Two themes warrant explicit acknowledgement before the design:

1. The maintainer-assigned issue (#43352) is titled "OAuth Authentication" — broader than "OIDC". Felix Singer's #37363#2 comment likewise asks for plain OAuth2 providers (GitHub, GitLab, Google). This proposal scopes to OIDC-only for MVP for security reasons (see decision #2), but raises the broader scope as an open question for the maintainer.
2. The kontron/redmine_oauth plugin is the community's de-facto reference. Its architectural shape (dedicated multi-provider model, provider presets, encrypted secrets, PKCE) is the basis of this proposal; the disqualifying gap (no ID token signature verification) is the reason a native implementation is needed.

### What already exists in the community

Four third-party plugins were analysed in detail before drafting this proposal:

| Plugin | Approach | Notable strengths | Disqualifying weaknesses for "core" |
| --- | --- | --- | --- |
| `enricohuang/redmine_oidc` | Hand-rolled `net/http` client | `OidcUserLink` join table keyed on `(issuer, sub)` — survives email changes and provider switches | ID token signature is never verified; no PKCE; no nonce; no logout |
| `devopskube/redmine_openid_connect` | Hand-rolled, DB-backed `OicSession` | RP-initiated logout; refresh-token storage; group/role mapping via Keycloak `resource_access` claims | ID token decoded as base64 with no signature check; user matched by email only; forced auto-registration; project effectively unmaintained |
| `nanego/redmine_omniauth_oidc` | Built on `omniauth_openid_connect` | **Correct ID token signature verification via JWKS**; nonce/state handling; RP-initiated logout with `id_token_hint`; 2FA bypass toggle | No group/role mapping; no multi-provider; PKCE supported by underlying gem but not enabled; depends on two additional Redmine plugins |
| `kontron/redmine_oauth` (v4.0.7) | Raw `oauth2` + `jwt` gems | Multi-provider DB model; 7 provider presets; PKCE; per-provider button styling; encrypted client secret; group/role mapping; IMAP bonus | ID token decoded with `JWT.decode(token, nil, false)` — signature explicitly skipped; role-to-group via lastname matching is fragile |

The architectural shape of `kontron/redmine_oauth` is the closest to what a native implementation needs. Its single disqualifying flaw — skipped ID token signature verification — is exactly what `nanego/redmine_omniauth_oidc` gets right. This proposal merges those two strengths and adds the missing pieces.

### Redmine integration points (current trunk)

The relevant seams discovered while mapping the codebase:

- `app/controllers/account_controller.rb` — `successful_authentication` fires the hook `controller_account_success_authentication_after`; on-the-fly registration uses `session[:auth_source_registration]`.
- `app/models/user.rb` — `User.try_to_login!` already handles external-auth-source user creation; we will not modify this path but will reuse its session-establishment idioms.
- `app/models/auth_source.rb` — pluggable STI abstraction for LDAP today. **This design deliberately does not extend AuthSource** because OIDC's browser-redirect flow does not fit the `authenticate(login, password)` contract.
- `config/routes.rb` — adding a top-level `/oidc/*` namespace and `/admin/oidc_providers` admin namespace.
- `Gemfile` — adding `jwt` gem for signature verification (well-maintained, minimal transitive deps).

---

## Goals and non-goals

### MVP goals

1. Native OpenID Connect 1.0 authentication for Redmine, no plugin layer.
2. Multi-provider support — admins can run Keycloak + Google + Auth0 simultaneously, each with its own button on the login page.
3. Provider presets for Keycloak, Okta, Auth0, Azure AD (Microsoft Entra ID), Google (Workspace / Cloud Identity), plus a fully-configurable generic OIDC profile.
4. Cryptographically correct ID token validation (JWKS-based signature, all standard claim checks).
5. Mandatory PKCE (S256) on every flow.
6. Universal IdP-agnostic claim-to-role mapping via configurable dot-notation paths.
7. Full SSO logout propagation: RP-initiated logout + Back-Channel Logout endpoint.
8. Token lifecycle: lazy refresh-on-expiry with configurable buffer, opt-in periodic introspection.
9. 2FA strategy that respects IdP's MFA assertion and falls back to Redmine TOTP setup when needed.
10. Auto-provisioning of new users — opt-in per provider, default off.
11. EN and CS translations.
12. Comprehensive test coverage (unit, functional, integration with stubbed IdP).

### Non-goals (out of MVP scope)

- Background token refresh for idle users (deferred — only relevant when an admin needs role propagation faster than next-action latency for offline users).
- Migration importers from any existing OIDC plugin.
- Provider-level "Migrate Issuer URL" admin action.
- SAML support.
- Plain OAuth2 (non-OIDC) providers like GitHub or GitLab.
- LDAP+OIDC user merging.
- SCIM provisioning.
- Front-Channel Logout (deprecated in favor of BCL).
- Step-up authentication.
- Mobile / native client support.

---

## Decision log

The following design decisions were made during brainstorming. Each is open to revision based on maintainer feedback.

| # | Topic | Decision | Rationale |
| --- | --- | --- | --- |
| 1 | Model | Dedicated `OidcProvider` model, not `AuthSource` STI | OIDC's browser-redirect flow does not fit `authenticate(login, password)` contract; multi-provider with per-provider quirks complicates STI |
| 2 | Protocols | **OIDC only in MVP** (open to revision — see Open questions #9) | Plain OAuth2 has no signed identity assertion — heterogenous and security-weaker than OIDC. Note: #43352 issue title and #37363#2 community comments suggest plain OAuth2 (GitHub, GitLab) is also desired. Raised as explicit open question for maintainer |
| 3 | MVP presets | Keycloak, Okta, Auth0, **Azure AD (Microsoft Entra ID)**, **Google (Workspace / Cloud Identity)** + Custom | Covers ~95% of enterprise IdPs out of the box. Specific IdPs from #37363#5 (Mariusz Lichota): Entra ID, Google Cloud Identity, Okta |
| 4 | Multi-provider | Multiple active simultaneously, one button each | Real use case: internal Keycloak + external Google contractors |
| 5 | Login UX | Local form on top, OIDC buttons below | Standard pattern (GitLab, Discourse); preserves emergency local admin login |
| 6 | User matching | `(issuer, sub)` link table; email bootstrap only with `email_verified=true` | Stable identity across email changes; prevents account-takeover via unverified email |
| 7 | Auto-provisioning | Opt-in per provider, default off | Safe default; admin must consciously delegate user creation to IdP |
| 8 | Role mapping | `role_strategy` enum: `group` (default) / `project_role` / `none`, with configurable dot-notation claim path | Universal across IdPs; supports both lightweight (group) and IdP-centric (project_role) authorization models |
| 9 | Logout | Local + RP-initiated default; BCL endpoint default-on | Full SSO propagation requires all three mechanisms |
| 10 | Token storage | `id` + `access` + `refresh` encrypted via `Redmine::Ciphering` in `oidc_user_links` | Needed for refresh, BCL, RP-initiated logout |
| 11 | Refresh strategy | Lazy on-request with 300s default buffer; pessimistic row lock for concurrency; **no background refresh in MVP** | Reactive design avoids dedicated scheduler infrastructure; row lock prevents refresh-token rotation races |
| 12 | Revocation layers | L1 lazy refresh + L2 BCL default; L3 introspection opt-in; L4 background deferred | Layered defense; admins pick level based on policy |
| 13 | Silent SSO | `prompt=none` redirect from a designated `is_primary` provider; opt-in per provider | Enables true cross-system "logged in once = logged in everywhere" |
| 14 | Target version | Trunk only (Rails 8.1, Ruby 3.2+) | No backport burden; aligns with 7.0.0 milestone |
| 15 | Plugin migration | None in MVP, documentation only | Importers double scope without proportional value |
| 16 | 2FA interaction | `bypass_if_idp_mfa` default; trusts `amr`/`acr`; falls back to Redmine TOTP setup wizard for auto-provisioned users | Honors IdP MFA when asserted; safely covers misconfigured IdPs |
| 17 | OIDC Discovery | Mandatory; admin only enters `issuer_url` | Reduces configuration errors by ~90% |
| 18 | Email bootstrap | Requires `email_verified=true` (defaults to false if absent) | Prevents account-takeover |
| 19 | Localization | EN + CS in MVP; other languages via community contribution at upstream PR time | Matches typical Redmine i18n contribution pattern |
| 20 | Issuer URL mutability | Immutable after creation; admin must delete + recreate | Cascade update is unsafe — can silently corrupt identity if admin actually moved IdP |
| 21 | Auth source coexistence | A Redmine user can simultaneously have local password, `auth_source_id` (LDAP), AND one or more `oidc_user_links`. No enforced exclusivity. | Migration path from LDAP to OIDC must not require breaking existing accounts; auto-provisioned OIDC users coexist with LDAP-provisioned accounts side by side |

---

## Architecture

### 1. Database schema

Three new tables plus minor extensions of existing membership tables.

```sql
-- 1. OIDC provider configuration (admin-managed)
CREATE TABLE oidc_providers (
  id                          BIGINT PRIMARY KEY,
  name                        VARCHAR(60)  NOT NULL,
  display_name                VARCHAR(60)  NOT NULL,
  preset                      VARCHAR(20)  NOT NULL,
  enabled                     BOOLEAN      NOT NULL DEFAULT TRUE,
  position                    INTEGER      NOT NULL DEFAULT 0,
  is_primary                  BOOLEAN      NOT NULL DEFAULT FALSE,

  issuer_url                  VARCHAR(255) NOT NULL,
  client_id                   VARCHAR(255) NOT NULL,
  client_secret_ciphertext    TEXT         NOT NULL,
  scopes                      VARCHAR(255) NOT NULL DEFAULT 'openid email profile',

  discovery_cache             TEXT,
  discovery_cached_at         DATETIME,
  jwks_cache                  TEXT,
  jwks_cached_at              DATETIME,

  uid_claim                   VARCHAR(40)  NOT NULL DEFAULT 'sub',
  email_claim                 VARCHAR(40)  NOT NULL DEFAULT 'email',
  firstname_claim             VARCHAR(40)  NOT NULL DEFAULT 'given_name',
  lastname_claim              VARCHAR(40)  NOT NULL DEFAULT 'family_name',

  role_strategy               VARCHAR(20)  NOT NULL DEFAULT 'group',
  roles_claim_path            VARCHAR(255),
  project_role_separator      VARCHAR(5)   NOT NULL DEFAULT '.',
  admin_role_values           TEXT,
  strip_role_prefix           VARCHAR(40),

  auto_provision              BOOLEAN      NOT NULL DEFAULT FALSE,
  auto_provision_status       INTEGER      NOT NULL DEFAULT 1,

  enable_rp_initiated_logout  BOOLEAN      NOT NULL DEFAULT TRUE,
  enable_back_channel_logout  BOOLEAN      NOT NULL DEFAULT TRUE,
  silent_sso_attempt          BOOLEAN      NOT NULL DEFAULT FALSE,

  refresh_buffer_seconds      INTEGER      NOT NULL DEFAULT 300,

  enable_introspection        BOOLEAN      NOT NULL DEFAULT FALSE,
  introspection_cache_seconds INTEGER      NOT NULL DEFAULT 300,

  twofa_strategy              VARCHAR(30)  NOT NULL DEFAULT 'bypass_if_idp_mfa',
  idp_mfa_amr_values          TEXT,
  idp_mfa_acr_values          TEXT,

  button_color                VARCHAR(7),
  button_icon                 VARCHAR(40),

  created_at                  DATETIME     NOT NULL,
  updated_at                  DATETIME     NOT NULL
);
CREATE UNIQUE INDEX idx_oidc_providers_name ON oidc_providers(name);
CREATE INDEX idx_oidc_providers_position ON oidc_providers(position);
CREATE UNIQUE INDEX idx_oidc_providers_primary
  ON oidc_providers(is_primary) WHERE is_primary = TRUE;

-- 2. Stable link between Redmine user and IdP identity
CREATE TABLE oidc_user_links (
  id                          BIGINT PRIMARY KEY,
  user_id                     INTEGER      NOT NULL,
  oidc_provider_id            BIGINT       NOT NULL,
  issuer                      VARCHAR(255) NOT NULL,
  sub                         VARCHAR(255) NOT NULL,
  sid                         VARCHAR(255),

  access_token_ciphertext     TEXT,
  refresh_token_ciphertext    TEXT,
  id_token_ciphertext         TEXT,
  access_token_expires_at     DATETIME,
  refresh_token_expires_at    DATETIME,

  last_synced_at              DATETIME,
  last_introspected_at        DATETIME,

  created_at                  DATETIME     NOT NULL,
  updated_at                  DATETIME     NOT NULL,

  FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
  FOREIGN KEY (oidc_provider_id) REFERENCES oidc_providers(id) ON DELETE CASCADE
);
CREATE UNIQUE INDEX idx_oidc_links_issuer_sub ON oidc_user_links(issuer, sub);
CREATE INDEX idx_oidc_links_user ON oidc_user_links(user_id);
CREATE INDEX idx_oidc_links_provider ON oidc_user_links(oidc_provider_id);

-- 3. Memberships tracking (for project_role strategy)
ALTER TABLE members
  ADD COLUMN oidc_managed_by_provider_id BIGINT NULL
  REFERENCES oidc_providers(id) ON DELETE SET NULL;
CREATE INDEX idx_members_oidc_managed ON members(oidc_managed_by_provider_id);

-- 4. Groups tracking (for group strategy)
ALTER TABLE groups_users
  ADD COLUMN oidc_managed_by_provider_id BIGINT NULL;
```

Key design choices:

- **Single configuration table** (no key-value abstraction). New configuration options require a migration. Pragmatic given the small finite set of OIDC parameters.
- **All secret material encrypted** via `Redmine::Ciphering` (the mechanism already used for `auth_sources.account_password` and the kontron plugin's `client_secret`).
- **`oidc_user_links.issuer` denormalised** from `oidc_providers.issuer_url` for fast `(issuer, sub)` lookup without joins. Issuer URL is immutable on the provider record to keep this consistent.
- **`oidc_managed_by_provider_id`** on `members` and `groups_users` distinguishes OIDC-synchronised assignments from manually-curated ones. Manual entries are never overwritten by sync.

### 2. Code structure

**Deferred pending maintainer input on #43352.** The user explicitly requested confirmation of upstream contribution conventions before committing to a file layout. The design otherwise assumes pure core integration (no plugin layer) in line with how `AuthSourceLdap` is organised today. The likely shape:

- `app/models/oidc_provider.rb`, `app/models/oidc_user_link.rb`
- `app/controllers/oidc_controller.rb`, `app/controllers/oidc_providers_controller.rb`
- `lib/redmine/oidc/*` for the discovery, JWKS, validator, client, role mapper, and session manager primitives
- `app/views/oidc_providers/*`, partial in `app/views/account/`
- Migration in `db/migrate/`
- Tests in `test/unit/`, `test/functional/`, `test/integration/`

Final layout to be confirmed.

### 3. Authentication flow — login

User clicks "Login with Keycloak" on the login screen (or a silent SSO redirect kicks in).

```text
GET /oidc/providers/:id/login?return_to=/projects/myproject

OidcController#login:
  1. provider = OidcProvider.find(params[:id])
     If disabled or not found → 404 + log warning

  2. Load discovery metadata (DB cache; refresh inline if > 24h old)
     authorization_endpoint, token_endpoint, jwks_uri, end_session_endpoint, ...

  3. Generate security parameters:
     state          = SecureRandom.urlsafe_base64(32)
     nonce          = SecureRandom.urlsafe_base64(32)
     code_verifier  = SecureRandom.urlsafe_base64(64)
     code_challenge = Base64URL(SHA256(code_verifier))

  4. Persist into session (short-lived, only between redirect and callback):
     session[:oidc_state], session[:oidc_nonce], session[:oidc_code_verifier]
     session[:oidc_provider_id], session[:oidc_return_to]
     session[:oidc_silent] = true if silent SSO

  5. Build authorization URL with PKCE and redirect.
```

**Silent SSO** is a separate entry point at `/oidc/silent_login`. The login view auto-triggers it when (a) user not authenticated, (b) a primary provider with `silent_sso_attempt=true` exists, (c) no silent attempt in the last 5 minutes (per-session timestamp).

Security invariants: `state` round-trips through session, `nonce` matches the ID token, PKCE S256 is mandatory, `code_verifier` never leaks to the client, `return_to` is sanitized against open-redirect, `redirect_uri` is built from `request.host`/`protocol` rather than user input.

### 4. Authentication flow — callback and session lifecycle

The callback path validates state, exchanges the code for tokens, validates the ID token cryptographically, locates or creates the user, persists tokens, applies role/group sync, handles 2FA, and establishes the Redmine session.

```text
GET /oidc/callback?code=<authz_code>&state=<state>

OidcController#callback:
  1. Validate state (matches session, single-use, deleted on consume).
  2. Exchange code for tokens (token endpoint, PKCE code_verifier).
  3. Validate ID token: JWKS signature, iss, aud, exp, iat, nonce.
  4. Extract identity and role claims using provider's configured paths.
  5. Locate user via (issuer, sub) link, OR via email_verified bootstrap, OR auto-provision.
  6. Persist encrypted tokens, sid, expiries on the link record.
  7. Apply RoleMapper.sync!.
  8. Handle 2FA per provider.twofa_strategy.
  9. Establish session (session[:user_id], session[:oidc_link_id]).
  10. Redirect to return_to.
```

**Per-request session lifecycle middleware** runs after Redmine's standard `find_current_user`. For OIDC-authenticated users it:

1. **Lazy refresh on expiry** — if `Time.now >= access_token_expires_at - refresh_buffer_seconds`, refresh the token under a pessimistic row lock. On `invalid_grant`, destroy the link and reset the session (IdP-side revocation propagates here).
2. **Optional introspection** (when `enable_introspection=true`) — call `/introspect`, no more frequently than `introspection_cache_seconds`. Inactive token → reset session.

The row lock is essential: concurrent requests within the buffer window must serialise on refresh, because IdPs that rotate refresh tokens (Keycloak, Auth0) invalidate the old token on use, so racing refresh calls would revoke each other.

### 5. Logout — RP-initiated and Back-Channel

**RP-initiated logout** (user clicks Sign out in Redmine):

1. Capture `id_token_hint` from the link before clearing anything.
2. Best-effort token revocation against IdP `revocation_endpoint`.
3. Destroy the `OidcUserLink`.
4. Reset Redmine session.
5. Redirect to IdP `end_session_endpoint?id_token_hint=...&post_logout_redirect_uri=.../logout_complete&client_id=...`.

**Back-Channel Logout** (IdP push to Redmine):

A public endpoint at `POST /oidc/providers/:id/backchannel_logout` accepts a signed `logout_token` JWT. The token is verified via the same JWKS infrastructure as the ID token validator, with the spec-mandated additional checks: `events` claim contains `http://schemas.openid.net/event/backchannel-logout`, no `nonce` claim, `jti` recorded for 5-minute replay protection.

On valid logout: find `OidcUserLink` rows matching `(provider_id, iss, sub, sid)` (where present), destroy associated server-side session rows, and destroy the links.

BCL requires a server-side session store (default Redmine uses cookie-based sessions). The admin UI surfaces this as a banner; setup instructions live in docs/oidc-setup.md.

**Full SSO logout propagation matrix:**

| Trigger | Mechanism |
| --- | --- |
| User clicks Sign out in Redmine | RP-initiated → IdP end_session → IdP pushes BCL to other RPs |
| User signs out in another RP (e.g. Mattermost) | That RP → IdP → BCL push to Redmine |
| Admin signs out user in IdP admin console | IdP pushes BCL to all RPs |
| Admin disables user in IdP (Keycloak, Okta, Auth0) | IdP pushes BCL on disable |

### 6. Claim mapping — three universal strategies

Every strategy uses a common configurable dot-notation claim path (e.g., `resource_access.redmine.roles`, `groups`, `roles`, `https://app.example.com/roles`) so that the same code works with Keycloak (client roles), Okta (`groups`), Auth0 (custom namespaced claims), Azure AD (`roles`), Google Workspace (`groups`), or any generic OIDC provider.

**Strategy `:none`** — no-op.

**Strategy `:group`** (default) — claim values map to Redmine `Group` names. The admin pre-creates groups and assigns them to projects with specific roles. OIDC then drives membership. Example: claim `["developers", "reviewers"]` adds the user to the `developers` and `reviewers` groups; existing memberships in groups not present in the claim and managed by this provider are removed.

**Strategy `:project_role`** — claim values formatted as `<project_identifier><separator><role_name>` map directly to `Member` × `Role` records. Example: claim `["acme.developer", "acme.manager", "blog.reporter"]` creates `Member(user, acme, Developer)`, `Member(user, acme, Manager)`, `Member(user, blog, Reporter)`. Unknown projects/roles are silently ignored.

For both strategies: `admin_role_values` is an explicit per-provider whitelist of claim values that grant Redmine admin flag. The flag is only managed when this list is non-empty (admin opt-in). Manually-assigned groups/memberships are never overwritten — tracked via the `oidc_managed_by_provider_id` column.

All sync is idempotent (running with the same claims twice yields the same state) and wrapped in `User.transaction`. The mapper is invoked at initial login and on every successful token refresh.

### 7. Admin UI

Three screens:

1. **Provider list** at `/admin/oidc_providers` — sortable table with drag-reorder, enable/primary toggles, row menu (Edit / Test connection / Refresh metadata / Disable / Delete).
2. **Provider create wizard** — two steps: preset selection, then preset-aware configuration form covering identification, connection (with `[Test connection]` button), claim mapping, role mapping with conditional fields per strategy, auto-provisioning, token & session, logout, 2FA, appearance (button colour and icon).
3. **Provider edit** — same form with `issuer_url` disabled (immutability) and client secret masked (re-saved only when non-empty).

`AccountController#login` view renders a single partial below the username/password form. The partial lists enabled providers ordered by `position`, each as a POST form to `/oidc/providers/:id/login`.

One global admin setting in `Administration > Settings > Authentication`: `oidc_show_local_login` (default true). Also displayed there: the BCL endpoint URL pattern to register in the IdP, per-provider.

### 8. Testing and security

**Test pyramid:**

- ~100 unit tests covering every `lib/redmine/oidc/*` primitive and both AR models. Critical concentration on ID token validation (positive and negative paths), PKCE, JWKS rotation, claim extraction edge cases, role-mapper diff logic, refresh row-lock serialisation.
- ~30 functional tests covering controller actions including BCL endpoint with valid and invalid logout tokens.
- ~5 integration tests covering the full flow end-to-end with `WebMock`-stubbed IdP discovery, JWKS, token, userinfo, and introspection endpoints.

`test/fixtures/oidc/` holds pre-baked discovery documents and JWKS for each preset. `test/support/oidc_helpers.rb` provides helpers like `sign_id_token(claims, key:)` and `stub_token_exchange(...)`.

The full Redmine test suite must remain green. We do not add live-IdP integration tests in MVP (CI complexity); a manual compatibility test suite against real Keycloak/Okta/Auth0 instances is documented separately.

**Security checklist** — gating criterion for patch submission (every item must be code-verifiable):

- ID token signature ALWAYS verified using JWKS (RS256/ES256). `alg: none` and HMAC algorithms rejected.
- ID token claims validated: `iss` exact match, `aud` contains client_id, `exp` future, `iat` within ±5 min, `nonce` matches.
- PKCE S256 mandatory.
- `state` and `nonce` cryptographically random, single-use.
- All persisted tokens encrypted via `Redmine::Ciphering`.
- Tokens never logged plaintext.
- Email bootstrap requires `email_verified=true`.
- After bootstrap, lookup strictly by `(issuer, sub)`.
- BCL endpoint `skip_forgery_protection` limited to that single action.
- `return_to` sanitised against open-redirect.
- `redirect_uri` built from request, not user input.
- `id_token_hint` captured before session reset.
- BCL `jti` replay cache active.
- BCL logout_token rejected if it contains `nonce` claim.
- Admin flag only sync-controlled when `admin_role_values` is non-empty.
- Manual group and membership assignments never overwritten.
- `client_secret` never returned in API responses.
- All IdP HTTP calls have 5s timeout.
- No retry loop that could amplify load on a struggling IdP.
- Authentication and role-mapping changes logged at INFO; security warnings at WARN.

---

## Open questions for the maintainer

Items where we explicitly seek upstream direction before implementation:

1. **Code structure / file layout** — Pure core integration in `app/`, `lib/redmine/oidc/`, etc.? Or a different organisation you have in mind?
2. **Patch series shape** — Is a single large patch acceptable, or would you prefer the work split into a series (e.g., DB schema + models, discovery/JWKS primitives, login flow, role mapping, admin UI, BCL, tests)?
3. **Gem dependency** — Adding `jwt` (`~> 2.x`) is the minimum to do JWKS signature verification properly. Is that acceptable? Alternatives are heavier (`omniauth_openid_connect` pulls OmniAuth) or unsafe (manual base64).
4. **Default UX of the login page** — Render OIDC buttons under the username/password form by default? Or behind an admin setting? Or only when providers exist? (MVP plan: only when at least one provider is enabled.)
5. **AuthSource vs. dedicated model** — This proposal does not extend `AuthSource`. Confirm this is the preferred shape, or do you want OIDC to live as a new `AuthSource` subclass (with the understood awkwardness)?
6. **Server-side session store requirement for BCL** — Acceptable to require admins to switch from cookie sessions to ActiveRecord sessions for BCL to be effective, or do you want BCL gated behind that change being completed?
7. **`Setting` integration** — One global setting (`oidc_show_local_login`) is proposed. Any others you'd add or remove?
8. **Localization scope** — EN + CS for MVP, other languages via community contributions during review. Or do you want a broader baseline?
9. **Scope: OIDC-only vs OIDC + plain OAuth2** — The issue title for #43352 says "OAuth Authentication" and #37363#2 (Felix Singer) explicitly asks for plain OAuth2 providers (GitHub, GitLab, Google OAuth). This proposal is OIDC-only for MVP because plain OAuth2 lacks a signed identity assertion (no ID token, no JWKS, identity trust rests on TLS to a userinfo-style endpoint). Two options for the upstream direction:
    - **(a) OIDC-only in MVP**, plain OAuth2 deferred — the design's "Custom" preset already covers any OIDC-compliant provider including federation gateways that sit in front of non-OIDC services.
    - **(b) OIDC + plain OAuth2 in MVP** — adds GitHub, GitLab, and Google OAuth2 (the three Felix Singer named). Doubles the surface area, weakens the security baseline (no signed identity), but matches the issue title.
    Our recommendation is (a), but the maintainer's preference should drive this.
10. **Prior art reference** — #43352#5 (Jan Catrysse) links to a "v2.0.0 feature branch" of an alternative SSO implementation. We did not review it; please point us at it if it informs the design.

---

## References

- OpenID Connect Core 1.0 — <https://openid.net/specs/openid-connect-core-1_0.html>
- OpenID Connect Discovery 1.0 — <https://openid.net/specs/openid-connect-discovery-1_0.html>
- OpenID Connect Back-Channel Logout 1.0 — <https://openid.net/specs/openid-connect-backchannel-1_0.html>
- RFC 7636 — Proof Key for Code Exchange by OAuth Public Clients (PKCE)
- RFC 7009 — OAuth 2.0 Token Revocation
- RFC 7662 — OAuth 2.0 Token Introspection
- Redmine issue #43352 — OAuth Authentication for Redmine (target 7.0.0, assigned)
- Redmine issue #37363 — Add native support for OIDC
- Plugins analysed: `enricohuang/redmine_oidc`, `devopskube/redmine_openid_connect`, `nanego/redmine_omniauth_oidc`, `kontron/redmine_oauth`
