Feature #43352 » native-openid-connect-design.md
Native OpenID Connect Support for Redmine — Design Proposal
| Field | Value |
|---|---|
| Date | 2026-05-24 |
| Status | Draft proposal — for discussion on redmine.org issue #43352 |
| Target Redmine version | 7.0.0 |
| Related issues | #43352, #37363, #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; OIDC has been an open feature request in #37363 for four years, and #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:
- 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.
- 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_authenticationfires the hookcontroller_account_success_authentication_after; on-the-fly registration usessession[: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 theauthenticate(login, password)contract.config/routes.rb— adding a top-level/oidc/*namespace and/admin/oidc_providersadmin namespace.Gemfile— addingjwtgem for signature verification (well-maintained, minimal transitive deps).
Goals and non-goals
MVP goals
- Native OpenID Connect 1.0 authentication for Redmine, no plugin layer.
- Multi-provider support — admins can run Keycloak + Google + Auth0 simultaneously, each with its own button on the login page.
- Provider presets for Keycloak, Okta, Auth0, Azure AD (Microsoft Entra ID), Google (Workspace / Cloud Identity), plus a fully-configurable generic OIDC profile.
- Cryptographically correct ID token validation (JWKS-based signature, all standard claim checks).
- Mandatory PKCE (S256) on every flow.
- Universal IdP-agnostic claim-to-role mapping via configurable dot-notation paths.
- Full SSO logout propagation: RP-initiated logout + Back-Channel Logout endpoint.
- Token lifecycle: lazy refresh-on-expiry with configurable buffer, opt-in periodic introspection.
- 2FA strategy that respects IdP's MFA assertion and falls back to Redmine TOTP setup when needed.
- Auto-provisioning of new users — opt-in per provider, default off.
- EN and CS translations.
- 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.
-- 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 forauth_sources.account_passwordand the kontron plugin'sclient_secret). oidc_user_links.issuerdenormalised fromoidc_providers.issuer_urlfor fast(issuer, sub)lookup without joins. Issuer URL is immutable on the provider record to keep this consistent.oidc_managed_by_provider_idonmembersandgroups_usersdistinguishes 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.rbapp/controllers/oidc_controller.rb,app/controllers/oidc_providers_controller.rblib/redmine/oidc/*for the discovery, JWKS, validator, client, role mapper, and session manager primitivesapp/views/oidc_providers/*, partial inapp/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).
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.
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:
- Lazy refresh on expiry — if
Time.now >= access_token_expires_at - refresh_buffer_seconds, refresh the token under a pessimistic row lock. Oninvalid_grant, destroy the link and reset the session (IdP-side revocation propagates here). - Optional introspection (when
enable_introspection=true) — call/introspect, no more frequently thanintrospection_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):
- Capture
id_token_hintfrom the link before clearing anything. - Best-effort token revocation against IdP
revocation_endpoint. - Destroy the
OidcUserLink. - Reset Redmine session.
- 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:
- Provider list at
/admin/oidc_providers— sortable table with drag-reorder, enable/primary toggles, row menu (Edit / Test connection / Refresh metadata / Disable / Delete). - 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). - Provider edit — same form with
issuer_urldisabled (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: noneand HMAC algorithms rejected. - ID token claims validated:
issexact match,audcontains client_id,expfuture,iatwithin ±5 min,noncematches. - PKCE S256 mandatory.
stateandnoncecryptographically 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_protectionlimited to that single action. return_tosanitised against open-redirect.redirect_uribuilt from request, not user input.id_token_hintcaptured before session reset.- BCL
jtireplay cache active. - BCL logout_token rejected if it contains
nonceclaim. - Admin flag only sync-controlled when
admin_role_valuesis non-empty. - Manual group and membership assignments never overwritten.
client_secretnever 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:
- Code structure / file layout — Pure core integration in
app/,lib/redmine/oidc/, etc.? Or a different organisation you have in mind? - 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)?
- Gem dependency — Adding
jwt(~> 2.x) is the minimum to do JWKS signature verification properly. Is that acceptable? Alternatives are heavier (omniauth_openid_connectpulls OmniAuth) or unsafe (manual base64). - 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.)
- 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 newAuthSourcesubclass (with the understood awkwardness)? - 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?
Settingintegration — One global setting (oidc_show_local_login) is proposed. Any others you'd add or remove?- Localization scope — EN + CS for MVP, other languages via community contributions during review. Or do you want a broader baseline?
- 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.
- 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